Onboarding basic login flow

This commit is contained in:
Bojan Mojsilovic 2023-10-11 13:57:36 +02:00
parent 01c5fde775
commit 823f94df04
21 changed files with 1027 additions and 13 deletions

View File

@ -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>

View 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);

View File

@ -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;
}
}

View File

@ -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;
}
}

View 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?&nbsp;
<ButtonLink onClick={props.onLogin}>
{intl.formatMessage(tActions.loginNow)}
</ButtonLink>
</div>
</div>
</Modal>
);
}
export default hookForDev(CreateAccountModal);

View 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;
}
}

View 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);

View File

@ -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 {

View File

@ -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>
);
}

View 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;
}
}

View 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);

View 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;
}
}
}
}

View 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;

View File

@ -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,

View File

@ -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
View 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);
}
});
},
};
};

View File

@ -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);
};

View File

@ -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');

View 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;

View File

@ -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. Its 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',

View File

@ -334,6 +334,8 @@ interface SendPaymentResponse {
}
export type NostrExtension = {
sec?: string,
pubkey?: string,
getPublicKey: () => Promise<string>,
getRelays: () => Promise<NostrRelays>,
signEvent: (event: NostrRelayEvent) => Promise<NostrRelaySignedEvent>,