12 changed files with 388 additions and 204 deletions
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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