12 changed files with 388 additions and 204 deletions
@ -1,85 +0,0 @@
@@ -1,85 +0,0 @@
|
||||
import { Button } from '@/components/ui/button' |
||||
import { Input } from '@/components/ui/input' |
||||
import { Label } from '@/components/ui/label' |
||||
import { useNostr } from '@/providers/NostrProvider' |
||||
import { Check, Copy, RefreshCcw } from 'lucide-react' |
||||
import { generateSecretKey } from 'nostr-tools' |
||||
import { nsecEncode } from 'nostr-tools/nip19' |
||||
import { useState } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
|
||||
export default function GenerateNewAccount({ |
||||
back, |
||||
onLoginSuccess |
||||
}: { |
||||
back: () => void |
||||
onLoginSuccess: () => void |
||||
}) { |
||||
const { t } = useTranslation() |
||||
const { nsecLogin } = useNostr() |
||||
const [nsec, setNsec] = useState(generateNsec()) |
||||
const [copied, setCopied] = useState(false) |
||||
const [password, setPassword] = useState('') |
||||
|
||||
const handleLogin = () => { |
||||
nsecLogin(nsec, password, true).then(() => onLoginSuccess()) |
||||
} |
||||
|
||||
return ( |
||||
<form |
||||
className="space-y-4" |
||||
onSubmit={(e) => { |
||||
e.preventDefault() |
||||
handleLogin() |
||||
}} |
||||
> |
||||
<div className="text-orange-400"> |
||||
{t( |
||||
'This is a private key. Do not share it with anyone. Keep it safe and secure. You will not be able to recover it if you lose it.' |
||||
)} |
||||
</div> |
||||
<div className="grid gap-2"> |
||||
<Label>nsec</Label> |
||||
<div className="flex gap-2"> |
||||
<Input value={nsec} /> |
||||
<Button type="button" variant="secondary" onClick={() => setNsec(generateNsec())}> |
||||
<RefreshCcw /> |
||||
</Button> |
||||
<Button |
||||
type="button" |
||||
onClick={() => { |
||||
navigator.clipboard.writeText(nsec) |
||||
setCopied(true) |
||||
setTimeout(() => setCopied(false), 2000) |
||||
}} |
||||
> |
||||
{copied ? <Check /> : <Copy />} |
||||
</Button> |
||||
</div> |
||||
</div> |
||||
<div className="grid gap-2"> |
||||
<Label htmlFor="password-input">{t('password')}</Label> |
||||
<Input |
||||
id="password-input" |
||||
type="password" |
||||
placeholder={t('optional: encrypt nsec')} |
||||
value={password} |
||||
onChange={(e) => setPassword(e.target.value)} |
||||
/> |
||||
</div> |
||||
<div className="flex gap-2"> |
||||
<Button className="w-fit px-8" variant="secondary" type="button" onClick={back}> |
||||
{t('Back')} |
||||
</Button> |
||||
<Button className="flex-1" type="submit"> |
||||
{t('Login')} |
||||
</Button> |
||||
</div> |
||||
</form> |
||||
) |
||||
} |
||||
|
||||
function generateNsec() { |
||||
const sk = generateSecretKey() |
||||
return nsecEncode(sk) |
||||
} |
||||
@ -0,0 +1,92 @@
@@ -0,0 +1,92 @@
|
||||
import { Button } from '@/components/ui/button' |
||||
import { Input } from '@/components/ui/input' |
||||
import { Label } from '@/components/ui/label' |
||||
import { pubkeyToNpub } from '@/lib/pubkey' |
||||
import { useNostr } from '@/providers/NostrProvider' |
||||
import { Check, Copy, Eye, EyeOff, KeyRound } from 'lucide-react' |
||||
import { useMemo, useState } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
|
||||
export default function PrivateKeyRecoverySetting() { |
||||
const { t } = useTranslation() |
||||
const { pubkey, nsec, ncryptsec } = useNostr() |
||||
const [showKey, setShowKey] = useState(false) |
||||
const [copiedNpub, setCopiedNpub] = useState(false) |
||||
const [copiedKey, setCopiedKey] = useState(false) |
||||
|
||||
const npub = useMemo(() => (pubkey ? pubkeyToNpub(pubkey) : null), [pubkey]) |
||||
const recoverableKey = nsec ?? ncryptsec |
||||
const keyLabel = nsec ? 'nsec' : 'ncryptsec' |
||||
|
||||
if (!pubkey || !recoverableKey) { |
||||
return null |
||||
} |
||||
|
||||
const copyToClipboard = async (text: string, which: 'npub' | 'key') => { |
||||
await navigator.clipboard.writeText(text) |
||||
if (which === 'npub') { |
||||
setCopiedNpub(true) |
||||
setTimeout(() => setCopiedNpub(false), 2000) |
||||
} else { |
||||
setCopiedKey(true) |
||||
setTimeout(() => setCopiedKey(false), 2000) |
||||
} |
||||
} |
||||
|
||||
return ( |
||||
<section className="space-y-4"> |
||||
<div className="flex items-center gap-2"> |
||||
<KeyRound className="size-4 shrink-0 text-muted-foreground" /> |
||||
<h2 className="text-sm font-semibold">{t('Private key recovery')}</h2> |
||||
</div> |
||||
<p className="text-sm text-muted-foreground"> |
||||
{t( |
||||
'Your private key is stored in this browser. Clearing cache does not remove your account, but losing this browser profile does. Back up your key somewhere safe.' |
||||
)} |
||||
</p> |
||||
{ncryptsec && !nsec && ( |
||||
<p className="text-sm text-muted-foreground"> |
||||
{t( |
||||
'This account uses an encrypted key (ncryptsec). You need your encryption password to sign in; the blob below is for backup only.' |
||||
)} |
||||
</p> |
||||
)} |
||||
<div className="grid gap-2"> |
||||
<Label>{t('npub')}</Label> |
||||
<div className="flex gap-2"> |
||||
<Input readOnly value={npub ?? ''} className="font-mono text-xs" /> |
||||
<Button |
||||
type="button" |
||||
variant="secondary" |
||||
size="icon" |
||||
aria-label={t('Copy npub')} |
||||
onClick={() => npub && copyToClipboard(npub, 'npub')} |
||||
> |
||||
{copiedNpub ? <Check /> : <Copy />} |
||||
</Button> |
||||
</div> |
||||
</div> |
||||
<div className="grid gap-2"> |
||||
<Label>{t('Copy private key')} ({keyLabel})</Label> |
||||
<div className="flex flex-wrap gap-2"> |
||||
<Button type="button" variant="secondary" onClick={() => copyToClipboard(recoverableKey, 'key')}> |
||||
{copiedKey ? <Check className="mr-2 size-4" /> : <Copy className="mr-2 size-4" />} |
||||
{t('Copy private key')} |
||||
</Button> |
||||
<Button type="button" variant="outline" onClick={() => setShowKey((v) => !v)}> |
||||
{showKey ? <EyeOff className="mr-2 size-4" /> : <Eye className="mr-2 size-4" />} |
||||
{showKey ? t('Hide key') : t('Show key')} |
||||
</Button> |
||||
</div> |
||||
{showKey && ( |
||||
<div className="rounded-md border bg-muted/40 p-3"> |
||||
<p className="text-xs text-orange-500 mb-2"> |
||||
{t('Do not share this with anyone. Anyone with this key can control your account.')} |
||||
</p> |
||||
<pre className="text-xs font-mono whitespace-pre-wrap break-all">{recoverableKey}</pre> |
||||
</div> |
||||
)} |
||||
</div> |
||||
</section> |
||||
) |
||||
} |
||||
@ -0,0 +1,69 @@
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'vitest' |
||||
import { kinds } from 'nostr-tools' |
||||
import { ExtendedKind, FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants' |
||||
import { |
||||
NEW_USER_INTEREST_TOPICS, |
||||
buildNewUserTemplateDrafts, |
||||
newUserProfileDisplayName, |
||||
newUserProfileName, |
||||
newUserProfileSuffix |
||||
} from '@/lib/new-user-template' |
||||
|
||||
const TEST_PUBKEY = 'a'.repeat(63) + 'b' |
||||
|
||||
describe('newUserProfileSuffix', () => { |
||||
it('returns a number between 1000 and 9999', () => { |
||||
const suffix = newUserProfileSuffix(TEST_PUBKEY) |
||||
expect(suffix).toBeGreaterThanOrEqual(1000) |
||||
expect(suffix).toBeLessThanOrEqual(9999) |
||||
}) |
||||
|
||||
it('formats profile names with the suffix', () => { |
||||
const suffix = newUserProfileSuffix(TEST_PUBKEY) |
||||
expect(newUserProfileName(TEST_PUBKEY)).toBe(`ImwaldUser${suffix}`) |
||||
expect(newUserProfileDisplayName(TEST_PUBKEY)).toBe(`Imwald User ${suffix}`) |
||||
}) |
||||
}) |
||||
|
||||
describe('buildNewUserTemplateDrafts', () => { |
||||
const drafts = buildNewUserTemplateDrafts(TEST_PUBKEY) |
||||
|
||||
it('builds profile kind 0 with unique names', () => { |
||||
expect(drafts.profile.kind).toBe(kinds.Metadata) |
||||
const profile = JSON.parse(drafts.profile.content) |
||||
expect(profile.name).toBe(newUserProfileName(TEST_PUBKEY)) |
||||
expect(profile.display_name).toBe(newUserProfileDisplayName(TEST_PUBKEY)) |
||||
expect(profile.about).toContain('Imwald') |
||||
}) |
||||
|
||||
it('builds favorite relays kind 10012', () => { |
||||
expect(drafts.favoriteRelays.kind).toBe(ExtendedKind.FAVORITE_RELAYS) |
||||
expect(drafts.favoriteRelays.tags.filter((t) => t[0] === 'relay')).toHaveLength(2) |
||||
}) |
||||
|
||||
it('splits mailbox read and write relays', () => { |
||||
expect(drafts.relayList.kind).toBe(kinds.RelayList) |
||||
const readTags = drafts.relayList.tags.filter((t) => t[0] === 'r' && t[2] === 'read') |
||||
const writeTags = drafts.relayList.tags.filter((t) => t[0] === 'r' && t[2] === 'write') |
||||
expect(readTags).toHaveLength(FAST_READ_RELAY_URLS.length) |
||||
expect(writeTags).toHaveLength(FAST_WRITE_RELAY_URLS.length) |
||||
}) |
||||
|
||||
it('builds HTTP relay list kind 10243 with mercury', () => { |
||||
expect(drafts.httpRelayList.kind).toBe(ExtendedKind.HTTP_RELAY_LIST) |
||||
expect(drafts.httpRelayList.tags.some((t) => t[1]?.includes('mercury-relay.imwald.eu'))).toBe(true) |
||||
}) |
||||
|
||||
it('builds interest list with expected topics', () => { |
||||
expect(drafts.interestList.kind).toBe(10015) |
||||
const topics = drafts.interestList.tags.filter((t) => t[0] === 't').map((t) => t[1]) |
||||
expect(topics).toEqual([...NEW_USER_INTEREST_TOPICS]) |
||||
}) |
||||
|
||||
it('builds empty follow and mute lists', () => { |
||||
expect(drafts.followList.kind).toBe(kinds.Contacts) |
||||
expect(drafts.followList.tags).toHaveLength(0) |
||||
expect(drafts.muteList.kind).toBe(10000) |
||||
expect(drafts.muteList.tags).toHaveLength(0) |
||||
}) |
||||
}) |
||||
@ -0,0 +1,109 @@
@@ -0,0 +1,109 @@
|
||||
import { |
||||
DEFAULT_FAVORITE_RELAYS, |
||||
FAST_READ_RELAY_URLS, |
||||
FAST_WRITE_RELAY_URLS |
||||
} from '@/constants' |
||||
import { |
||||
createFavoriteRelaysDraftEvent, |
||||
createFollowListDraftEvent, |
||||
createHttpRelayListDraftEvent, |
||||
createInterestListDraftEvent, |
||||
createMuteListDraftEvent, |
||||
createProfileDraftEvent, |
||||
createRelayListDraftEvent |
||||
} from '@/lib/draft-event' |
||||
import { TDraftEvent, TMailboxRelay } from '@/types' |
||||
|
||||
export const NEW_USER_HTTP_RELAY_URL = 'https://mercury-relay.imwald.eu/' |
||||
|
||||
export const NEW_USER_INTEREST_TOPICS = [ |
||||
'art', |
||||
'music', |
||||
'news', |
||||
'foodstr', |
||||
'coffeechain', |
||||
'travel', |
||||
'grownostr', |
||||
'plebchain' |
||||
] as const |
||||
|
||||
export const NEW_USER_PROFILE_ABOUT = 'New on Nostr via Imwald. Edit your profile in Settings.' |
||||
|
||||
/** Stable 4-digit suffix (1000–9999) from pubkey hex. */ |
||||
export function newUserProfileSuffix(pubkey: string): number { |
||||
const hex = pubkey.trim().toLowerCase() |
||||
if (!/^[0-9a-f]{64}$/.test(hex)) { |
||||
return 1000 |
||||
} |
||||
return (parseInt(hex.slice(-4), 16) % 9000) + 1000 |
||||
} |
||||
|
||||
export function newUserProfileName(pubkey: string): string { |
||||
return `ImwaldUser${newUserProfileSuffix(pubkey)}` |
||||
} |
||||
|
||||
export function newUserProfileDisplayName(pubkey: string): string { |
||||
return `Imwald User ${newUserProfileSuffix(pubkey)}` |
||||
} |
||||
|
||||
export function buildNewUserMailboxRelays(): TMailboxRelay[] { |
||||
return [ |
||||
...FAST_READ_RELAY_URLS.map((url) => ({ url, scope: 'read' as const })), |
||||
...FAST_WRITE_RELAY_URLS.map((url) => ({ url, scope: 'write' as const })) |
||||
] |
||||
} |
||||
|
||||
export function buildNewUserProfileDraft(pubkey: string): TDraftEvent { |
||||
const content = JSON.stringify({ |
||||
name: newUserProfileName(pubkey), |
||||
display_name: newUserProfileDisplayName(pubkey), |
||||
about: NEW_USER_PROFILE_ABOUT |
||||
}) |
||||
return createProfileDraftEvent(content) |
||||
} |
||||
|
||||
export function buildNewUserFavoriteRelaysDraft(): TDraftEvent { |
||||
return createFavoriteRelaysDraftEvent([...DEFAULT_FAVORITE_RELAYS], []) |
||||
} |
||||
|
||||
export function buildNewUserRelayListDraft(): TDraftEvent { |
||||
return createRelayListDraftEvent(buildNewUserMailboxRelays()) |
||||
} |
||||
|
||||
export function buildNewUserHttpRelayListDraft(): TDraftEvent { |
||||
return createHttpRelayListDraftEvent([{ url: NEW_USER_HTTP_RELAY_URL, scope: 'both' }]) |
||||
} |
||||
|
||||
export function buildNewUserInterestListDraft(): TDraftEvent { |
||||
return createInterestListDraftEvent([...NEW_USER_INTEREST_TOPICS]) |
||||
} |
||||
|
||||
export function buildNewUserFollowListDraft(): TDraftEvent { |
||||
return createFollowListDraftEvent([]) |
||||
} |
||||
|
||||
export function buildNewUserMuteListDraft(): TDraftEvent { |
||||
return createMuteListDraftEvent([]) |
||||
} |
||||
|
||||
export type TNewUserTemplateDrafts = { |
||||
profile: TDraftEvent |
||||
favoriteRelays: TDraftEvent |
||||
relayList: TDraftEvent |
||||
httpRelayList: TDraftEvent |
||||
interestList: TDraftEvent |
||||
followList: TDraftEvent |
||||
muteList: TDraftEvent |
||||
} |
||||
|
||||
export function buildNewUserTemplateDrafts(pubkey: string): TNewUserTemplateDrafts { |
||||
return { |
||||
profile: buildNewUserProfileDraft(pubkey), |
||||
favoriteRelays: buildNewUserFavoriteRelaysDraft(), |
||||
relayList: buildNewUserRelayListDraft(), |
||||
httpRelayList: buildNewUserHttpRelayListDraft(), |
||||
interestList: buildNewUserInterestListDraft(), |
||||
followList: buildNewUserFollowListDraft(), |
||||
muteList: buildNewUserMuteListDraft() |
||||
} |
||||
} |
||||
Loading…
Reference in new issue