mirror of
https://github.com/PrimalHQ/primal-web-app.git
synced 2024-09-30 00:41:09 +00:00
Onboarding basic login flow
This commit is contained in:
parent
01c5fde775
commit
823f94df04
@ -29,6 +29,7 @@ const NotFound = lazy(() => import('./pages/NotFound'));
|
||||
const EditProfile = lazy(() => import('./pages/EditProfile'));
|
||||
const Profile = lazy(() => import('./pages/Profile'));
|
||||
const Mutelist = lazy(() => import('./pages/Mutelist'));
|
||||
const CreateAccount = lazy(() => import('./pages/CreateAccount'));
|
||||
|
||||
const NotifSettings = lazy(() => import('./pages/Settings/Notifications'));
|
||||
const Appearance = lazy(() => import('./pages/Settings/Appearance'));
|
||||
@ -118,6 +119,7 @@ const Router: Component = () => {
|
||||
<Route path="/search/:query" component={Search} />
|
||||
<Route path="/rest" component={Explore} />
|
||||
<Route path="/mutelist/:npub" component={Mutelist} />
|
||||
<Route path="/new" component={CreateAccount} />
|
||||
<Route path="/404" component={NotFound} />
|
||||
<Route path="/:vanityName" component={Profile} data={getKnownProfiles} />
|
||||
</Route>
|
||||
|
28
src/components/Buttons/ButtonLink.tsx
Normal file
28
src/components/Buttons/ButtonLink.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import { Component, JSXElement, Match, Show, Switch } from 'solid-js';
|
||||
import { hookForDev } from '../../lib/devTools';
|
||||
import { Button } from "@kobalte/core";
|
||||
|
||||
import styles from './Buttons.module.scss';
|
||||
|
||||
const ButtonLink: Component<{
|
||||
id?: string,
|
||||
onClick?: (e: MouseEvent) => void,
|
||||
children?: JSXElement,
|
||||
disabled?: boolean,
|
||||
title?: string,
|
||||
}> = (props) => {
|
||||
|
||||
return (
|
||||
<Button.Root
|
||||
id={props.id}
|
||||
class={styles.link}
|
||||
onClick={props.onClick}
|
||||
disabled={props.disabled}
|
||||
title={props.title}
|
||||
>
|
||||
{props.children}
|
||||
</Button.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export default hookForDev(ButtonLink);
|
@ -161,3 +161,28 @@
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.link {
|
||||
display: inline;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: fit-content;
|
||||
height: fit-content;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
font-size: 16px;
|
||||
line-height: 20px;
|
||||
font-weight: 700;
|
||||
background: none;
|
||||
color: var(--accent-1);
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,84 @@
|
||||
.modal {
|
||||
position: fixed;
|
||||
width: 420px;
|
||||
color: var(--text-secondary);
|
||||
background-color: var(--background-site);
|
||||
background: linear-gradient(var(--background-site),
|
||||
var(--background-site)) padding-box,
|
||||
var(--brand-gradient) border-box;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 22px;
|
||||
|
||||
.xClose {
|
||||
background: none;
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: fit-content;
|
||||
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
right: 18px;
|
||||
|
||||
.iconClose {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
display: inline-block;
|
||||
margin: 0px 0px;
|
||||
background-color: var(--text-secondary);
|
||||
-webkit-mask: url(../../assets/icons/close.svg) no-repeat center;
|
||||
mask: url(../../assets/icons/close.svg) no-repeat center;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.iconClose {
|
||||
background-color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 800;
|
||||
font-size: 18px;
|
||||
line-height: 18px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--text-secondary);
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
margin-bottom: 20px;
|
||||
|
||||
button {
|
||||
width: fit-content;
|
||||
padding: 10px;
|
||||
margin: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.alternative {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
}
|
66
src/components/CreateAccountModal/CreateAccountModal.tsx
Normal file
66
src/components/CreateAccountModal/CreateAccountModal.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import { useIntl } from '@cookbook/solid-intl';
|
||||
import { Component, createEffect, createSignal, For, Show } from 'solid-js';
|
||||
import { useAccountContext } from '../../contexts/AccountContext';
|
||||
import { useSettingsContext } from '../../contexts/SettingsContext';
|
||||
import { zapNote } from '../../lib/zap';
|
||||
import { userName } from '../../stores/profile';
|
||||
import { toastZapFail, zapCustomOption } from '../../translations';
|
||||
import { PrimalNote } from '../../types/primal';
|
||||
import { debounce } from '../../utils';
|
||||
import Modal from '../Modal/Modal';
|
||||
import { useToastContext } from '../Toaster/Toaster';
|
||||
|
||||
import { account as t, actions as tActions } from '../../translations';
|
||||
|
||||
import styles from './CreateAccountModal.module.scss';
|
||||
import { hookForDev } from '../../lib/devTools';
|
||||
import ButtonPrimary from '../Buttons/ButtonPrimary';
|
||||
import ButtonLink from '../Buttons/ButtonLink';
|
||||
import { useNavigate } from '@solidjs/router';
|
||||
|
||||
const CreateAccountModal: Component<{
|
||||
id?: string,
|
||||
open?: boolean,
|
||||
onLogin?: () => void,
|
||||
onAbort?: () => void,
|
||||
}> = (props) => {
|
||||
|
||||
const intl = useIntl();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const onCreateAccount = () => {
|
||||
navigate('/new');
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={props.open}>
|
||||
<div id={props.id} class={styles.modal}>
|
||||
<button class={styles.xClose} onClick={props.onAbort}>
|
||||
<div class={styles.iconClose}></div>
|
||||
</button>
|
||||
<div class={styles.title}>
|
||||
{intl.formatMessage(tActions.getStarted)}
|
||||
</div>
|
||||
<div class={styles.description}>
|
||||
{intl.formatMessage(t.createNewDescription)}
|
||||
</div>
|
||||
<div class={styles.actions}>
|
||||
<ButtonPrimary
|
||||
onClick={onCreateAccount}
|
||||
>
|
||||
{intl.formatMessage(tActions.createAccount)}
|
||||
</ButtonPrimary>
|
||||
</div>
|
||||
<div class={styles.alternative}>
|
||||
Already have Nostr a account?
|
||||
<ButtonLink onClick={props.onLogin}>
|
||||
{intl.formatMessage(tActions.loginNow)}
|
||||
</ButtonLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default hookForDev(CreateAccountModal);
|
94
src/components/CreatePinModal/CreatePinModal.module.scss
Normal file
94
src/components/CreatePinModal/CreatePinModal.module.scss
Normal file
@ -0,0 +1,94 @@
|
||||
.modal {
|
||||
position: fixed;
|
||||
width: 452px;
|
||||
color: var(--text-secondary);
|
||||
background-color: var(--background-site);
|
||||
background: linear-gradient(var(--background-site),
|
||||
var(--background-site)) padding-box,
|
||||
var(--brand-gradient) border-box;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 22px;
|
||||
|
||||
.xClose {
|
||||
background: none;
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: fit-content;
|
||||
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
right: 18px;
|
||||
|
||||
.iconClose {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
display: inline-block;
|
||||
margin: 0px 0px;
|
||||
background-color: var(--text-secondary);
|
||||
-webkit-mask: url(../../assets/icons/close.svg) no-repeat center;
|
||||
mask: url(../../assets/icons/close.svg) no-repeat center;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.iconClose {
|
||||
background-color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 800;
|
||||
font-size: 18px;
|
||||
line-height: 18px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--text-secondary);
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.inputs {
|
||||
margin-bottom: 4px;
|
||||
> div {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
|
||||
button {
|
||||
width: fit-content;
|
||||
min-width: 192px;
|
||||
margin: 0px;
|
||||
>div {
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.alternative {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
}
|
118
src/components/CreatePinModal/CreatePinModal.tsx
Normal file
118
src/components/CreatePinModal/CreatePinModal.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
import { useIntl } from '@cookbook/solid-intl';
|
||||
import { Component, createEffect, createSignal, For, Match, Show, Switch } from 'solid-js';
|
||||
import { useAccountContext } from '../../contexts/AccountContext';
|
||||
import { useSettingsContext } from '../../contexts/SettingsContext';
|
||||
import { zapNote } from '../../lib/zap';
|
||||
import { userName } from '../../stores/profile';
|
||||
import { toastZapFail, zapCustomOption } from '../../translations';
|
||||
import { PrimalNote } from '../../types/primal';
|
||||
import { debounce } from '../../utils';
|
||||
import Modal from '../Modal/Modal';
|
||||
import { useToastContext } from '../Toaster/Toaster';
|
||||
|
||||
import { login as tLogin, pin as tPin, actions as tActions } from '../../translations';
|
||||
|
||||
import styles from './CreatePinModal.module.scss';
|
||||
import { hookForDev } from '../../lib/devTools';
|
||||
import ButtonPrimary from '../Buttons/ButtonPrimary';
|
||||
import ButtonLink from '../Buttons/ButtonLink';
|
||||
import { useNavigate } from '@solidjs/router';
|
||||
import TextInput from '../TextInput/TextInput';
|
||||
import ButtonSecondary from '../Buttons/ButtonSecondary';
|
||||
|
||||
const CreatePinModal: Component<{
|
||||
id?: string,
|
||||
open?: boolean,
|
||||
valueToEncrypt?: string,
|
||||
onPinApplied?: (encryptedValue: string) => void,
|
||||
onAbort?: () => void,
|
||||
}> = (props) => {
|
||||
|
||||
const intl = useIntl();
|
||||
const navigate = useNavigate();
|
||||
|
||||
let pinInput: HTMLInputElement | undefined;
|
||||
|
||||
const [pin, setPin] = createSignal('');
|
||||
const [rePin, setRePin] = createSignal('');
|
||||
|
||||
const encriptWithPin = () => {
|
||||
const val = props.valueToEncrypt || '<NA>';
|
||||
return `${val}_${pin()}`;
|
||||
};
|
||||
|
||||
const onSetPin = () => {
|
||||
// Verify pin
|
||||
// Encrypt private key
|
||||
// Execute callback
|
||||
props.onPinApplied && props.onPinApplied(encriptWithPin());
|
||||
};
|
||||
|
||||
const onOptout = () => {
|
||||
props.onPinApplied && props.onPinApplied(props.valueToEncrypt || '');
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
if (props.open) {
|
||||
pinInput?.focus();
|
||||
}
|
||||
});
|
||||
|
||||
const isValidPin = () => {
|
||||
return pin().length > 3;
|
||||
}
|
||||
|
||||
const isValidRePin = () => {
|
||||
return rePin() === pin();
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<Modal open={props.open}>
|
||||
<div id={props.id} class={styles.modal}>
|
||||
<button class={styles.xClose} onClick={props.onAbort}>
|
||||
<div class={styles.iconClose}></div>
|
||||
</button>
|
||||
<div class={styles.title}>
|
||||
{intl.formatMessage(tPin.title)}
|
||||
</div>
|
||||
<div class={styles.description}>
|
||||
{intl.formatMessage(tPin.description)}
|
||||
</div>
|
||||
<div class={styles.inputs}>
|
||||
<TextInput
|
||||
type="password"
|
||||
ref={pinInput}
|
||||
value={pin()}
|
||||
onChange={(val: string) => setPin(val)}
|
||||
validationState={isValidPin() ? 'valid' : 'invalid'}
|
||||
errorMessage={intl.formatMessage(tPin.invalidPin)}
|
||||
/>
|
||||
<TextInput
|
||||
type="password"
|
||||
value={rePin()}
|
||||
onChange={(val: string) => setRePin(val)}
|
||||
label={intl.formatMessage(tPin.reEnter)}
|
||||
validationState={rePin().length === 0 || isValidRePin() ? 'valid' : 'invalid'}
|
||||
errorMessage={intl.formatMessage(tPin.invalidRePin)}
|
||||
/>
|
||||
</div>
|
||||
<div class={styles.actions}>
|
||||
<ButtonPrimary
|
||||
onClick={onSetPin}
|
||||
disabled={!isValidPin() || !isValidRePin()}
|
||||
>
|
||||
{intl.formatMessage(tActions.createPin)}
|
||||
</ButtonPrimary>
|
||||
<ButtonSecondary
|
||||
onClick={onOptout}
|
||||
>
|
||||
{intl.formatMessage(tActions.optoutPin)}
|
||||
</ButtonSecondary>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default hookForDev(CreatePinModal);
|
@ -135,14 +135,21 @@
|
||||
}
|
||||
|
||||
.welcomeMessage {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 72px;
|
||||
font-weight: 300;
|
||||
font-size: 32px;
|
||||
line-height: 34px;
|
||||
color: var(--brand-text);
|
||||
text-transform: lowercase;
|
||||
|
||||
button {
|
||||
width: fit-content;
|
||||
min-width: 117px;
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.welcomeMessageSmall {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Component, onCleanup, onMount, Show } from 'solid-js';
|
||||
import { Component, createSignal, onCleanup, onMount, Show } from 'solid-js';
|
||||
import Avatar from '../Avatar/Avatar';
|
||||
|
||||
import styles from './HomeHeader.module.scss';
|
||||
@ -8,8 +8,11 @@ import SmallCallToAction from '../SmallCallToAction/SmallCallToAction';
|
||||
import { useHomeContext } from '../../contexts/HomeContext';
|
||||
import { useIntl } from '@cookbook/solid-intl';
|
||||
import { useSettingsContext } from '../../contexts/SettingsContext';
|
||||
import { placeholders as t } from '../../translations';
|
||||
import { placeholders as t, actions as tActions } from '../../translations';
|
||||
import { hookForDev } from '../../lib/devTools';
|
||||
import ButtonPrimary from '../Buttons/ButtonPrimary';
|
||||
import CreateAccountModal from '../CreateAccountModal/CreateAccountModal';
|
||||
import LoginModal from '../LoginModal/LoginModal';
|
||||
|
||||
const HomeHeader: Component< { id?: string} > = (props) => {
|
||||
|
||||
@ -73,13 +76,31 @@ const HomeHeader: Component< { id?: string} > = (props) => {
|
||||
|
||||
const activeUser = () => account?.activeUser;
|
||||
|
||||
const [showGettingStarted, setShowGettingStarted] = createSignal(false);
|
||||
const [showLogin, setShowLogin] = createSignal(false);
|
||||
|
||||
const onGetStarted = () => {
|
||||
setShowGettingStarted(true);
|
||||
};
|
||||
|
||||
const doCreateAccount = () => {};
|
||||
|
||||
return (
|
||||
<div id={props.id} class={styles.fullHeader}>
|
||||
<Show
|
||||
when={account?.hasPublicKey()}
|
||||
fallback={<div class={styles.welcomeMessage}>
|
||||
{intl.formatMessage(t.guestUserGreeting)}
|
||||
</div>}
|
||||
fallback={
|
||||
<Show when={account?.isKeyLookupDone}>
|
||||
<div class={styles.welcomeMessage}>
|
||||
<div>
|
||||
{intl.formatMessage(t.guestUserGreeting)}
|
||||
</div>
|
||||
<ButtonPrimary onClick={onGetStarted}>
|
||||
{intl.formatMessage(tActions.getStarted)}
|
||||
</ButtonPrimary>
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<button class={styles.callToAction} onClick={onShowNewNoteinput}>
|
||||
<Avatar
|
||||
@ -125,6 +146,18 @@ const HomeHeader: Component< { id?: string} > = (props) => {
|
||||
<div class={styles.rightCorner}></div>
|
||||
</div>
|
||||
</div>
|
||||
<CreateAccountModal
|
||||
open={showGettingStarted()}
|
||||
onAbort={() => setShowGettingStarted(false)}
|
||||
onLogin={() => {
|
||||
setShowGettingStarted(false);
|
||||
setShowLogin(true);
|
||||
}}
|
||||
/>
|
||||
<LoginModal
|
||||
open={showLogin()}
|
||||
onAbort={() => setShowLogin(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
89
src/components/LoginModal/LoginModal.module.scss
Normal file
89
src/components/LoginModal/LoginModal.module.scss
Normal file
@ -0,0 +1,89 @@
|
||||
.modal {
|
||||
position: fixed;
|
||||
width: 420px;
|
||||
color: var(--text-secondary);
|
||||
background-color: var(--background-site);
|
||||
background: linear-gradient(var(--background-site),
|
||||
var(--background-site)) padding-box,
|
||||
var(--brand-gradient) border-box;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 22px;
|
||||
|
||||
.xClose {
|
||||
background: none;
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: fit-content;
|
||||
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
right: 18px;
|
||||
|
||||
.iconClose {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
display: inline-block;
|
||||
margin: 0px 0px;
|
||||
background-color: var(--text-secondary);
|
||||
-webkit-mask: url(../../assets/icons/close.svg) no-repeat center;
|
||||
mask: url(../../assets/icons/close.svg) no-repeat center;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.iconClose {
|
||||
background-color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 800;
|
||||
font-size: 18px;
|
||||
line-height: 18px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--text-secondary);
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.inputs {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
margin-bottom: 20px;
|
||||
|
||||
button {
|
||||
width: fit-content;
|
||||
min-width: 192px;
|
||||
padding: 10px;
|
||||
margin: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.alternative {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
}
|
128
src/components/LoginModal/LoginModal.tsx
Normal file
128
src/components/LoginModal/LoginModal.tsx
Normal file
@ -0,0 +1,128 @@
|
||||
import { useIntl } from '@cookbook/solid-intl';
|
||||
import { Component, createEffect, createSignal, Match, Switch } from 'solid-js';
|
||||
import { useAccountContext } from '../../contexts/AccountContext';
|
||||
import Modal from '../Modal/Modal';
|
||||
|
||||
import { login as tLogin, actions as tActions } from '../../translations';
|
||||
|
||||
import styles from './LoginModal.module.scss';
|
||||
import { hookForDev } from '../../lib/devTools';
|
||||
import ButtonPrimary from '../Buttons/ButtonPrimary';
|
||||
import CreatePinModal from '../CreatePinModal/CreatePinModal';
|
||||
import TextInput from '../TextInput/TextInput';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { storeSec } from '../../lib/localStore';
|
||||
|
||||
const LoginModal: Component<{
|
||||
id?: string,
|
||||
open?: boolean,
|
||||
onAbort?: () => void,
|
||||
}> = (props) => {
|
||||
|
||||
const intl = useIntl();
|
||||
const account = useAccountContext();
|
||||
|
||||
const [step, setStep] = createSignal<'login' | 'pin' | 'none'>('login')
|
||||
const [enteredKey, setEnteredKey] = createSignal('');
|
||||
|
||||
let loginInput: HTMLInputElement | undefined;
|
||||
|
||||
const onLogin = () => {
|
||||
const sec = enteredKey();
|
||||
|
||||
if (!isValidNsec()) return;
|
||||
|
||||
account?.actions.setSec(sec);
|
||||
storeSec(sec);
|
||||
setStep(() => 'pin');
|
||||
};
|
||||
|
||||
const onStoreSec = (sec: string | undefined) => {
|
||||
storeSec(sec);
|
||||
onAbort();
|
||||
}
|
||||
|
||||
const onAbort = () => {
|
||||
setStep(() => 'login');
|
||||
setEnteredKey('');
|
||||
props.onAbort && props.onAbort();
|
||||
}
|
||||
|
||||
const isValidNsec: () => boolean = () => {
|
||||
const key = enteredKey();
|
||||
|
||||
if (key.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (key.startsWith('nsec')) {
|
||||
try {
|
||||
console.log('KEY: ', key)
|
||||
const decoded = nip19.decode(key);
|
||||
|
||||
return decoded.type === 'nsec' && decoded.data;
|
||||
} catch(e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
if (props.open && step() === 'login') {
|
||||
loginInput?.focus();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={step() === 'login'}>
|
||||
<Modal open={props.open}>
|
||||
<div id={props.id} class={styles.modal}>
|
||||
<button class={styles.xClose} onClick={onAbort}>
|
||||
<div class={styles.iconClose}></div>
|
||||
</button>
|
||||
<div class={styles.title}>
|
||||
{intl.formatMessage(tLogin.title)}
|
||||
</div>
|
||||
<div class={styles.description}>
|
||||
{intl.formatMessage(tLogin.description)}
|
||||
</div>
|
||||
<div class={styles.inputs}>
|
||||
<TextInput
|
||||
ref={loginInput}
|
||||
type="password"
|
||||
value={enteredKey()}
|
||||
onChange={setEnteredKey}
|
||||
validationState={enteredKey().length === 0 || isValidNsec() ? 'valid' : 'invalid'}
|
||||
errorMessage={intl.formatMessage(tLogin.invalidNsec)}
|
||||
/>
|
||||
</div>
|
||||
<div class={styles.actions}>
|
||||
<ButtonPrimary
|
||||
onClick={onLogin}
|
||||
disabled={enteredKey().length === 0 || !isValidNsec()}
|
||||
>
|
||||
{intl.formatMessage(tActions.login)}
|
||||
</ButtonPrimary>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</Match>
|
||||
|
||||
<Match when={step() === 'pin'}>
|
||||
<CreatePinModal
|
||||
open={step() === 'pin'}
|
||||
onAbort={() => {
|
||||
onStoreSec(account?.sec);
|
||||
}}
|
||||
valueToEncrypt={enteredKey()}
|
||||
onPinApplied={onStoreSec}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
export default hookForDev(LoginModal);
|
71
src/components/TextInput/TextInput.module.scss
Normal file
71
src/components/TextInput/TextInput.module.scss
Normal file
@ -0,0 +1,71 @@
|
||||
.container {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
min-height: 98px;
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
|
||||
.label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.input {
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--subtile-devider);
|
||||
background: var(--background-input);
|
||||
|
||||
width: calc(100% - 16px);
|
||||
height: 40px;
|
||||
|
||||
margin-top: 8px;
|
||||
margin-bottom: 5px;
|
||||
padding-inline: 8px !important;
|
||||
|
||||
color: var(--text-secondary);
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
|
||||
&:focus, &:focus-visible {
|
||||
outline: none;
|
||||
border: 1px solid var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.errorMessage {
|
||||
color: var(--warning-color);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.inputWrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
.inputAfter {
|
||||
position: absolute;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
top: 10px;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
60
src/components/TextInput/TextInput.tsx
Normal file
60
src/components/TextInput/TextInput.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import { Component, JSXElement, Show } from 'solid-js';
|
||||
import { TextField } from '@kobalte/core';
|
||||
|
||||
import styles from './TextInput.module.scss';
|
||||
|
||||
const TextInput: Component<{
|
||||
label?: string,
|
||||
description?: string,
|
||||
errorMessage?: string,
|
||||
value?: string,
|
||||
onChange?: (value: string) => void,
|
||||
children?: JSXElement,
|
||||
readonly?: boolean,
|
||||
ref?: HTMLInputElement,
|
||||
validationState?: 'valid' | 'invalid',
|
||||
type?: string,
|
||||
}> = (props) => {
|
||||
|
||||
return (
|
||||
<div class={styles.container}>
|
||||
<TextField.Root
|
||||
class={styles.root}
|
||||
value={props.value}
|
||||
onChange={props.onChange}
|
||||
validationState={props.validationState}
|
||||
>
|
||||
<Show when={props.label}>
|
||||
<TextField.Label class={styles.label}>
|
||||
{props.label}
|
||||
</TextField.Label>
|
||||
</Show>
|
||||
|
||||
<div class={styles.inputWrapper}>
|
||||
<TextField.Input
|
||||
ref={props.ref}
|
||||
class={styles.input}
|
||||
readOnly={props.readonly}
|
||||
type={props.type}
|
||||
/>
|
||||
|
||||
<div class={styles.inputAfter}>
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={props.description}>
|
||||
<TextField.Description class={styles.description}>
|
||||
{props.description}
|
||||
</TextField.Description>
|
||||
</Show>
|
||||
|
||||
<TextField.ErrorMessage class={styles.errorMessage}>
|
||||
{props.errorMessage}
|
||||
</TextField.ErrorMessage>
|
||||
</TextField.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TextInput;
|
@ -255,7 +255,6 @@ export const editMentionRegex = /(?:\s|^)@\`(.*?)\`/ig;
|
||||
|
||||
export const medZapLimit = 1000;
|
||||
|
||||
|
||||
export const defaultNotificationSettings: Record<string, boolean> = {
|
||||
NEW_USER_FOLLOWED_YOU: true,
|
||||
USER_UNFOLLOWED_YOU: true,
|
||||
|
@ -22,12 +22,13 @@ import { Kind, relayConnectingTimeout } from "../constants";
|
||||
import { isConnected, refreshSocketListeners, removeSocketListeners, socket, subscribeTo, reset } from "../sockets";
|
||||
import { sendContacts, sendLike, sendMuteList, triggerImportEvents } from "../lib/notes";
|
||||
// @ts-ignore Bad types in nostr-tools
|
||||
import { generatePrivateKey, Relay } from "nostr-tools";
|
||||
import { generatePrivateKey, Relay, getPublicKey as nostrGetPubkey, nip19 } from "nostr-tools";
|
||||
import { APP_ID } from "../App";
|
||||
import { getLikes, getFilterlists, getProfileContactList, getProfileMuteList, getUserProfiles, sendFilterlists, getAllowlist, sendAllowList } from "../lib/profile";
|
||||
import { getStorage, saveFollowing, saveLikes, saveMuted, saveMuteList, saveRelaySettings } from "../lib/localStore";
|
||||
import { getStorage, readSecFromStorage, saveFollowing, saveLikes, saveMuted, saveMuteList, saveRelaySettings, storeSec } from "../lib/localStore";
|
||||
import { connectRelays, connectToRelay, getDefaultRelays, getPreConfiguredRelays } from "../lib/relays";
|
||||
import { getPublicKey } from "../lib/nostrAPI";
|
||||
import { generateKeys } from "../lib/PrimalNostr";
|
||||
|
||||
export type AccountContextStore = {
|
||||
likes: string[],
|
||||
@ -52,6 +53,7 @@ export type AccountContextStore = {
|
||||
mutelistSince: number,
|
||||
allowlist: string[],
|
||||
allowlistSince: number,
|
||||
sec: string | undefined,
|
||||
actions: {
|
||||
showNewNoteForm: () => void,
|
||||
hideNewNoteForm: () => void,
|
||||
@ -74,6 +76,7 @@ export type AccountContextStore = {
|
||||
updateFilterList: (pubkey: string | undefined, content?: boolean, trending?: boolean) => void,
|
||||
addToAllowlist: (pubkey: string | undefined, then?: () => void) => void,
|
||||
removeFromAllowlist: (pubkey: string | undefined) => void,
|
||||
setSec: (sec: string | undefined) => void,
|
||||
},
|
||||
}
|
||||
|
||||
@ -99,6 +102,7 @@ const initialData = {
|
||||
mutelistSince: 0,
|
||||
allowlist: [],
|
||||
allowlistSince: 0,
|
||||
sec: undefined,
|
||||
};
|
||||
|
||||
export const AccountContext = createContext<AccountContextStore>();
|
||||
@ -113,6 +117,17 @@ export function AccountProvider(props: { children: JSXElement }) {
|
||||
|
||||
let connectedRelaysCopy: Relay[] = [];
|
||||
|
||||
const setSec = (sec: string | undefined) => {
|
||||
const decoded = nip19.decode(sec);
|
||||
|
||||
if (decoded.type === 'nsec' && decoded.data) {
|
||||
updateStore('sec', () => sec);
|
||||
const pubkey = nostrGetPubkey(decoded.data);
|
||||
setPublicKey(pubkey);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const setPublicKey = (pubkey: string | undefined) => {
|
||||
updateStore('publicKey', () => pubkey);
|
||||
pubkey ? localStorage.setItem('pubkey', pubkey) : localStorage.removeItem('pubkey');
|
||||
@ -242,7 +257,7 @@ export function AccountProvider(props: { children: JSXElement }) {
|
||||
const nostr = win.nostr;
|
||||
|
||||
if (nostr === undefined) {
|
||||
console.log('No WebLn extension');
|
||||
console.log('Nostr extension not found');
|
||||
// Try again after one second if extensionAttempts are not exceeded
|
||||
if (extensionAttempt < 1) {
|
||||
extensionAttempt += 1;
|
||||
@ -250,7 +265,15 @@ export function AccountProvider(props: { children: JSXElement }) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateStore('isKeyLookupDone', true);
|
||||
const sec = readSecFromStorage();
|
||||
|
||||
if (sec) {
|
||||
setSec(sec);
|
||||
} else {
|
||||
updateStore('publicKey', () => undefined);
|
||||
}
|
||||
|
||||
updateStore('isKeyLookupDone', () => true);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1183,6 +1206,7 @@ const [store, updateStore] = createStore<AccountContextStore>({
|
||||
updateFilterList,
|
||||
addToAllowlist,
|
||||
removeFromAllowlist,
|
||||
setSec,
|
||||
},
|
||||
});
|
||||
|
||||
|
75
src/lib/PrimalNostr.ts
Normal file
75
src/lib/PrimalNostr.ts
Normal file
@ -0,0 +1,75 @@
|
||||
// @ts-ignore Bad types in nostr-tools
|
||||
import { generatePrivateKey, getPublicKey, nip04, getSignature, getEventHash, validateEvent, verifySignature } from 'nostr-tools';
|
||||
import { NostrExtension, NostrRelayEvent, NostrRelays, NostrRelaySignedEvent } from '../types/primal';
|
||||
import { readSecFromStorage, storeSec } from './localStore';
|
||||
|
||||
export const generateKeys = (forceNewKey?: boolean) => {
|
||||
const sec = forceNewKey ?
|
||||
generatePrivateKey() :
|
||||
readSecFromStorage() || generatePrivateKey();
|
||||
const pubkey = getPublicKey(sec);
|
||||
|
||||
return { sec, pubkey };
|
||||
};
|
||||
|
||||
export const PrimalNostr: (pk?: string) => NostrExtension = (pk?: string) => {
|
||||
let sec: string = pk || readSecFromStorage() || generatePrivateKey();
|
||||
|
||||
let pubkey: string = getPublicKey(sec);
|
||||
|
||||
storeSec(sec);
|
||||
|
||||
const gPk: () => Promise<string> = () => new Promise<string>(r => r(getPublicKey(sec)));
|
||||
const gRl: () => Promise<NostrRelays> = () => new Promise<NostrRelays>((resolve) => {resolve({})});
|
||||
|
||||
const encrypt: (pubkey: string, message: string) => Promise<string> =
|
||||
(pubkey, message) => new Promise((rs, rj) => {
|
||||
try {
|
||||
rs(nip04.encrypt(sec, pubkey, message));
|
||||
} catch(e) {
|
||||
console.log('Failed to encript (PrimalNostr): ', e);
|
||||
rj();
|
||||
}
|
||||
});
|
||||
|
||||
const decrypt: (pubkey: string, message: string) => Promise<string> =
|
||||
(pubkey, message) => new Promise((rs, rj) => {
|
||||
try {
|
||||
rs(nip04.decrypt(sec, pubkey, message));
|
||||
} catch(e) {
|
||||
console.log('Failed to decrypt (PrimalNostr): ', e);
|
||||
rj();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
sec,
|
||||
pubkey,
|
||||
getPublicKey: gPk,
|
||||
getRelays: gRl,
|
||||
nip04: {
|
||||
encrypt,
|
||||
decrypt,
|
||||
},
|
||||
signEvent: (event: NostrRelayEvent) => {
|
||||
return new Promise<NostrRelaySignedEvent>((resolve, reject) => {
|
||||
try {
|
||||
const id = getEventHash(event);
|
||||
const sig = getSignature(event, sec);
|
||||
|
||||
const signed: NostrRelaySignedEvent = { ...event, id, sig, pubkey };
|
||||
|
||||
const isValid = validateEvent(signed);
|
||||
const isVerified = verifySignature(signed);
|
||||
|
||||
if (!isValid) throw('event-not-valid');
|
||||
if (!isVerified) throw('event-sig-not-verified');
|
||||
|
||||
resolve(signed);
|
||||
} catch(e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
@ -141,3 +141,17 @@ export const saveTheme = (pubkey: string | undefined, theme: string) => {
|
||||
|
||||
setStorage(pubkey, store);
|
||||
};
|
||||
|
||||
export const readSecFromStorage = () => {
|
||||
return localStorage.getItem('primalSec') || undefined;
|
||||
};
|
||||
|
||||
export const storeSec = (sec: string | undefined) => {
|
||||
if (!sec) {
|
||||
localStorage.removeItem('primalSec');
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem('primalSec', sec);
|
||||
|
||||
};
|
||||
|
@ -7,6 +7,7 @@ import {
|
||||
SendPaymentResponse,
|
||||
WebLnExtension,
|
||||
} from "../types/primal";
|
||||
import { PrimalNostr } from "./PrimalNostr";
|
||||
|
||||
|
||||
type QueueItem = {
|
||||
@ -75,7 +76,7 @@ const enqueueWebLn = async <T>(action: (webln: WebLnExtension) => Promise<T>) =>
|
||||
|
||||
const enqueueNostr = async <T>(action: (nostr: NostrExtension) => Promise<T>) => {
|
||||
const win = window as NostrWindow;
|
||||
const nostr = win.nostr;
|
||||
const nostr = win.nostr || PrimalNostr();
|
||||
|
||||
if (nostr === undefined) {
|
||||
throw('no_nostr_extension');
|
||||
|
13
src/pages/CreateAccount.tsx
Normal file
13
src/pages/CreateAccount.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { Component } from 'solid-js';
|
||||
import MissingPage from '../components/MissingPage/MissingPage';
|
||||
|
||||
|
||||
const CreateAccount: Component = () => {
|
||||
return (
|
||||
<>
|
||||
<MissingPage title="create account" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateAccount;
|
@ -19,9 +19,90 @@ export const account = {
|
||||
defaultMessage: 'You need to be signed in to perform this action',
|
||||
description: 'Message to user that an action cannot be preformed without a public key',
|
||||
},
|
||||
createNewDescription: {
|
||||
id: 'account.createNewDescription',
|
||||
defaultMessage: 'New to Nostr? Create your account now and join this magical place. It’s quick and easy!',
|
||||
description: 'Label inviting users to join Nostr',
|
||||
},
|
||||
};
|
||||
|
||||
export const login = {
|
||||
title: {
|
||||
id: 'login.title',
|
||||
defaultMessage: 'Login',
|
||||
description: 'Login ',
|
||||
},
|
||||
description: {
|
||||
id: 'login.description',
|
||||
defaultMessage: 'Enter your Nostr private key (starting with “nsec”):',
|
||||
description: 'Label describing the login proccess',
|
||||
},
|
||||
invalidNsec: {
|
||||
id: 'login.invalidNsec',
|
||||
defaultMessage: 'Please enter a valid Nostr private key',
|
||||
description: 'Label informing the user of an invalid nsec key',
|
||||
},
|
||||
};
|
||||
|
||||
export const pin = {
|
||||
title: {
|
||||
id: 'pin.title',
|
||||
defaultMessage: 'Create Pin',
|
||||
description: 'Create Pin ',
|
||||
},
|
||||
description: {
|
||||
id: 'pin.description',
|
||||
defaultMessage: 'Create a PIN to secure your account. You will need to enter this PIN every time you login to the Primal web app:',
|
||||
description: 'Label describing what the pin is used for',
|
||||
},
|
||||
reEnter: {
|
||||
id: 'pin.reEnter',
|
||||
defaultMessage: 'Re-type your PIN:',
|
||||
description: 'Label instructing the user to re-enter the pin',
|
||||
},
|
||||
invalidPin: {
|
||||
id: 'pin.invalidPin',
|
||||
defaultMessage: 'PIN must be at least 4 characters',
|
||||
description: 'Label instructing the user on the valid pin requirements',
|
||||
},
|
||||
invalidRePin: {
|
||||
id: 'pin.invalidRePin',
|
||||
defaultMessage: 'PINs don\'t match',
|
||||
description: 'Label instructing the user that the two pins don\'t match',
|
||||
},
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
createPin: {
|
||||
id: 'actions.createPin',
|
||||
defaultMessage: 'Set PIN',
|
||||
description: 'Create PIN action, button label',
|
||||
},
|
||||
optoutPin: {
|
||||
id: 'actions.optoutPin',
|
||||
defaultMessage: 'Continue without a PIN',
|
||||
description: 'opt-out of PIN action, button label',
|
||||
},
|
||||
createAccount: {
|
||||
id: 'actions.createAccount',
|
||||
defaultMessage: 'Create Account',
|
||||
description: 'Create account action, button label',
|
||||
},
|
||||
login: {
|
||||
id: 'actions.login',
|
||||
defaultMessage: 'Login',
|
||||
description: 'Login action, button label',
|
||||
},
|
||||
loginNow: {
|
||||
id: 'actions.loginNow',
|
||||
defaultMessage: 'Login now',
|
||||
description: 'Login Now action, button label',
|
||||
},
|
||||
getStarted: {
|
||||
id: 'actions.getStarted',
|
||||
defaultMessage: 'Get Started',
|
||||
description: 'Get Started action, button label',
|
||||
},
|
||||
cancel: {
|
||||
id: 'actions.cancel',
|
||||
defaultMessage: 'cancel',
|
||||
|
2
src/types/primal.d.ts
vendored
2
src/types/primal.d.ts
vendored
@ -334,6 +334,8 @@ interface SendPaymentResponse {
|
||||
}
|
||||
|
||||
export type NostrExtension = {
|
||||
sec?: string,
|
||||
pubkey?: string,
|
||||
getPublicKey: () => Promise<string>,
|
||||
getRelays: () => Promise<NostrRelays>,
|
||||
signEvent: (event: NostrRelayEvent) => Promise<NostrRelaySignedEvent>,
|
||||
|
Loading…
Reference in New Issue
Block a user