Ditto auth See merge request soapbox-pub/soapbox!2951environments/review-main-yi2y9f/deployments/4455
commit
460e22ce2b
@ -1,22 +1,26 @@
|
||||
import { nip19 } from 'nostr-tools';
|
||||
|
||||
import { signer } from 'soapbox/features/nostr/sign';
|
||||
import { type AppDispatch } from 'soapbox/store';
|
||||
|
||||
import { verifyCredentials } from './auth';
|
||||
|
||||
/** Log in with a Nostr pubkey. */
|
||||
function nostrLogIn() {
|
||||
function logInNostr(pubkey: string) {
|
||||
return (dispatch: AppDispatch) => {
|
||||
const npub = nip19.npubEncode(pubkey);
|
||||
return dispatch(verifyCredentials(npub));
|
||||
};
|
||||
}
|
||||
|
||||
/** Log in with a Nostr extension. */
|
||||
function nostrExtensionLogIn() {
|
||||
return async (dispatch: AppDispatch) => {
|
||||
if (!signer) {
|
||||
if (!window.nostr) {
|
||||
throw new Error('No Nostr signer available');
|
||||
}
|
||||
|
||||
const pubkey = await signer.getPublicKey();
|
||||
const npub = nip19.npubEncode(pubkey);
|
||||
|
||||
return dispatch(verifyCredentials(npub));
|
||||
const pubkey = await window.nostr.getPublicKey();
|
||||
return dispatch(logInNostr(pubkey));
|
||||
};
|
||||
}
|
||||
|
||||
export { nostrLogIn };
|
||||
export { logInNostr, nostrExtensionLogIn };
|
@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
|
||||
import Emoji from 'soapbox/components/ui/emoji/emoji';
|
||||
|
||||
interface IEmojiGraphic {
|
||||
emoji: string;
|
||||
}
|
||||
|
||||
/** Large emoji with a background for display purposes (eg breaking up a page). */
|
||||
const EmojiGraphic: React.FC<IEmojiGraphic> = ({ emoji }) => {
|
||||
return (
|
||||
<div className='flex items-center justify-center'>
|
||||
<div className='rounded-full bg-gray-100 p-8 dark:bg-gray-800'>
|
||||
<Emoji className='h-24 w-24' emoji={emoji} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmojiGraphic;
|
@ -0,0 +1,54 @@
|
||||
import { NRelay, NRelay1, NostrSigner } from '@soapbox/nspec';
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
|
||||
import { NKeys } from 'soapbox/features/nostr/keys';
|
||||
import { useOwnAccount } from 'soapbox/hooks';
|
||||
import { useInstance } from 'soapbox/hooks/useInstance';
|
||||
|
||||
interface NostrContextType {
|
||||
relay?: NRelay;
|
||||
pubkey?: string;
|
||||
signer?: NostrSigner;
|
||||
}
|
||||
|
||||
const NostrContext = createContext<NostrContextType | undefined>(undefined);
|
||||
|
||||
interface NostrProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const NostrProvider: React.FC<NostrProviderProps> = ({ children }) => {
|
||||
const instance = useInstance();
|
||||
const [relay, setRelay] = useState<NRelay1>();
|
||||
|
||||
const { account } = useOwnAccount();
|
||||
|
||||
const url = instance.nostr?.relay;
|
||||
const pubkey = instance.nostr?.pubkey;
|
||||
const accountPubkey = account?.nostr.pubkey;
|
||||
|
||||
const signer = (accountPubkey ? NKeys.get(accountPubkey) : undefined) ?? window.nostr;
|
||||
|
||||
useEffect(() => {
|
||||
if (url) {
|
||||
setRelay(new NRelay1(url));
|
||||
}
|
||||
return () => {
|
||||
relay?.close();
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
return (
|
||||
<NostrContext.Provider value={{ relay, pubkey, signer }}>
|
||||
{children}
|
||||
</NostrContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useNostr = () => {
|
||||
const context = useContext(NostrContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useNostr must be used within a NostrProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
@ -0,0 +1,112 @@
|
||||
import { NSchema as n, NostrSigner, NSecSigner } from '@soapbox/nspec';
|
||||
import { WebLock } from '@soapbox/weblock';
|
||||
import { getPublicKey, nip19 } from 'nostr-tools';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Gets Nostr keypairs from storage and returns a `Map`-like object of signers.
|
||||
* When instantiated, it will lock the storage key to prevent tampering.
|
||||
* Changes to the object will sync to storage.
|
||||
*/
|
||||
export class NKeyStorage implements ReadonlyMap<string, NostrSigner> {
|
||||
|
||||
#keypairs = new Map<string, Uint8Array>();
|
||||
#storage: Storage;
|
||||
#storageKey: string;
|
||||
|
||||
constructor(storage: Storage, storageKey: string) {
|
||||
this.#storage = storage;
|
||||
this.#storageKey = storageKey;
|
||||
|
||||
const data = this.#storage.getItem(storageKey);
|
||||
WebLock.storages.lockKey(storageKey);
|
||||
|
||||
try {
|
||||
const nsecs = new Set(this.#dataSchema().parse(data));
|
||||
|
||||
for (const nsec of nsecs) {
|
||||
const { data: secretKey } = nip19.decode(nsec);
|
||||
const pubkey = getPublicKey(secretKey);
|
||||
this.#keypairs.set(pubkey, secretKey);
|
||||
}
|
||||
} catch (e) {
|
||||
this.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#dataSchema(): z.ZodType<`nsec1${string}`[]> {
|
||||
return n.json().pipe(n.bech32('nsec').array());
|
||||
}
|
||||
|
||||
#syncStorage() {
|
||||
const secretKeys = [...this.#keypairs.values()].map(nip19.nsecEncode);
|
||||
this.#storage.setItem(this.#storageKey, JSON.stringify(secretKeys));
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.#keypairs.size;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.#keypairs.clear();
|
||||
this.#syncStorage();
|
||||
}
|
||||
|
||||
delete(pubkey: string): boolean {
|
||||
const result = this.#keypairs.delete(pubkey);
|
||||
this.#syncStorage();
|
||||
return result;
|
||||
}
|
||||
|
||||
forEach(callbackfn: (signer: NostrSigner, pubkey: string, map: typeof this) => void, thisArg?: any): void {
|
||||
for (const [pubkey] of this.#keypairs) {
|
||||
const signer = this.get(pubkey);
|
||||
if (signer) {
|
||||
callbackfn.call(thisArg, signer, pubkey, this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get(pubkey: string): NostrSigner | undefined {
|
||||
const secretKey = this.#keypairs.get(pubkey);
|
||||
if (secretKey) {
|
||||
return new NSecSigner(secretKey);
|
||||
}
|
||||
}
|
||||
|
||||
has(pubkey: string): boolean {
|
||||
return this.#keypairs.has(pubkey);
|
||||
}
|
||||
|
||||
add(secretKey: Uint8Array): NostrSigner {
|
||||
const pubkey = getPublicKey(secretKey);
|
||||
this.#keypairs.set(pubkey, secretKey);
|
||||
this.#syncStorage();
|
||||
return this.get(pubkey)!;
|
||||
}
|
||||
|
||||
*entries(): IterableIterator<[string, NostrSigner]> {
|
||||
for (const [pubkey] of this.#keypairs) {
|
||||
yield [pubkey, this.get(pubkey)!];
|
||||
}
|
||||
}
|
||||
|
||||
*keys(): IterableIterator<string> {
|
||||
for (const pubkey of this.#keypairs.keys()) {
|
||||
yield pubkey;
|
||||
}
|
||||
}
|
||||
|
||||
*values(): IterableIterator<NostrSigner> {
|
||||
for (const pubkey of this.#keypairs.keys()) {
|
||||
yield this.get(pubkey)!;
|
||||
}
|
||||
}
|
||||
|
||||
[Symbol.iterator](): IterableIterator<[string, NostrSigner]> {
|
||||
return this.entries();
|
||||
}
|
||||
|
||||
[Symbol.toStringTag] = 'NKeyStorage';
|
||||
|
||||
}
|
@ -1,44 +0,0 @@
|
||||
import { hexToBytes } from '@noble/hashes/utils';
|
||||
import { type NostrSigner, type NostrEvent, NSecSigner } from 'nspec';
|
||||
|
||||
/** Use key from `localStorage` if available, falling back to NIP-07. */
|
||||
export class SoapboxSigner implements NostrSigner {
|
||||
|
||||
#signer: NostrSigner;
|
||||
|
||||
constructor() {
|
||||
const privateKey = localStorage.getItem('soapbox:nostr:privateKey');
|
||||
const signer = privateKey ? new NSecSigner(hexToBytes(privateKey)) : window.nostr;
|
||||
|
||||
if (!signer) {
|
||||
throw new Error('No Nostr signer available');
|
||||
}
|
||||
|
||||
this.#signer = signer;
|
||||
}
|
||||
|
||||
async getPublicKey(): Promise<string> {
|
||||
return this.#signer.getPublicKey();
|
||||
}
|
||||
|
||||
async signEvent(event: Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>): Promise<NostrEvent> {
|
||||
return this.#signer.signEvent(event);
|
||||
}
|
||||
|
||||
nip04 = {
|
||||
encrypt: (pubkey: string, plaintext: string): Promise<string> => {
|
||||
if (!this.#signer.nip04) {
|
||||
throw new Error('NIP-04 not supported by signer');
|
||||
}
|
||||
return this.#signer.nip04.encrypt(pubkey, plaintext);
|
||||
},
|
||||
|
||||
decrypt: (pubkey: string, ciphertext: string): Promise<string> => {
|
||||
if (!this.#signer.nip04) {
|
||||
throw new Error('NIP-04 not supported by signer');
|
||||
}
|
||||
return this.#signer.nip04.decrypt(pubkey, ciphertext);
|
||||
},
|
||||
};
|
||||
|
||||
}
|
@ -0,0 +1,59 @@
|
||||
import { NostrEvent, NostrFilter } from '@soapbox/nspec';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useNostr } from 'soapbox/contexts/nostr-context';
|
||||
|
||||
/** Streams events from the relay for the given filters. */
|
||||
export function useNostrReq(filters: NostrFilter[]): { events: NostrEvent[]; eose: boolean; closed: boolean } {
|
||||
const { relay } = useNostr();
|
||||
|
||||
const [events, setEvents] = useState<NostrEvent[]>([]);
|
||||
const [closed, setClosed] = useState(false);
|
||||
const [eose, setEose] = useState(false);
|
||||
|
||||
const controller = useRef<AbortController>(new AbortController());
|
||||
const signal = controller.current.signal;
|
||||
const value = useValue(filters);
|
||||
|
||||
useEffect(() => {
|
||||
if (relay && value.length) {
|
||||
(async () => {
|
||||
for await (const msg of relay.req(value, { signal })) {
|
||||
if (msg[0] === 'EVENT') {
|
||||
setEvents((prev) => [msg[2], ...prev]);
|
||||
} else if (msg[0] === 'EOSE') {
|
||||
setEose(true);
|
||||
} else if (msg[0] === 'CLOSED') {
|
||||
setClosed(true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
return () => {
|
||||
controller.current.abort();
|
||||
controller.current = new AbortController();
|
||||
setEose(false);
|
||||
setClosed(false);
|
||||
};
|
||||
}, [relay, value]);
|
||||
|
||||
return {
|
||||
events,
|
||||
eose,
|
||||
closed,
|
||||
};
|
||||
}
|
||||
|
||||
/** Preserves the memory reference of a value across re-renders. */
|
||||
function useValue<T>(value: T): T {
|
||||
const ref = useRef<T>(value);
|
||||
|
||||
if (!isEqual(ref.current, value)) {
|
||||
ref.current = value;
|
||||
}
|
||||
|
||||
return ref.current;
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
import { NKeyStorage } from './NKeyStorage';
|
||||
|
||||
export const NKeys = new NKeyStorage(
|
||||
localStorage,
|
||||
'soapbox:nostr:keys',
|
||||
);
|
@ -1,13 +0,0 @@
|
||||
import { type NostrSigner } from 'nspec';
|
||||
|
||||
import { SoapboxSigner } from './SoapboxSigner';
|
||||
|
||||
let signer: NostrSigner | undefined;
|
||||
|
||||
try {
|
||||
signer = new SoapboxSigner();
|
||||
} catch (_) {
|
||||
// No signer available
|
||||
}
|
||||
|
||||
export { signer };
|
@ -1,18 +1,11 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Spinner } from 'soapbox/components/ui';
|
||||
import { Modal, Spinner } from 'soapbox/components/ui';
|
||||
|
||||
const ModalLoading = () => (
|
||||
<div className='modal-root__modal error-modal'>
|
||||
<div className='error-modal__body'>
|
||||
<Spinner />
|
||||
</div>
|
||||
<div className='error-modal__footer'>
|
||||
<div>
|
||||
<button className='error-modal__nav' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Modal>
|
||||
<Spinner />
|
||||
</Modal>
|
||||
);
|
||||
|
||||
export default ModalLoading;
|
||||
|
@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { closeModal } from 'soapbox/actions/modals';
|
||||
import { nostrExtensionLogIn } from 'soapbox/actions/nostr';
|
||||
import Stack from 'soapbox/components/ui/stack/stack';
|
||||
import Text from 'soapbox/components/ui/text/text';
|
||||
import { useAppDispatch } from 'soapbox/hooks';
|
||||
|
||||
const NostrExtensionIndicator: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const onClick = () => {
|
||||
dispatch(nostrExtensionLogIn());
|
||||
dispatch(closeModal());
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack space={2} className='rounded-lg bg-gray-100 p-2 dark:bg-gray-800'>
|
||||
<Text size='xs'>
|
||||
{window.nostr ? (
|
||||
<FormattedMessage
|
||||
id='nostr_extension.found'
|
||||
defaultMessage='<link>Sign in</link> with browser extension.'
|
||||
values={{
|
||||
link: (node) => <button type='button' className='underline' onClick={onClick}>{node}</button>,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage id='nostr_extension.not_found' defaultMessage='Browser extension not found.' />
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default NostrExtensionIndicator;
|
@ -0,0 +1,30 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import ExtensionStep from './steps/extension-step';
|
||||
import KeyAddStep from './steps/key-add-step';
|
||||
|
||||
type Step = 'extension' | 'key-add';
|
||||
|
||||
interface INostrLoginModal {
|
||||
onClose: (type?: string) => void;
|
||||
step?: Step;
|
||||
}
|
||||
|
||||
const NostrLoginModal: React.FC<INostrLoginModal> = ({ onClose, step: firstStep }) => {
|
||||
const [step, setStep] = useState<Step>(firstStep ?? (window.nostr ? 'extension' : 'key-add'));
|
||||
|
||||
const handleClose = () => onClose('NOSTR_LOGIN');
|
||||
|
||||
switch (step) {
|
||||
case 'extension':
|
||||
return <ExtensionStep onClickAlt={() => setStep('key-add')} onClose={handleClose} />;
|
||||
case 'key-add':
|
||||
return <KeyAddStep onClose={handleClose} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default NostrLoginModal;
|
||||
|
||||
export type { Step };
|
@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { nostrExtensionLogIn } from 'soapbox/actions/nostr';
|
||||
import EmojiGraphic from 'soapbox/components/emoji-graphic';
|
||||
import { Button, Stack, Modal } from 'soapbox/components/ui';
|
||||
import { useAppDispatch } from 'soapbox/hooks';
|
||||
|
||||
interface IExtensionStep {
|
||||
onClickAlt: () => void;
|
||||
onClose(): void;
|
||||
}
|
||||
|
||||
const ExtensionStep: React.FC<IExtensionStep> = ({ onClickAlt, onClose }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const onClick = () => {
|
||||
dispatch(nostrExtensionLogIn());
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal title={<FormattedMessage id='nostr_signup.siwe.title' defaultMessage='Sign in' />} onClose={onClose}>
|
||||
<Stack space={6}>
|
||||
<EmojiGraphic emoji='🔐' />
|
||||
|
||||
<Stack space={3}>
|
||||
<Button theme='accent' size='lg' onClick={onClick}>
|
||||
<FormattedMessage id='nostr_signup.siwe.action' defaultMessage='Sign in with extension' />
|
||||
</Button>
|
||||
|
||||
<Button theme='transparent' onClick={onClickAlt}>
|
||||
<FormattedMessage id='nostr_signup.siwe.alt' defaultMessage='Sign in with key' />
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExtensionStep;
|
@ -0,0 +1,71 @@
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import React, { useState } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { logInNostr } from 'soapbox/actions/nostr';
|
||||
import EmojiGraphic from 'soapbox/components/emoji-graphic';
|
||||
import { Button, Stack, Modal, Input, FormGroup, Form } from 'soapbox/components/ui';
|
||||
import { NKeys } from 'soapbox/features/nostr/keys';
|
||||
import { useAppDispatch } from 'soapbox/hooks';
|
||||
|
||||
import NostrExtensionIndicator from '../components/nostr-extension-indicator';
|
||||
|
||||
interface IKeyAddStep {
|
||||
onClose(): void;
|
||||
}
|
||||
|
||||
const KeyAddStep: React.FC<IKeyAddStep> = ({ onClose }) => {
|
||||
const [nsec, setNsec] = useState('');
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setNsec(e.target.value);
|
||||
setError(undefined);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const result = nip19.decode(nsec);
|
||||
if (result.type === 'nsec') {
|
||||
const seckey = result.data;
|
||||
const signer = NKeys.add(seckey);
|
||||
const pubkey = await signer.getPublicKey();
|
||||
dispatch(logInNostr(pubkey));
|
||||
onClose();
|
||||
}
|
||||
} catch (e) {
|
||||
setError('Invalid nsec');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal title={<FormattedMessage id='nostr_signup.key-add.title' defaultMessage='Import Key' />} onClose={onClose}>
|
||||
<Stack className='my-3' space={6}>
|
||||
<NostrExtensionIndicator />
|
||||
|
||||
<EmojiGraphic emoji='🔑' />
|
||||
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Stack space={6}>
|
||||
<FormGroup labelText='Secret key' errors={error ? [error] : []}>
|
||||
<Input
|
||||
value={nsec}
|
||||
type='password'
|
||||
onChange={handleChange}
|
||||
placeholder='nsec1…'
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<Button theme='accent' size='lg' type='submit' disabled={!nsec}>
|
||||
Add Key
|
||||
</Button>
|
||||
</Stack>
|
||||
</Form>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default KeyAddStep;
|
@ -0,0 +1,33 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import ExtensionStep from '../nostr-login-modal/steps/extension-step';
|
||||
|
||||
import KeyStep from './steps/key-step';
|
||||
import KeygenStep from './steps/keygen-step';
|
||||
|
||||
type Step = 'extension' | 'key' | 'keygen';
|
||||
|
||||
interface INostrSignupModal {
|
||||
onClose: (type?: string) => void;
|
||||
}
|
||||
|
||||
const NostrSigninModal: React.FC<INostrSignupModal> = ({ onClose }) => {
|
||||
const [step, setStep] = useState<Step>(window.nostr ? 'extension' : 'key');
|
||||
|
||||
const handleClose = () => onClose('NOSTR_SIGNUP');
|
||||
|
||||
switch (step) {
|
||||
case 'extension':
|
||||
return <ExtensionStep onClickAlt={() => setStep('key')} onClose={handleClose} />;
|
||||
case 'key':
|
||||
return <KeyStep setStep={setStep} onClose={handleClose} />;
|
||||
case 'keygen':
|
||||
return <KeygenStep onClose={handleClose} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default NostrSigninModal;
|
||||
|
||||
export type { Step };
|
@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import EmojiGraphic from 'soapbox/components/emoji-graphic';
|
||||
import { Button, Stack, Modal } from 'soapbox/components/ui';
|
||||
import { useAppDispatch } from 'soapbox/hooks';
|
||||
|
||||
import NostrExtensionIndicator from '../../nostr-login-modal/components/nostr-extension-indicator';
|
||||
import { Step } from '../nostr-signup-modal';
|
||||
|
||||
interface IKeyStep {
|
||||
setStep(step: Step): void;
|
||||
onClose(): void;
|
||||
}
|
||||
|
||||
const KeyStep: React.FC<IKeyStep> = ({ setStep, onClose }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const onAltClick = () => {
|
||||
onClose();
|
||||
dispatch(openModal('NOSTR_LOGIN', { step: 'key-add' }));
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal title={<FormattedMessage id='nostr_signup.key.title' defaultMessage='You need a key to continue' />} onClose={onClose}>
|
||||
<Stack className='my-3' space={6}>
|
||||
<NostrExtensionIndicator />
|
||||
|
||||
<EmojiGraphic emoji='🔑' />
|
||||
|
||||
<Stack space={3} alignItems='center'>
|
||||
<Button theme='accent' size='lg' onClick={() => setStep('keygen')}>
|
||||
Generate key
|
||||
</Button>
|
||||
|
||||
<Button theme='transparent' onClick={onAltClick}>
|
||||
I already have a key
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default KeyStep;
|
@ -0,0 +1,81 @@
|
||||
import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { fetchAccount } from 'soapbox/actions/accounts';
|
||||
import { logInNostr } from 'soapbox/actions/nostr';
|
||||
import CopyableInput from 'soapbox/components/copyable-input';
|
||||
import EmojiGraphic from 'soapbox/components/emoji-graphic';
|
||||
import { Button, Stack, Modal, FormGroup, Text, Tooltip } from 'soapbox/components/ui';
|
||||
import { NKeys } from 'soapbox/features/nostr/keys';
|
||||
import { useAppDispatch, useInstance } from 'soapbox/hooks';
|
||||
import { download } from 'soapbox/utils/download';
|
||||
import { slugify } from 'soapbox/utils/input';
|
||||
|
||||
interface IKeygenStep {
|
||||
onClose(): void;
|
||||
}
|
||||
|
||||
const KeygenStep: React.FC<IKeygenStep> = ({ onClose }) => {
|
||||
const instance = useInstance();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const secretKey = useMemo(() => generateSecretKey(), []);
|
||||
const pubkey = useMemo(() => getPublicKey(secretKey), [secretKey]);
|
||||
|
||||
const nsec = useMemo(() => nip19.nsecEncode(secretKey), [secretKey]);
|
||||
const npub = useMemo(() => nip19.npubEncode(pubkey), [pubkey]);
|
||||
|
||||
const [downloaded, setDownloaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Pre-fetch into cache.
|
||||
dispatch(fetchAccount(pubkey)).catch(() => {});
|
||||
}, [pubkey]);
|
||||
|
||||
const handleDownload = () => {
|
||||
download(nsec, `${slugify(instance.title)}-${npub.slice(5, 9)}.nsec.txt`);
|
||||
setDownloaded(true);
|
||||
};
|
||||
|
||||
const handleCopy = () => setDownloaded(true);
|
||||
|
||||
const handleNext = async () => {
|
||||
const signer = NKeys.add(secretKey);
|
||||
const pubkey = await signer.getPublicKey();
|
||||
dispatch(logInNostr(pubkey));
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal title={<FormattedMessage id='nostr_signup.keygen.title' defaultMessage='Your new key' />} onClose={onClose}>
|
||||
<Stack className='my-3' space={9}>
|
||||
<EmojiGraphic emoji='🔑' />
|
||||
|
||||
<Stack alignItems='center'>
|
||||
<Button theme='primary' size='lg' icon={require('@tabler/icons/download.svg')} onClick={handleDownload}>
|
||||
Download key
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<FormGroup labelText='Secret key'>
|
||||
<CopyableInput value={nsec} type='password' onCopy={handleCopy} />
|
||||
</FormGroup>
|
||||
|
||||
<Stack className='rounded-xl bg-gray-100 p-4 dark:bg-gray-800'>
|
||||
<Text>Back up your secret key in a secure place. If lost, your account cannot be recovered. Never share your secret key with anyone.</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack alignItems='end'>
|
||||
<Tooltip text='Download your key to continue' disabled={downloaded}>
|
||||
<Button theme='accent' disabled={!downloaded} size='lg' onClick={handleNext}>
|
||||
Next
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default KeygenStep;
|
@ -1,5 +1,5 @@
|
||||
import { TypedUseSelectorHook, useSelector } from 'react-redux';
|
||||
|
||||
import { RootState } from 'soapbox/store';
|
||||
import type { RootState } from 'soapbox/store';
|
||||
|
||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
|
||||
|
Loading…
Reference in new issue