signup
This commit is contained in:
@@ -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)
|
||||
}
|
||||
229
src/components/AccountManager/Signup.tsx
Normal file
229
src/components/AccountManager/Signup.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { ArrowLeft, Check, Copy, Download, RefreshCcw } from 'lucide-react'
|
||||
import { generateSecretKey } from 'nostr-tools'
|
||||
import { nsecEncode } from 'nostr-tools/nip19'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import InfoCard from '../InfoCard'
|
||||
|
||||
type Step = 'generate' | 'password'
|
||||
|
||||
export default function Signup({
|
||||
back,
|
||||
onSignupSuccess
|
||||
}: {
|
||||
back: () => void
|
||||
onSignupSuccess: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { nsecLogin } = useNostr()
|
||||
const [step, setStep] = useState<Step>('generate')
|
||||
const [nsec, setNsec] = useState(generateNsec())
|
||||
const [checkedSaveKey, setCheckedSaveKey] = useState(false)
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleDownload = () => {
|
||||
const blob = new Blob([nsec], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'nostr-private-key.txt'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const handleSignup = async () => {
|
||||
await nsecLogin(nsec, password || undefined, true)
|
||||
onSignupSuccess()
|
||||
}
|
||||
|
||||
const passwordsMatch = password === confirmPassword
|
||||
const canSubmit = !password || passwordsMatch
|
||||
|
||||
const renderStepIndicator = () => (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{(['generate', 'password'] as Step[]).map((s, index) => (
|
||||
<div key={s} className="flex items-center">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold ${
|
||||
step === s
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: step === 'password' && s === 'generate'
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
{index < 1 && <div className="w-12 h-0.5 bg-muted mx-1" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (step === 'generate') {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{renderStepIndicator()}
|
||||
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold mb-2">{t('Generate Your Account')}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('Your private key IS your account. Keep it safe!')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<InfoCard
|
||||
variant="alert"
|
||||
title={t('Important')}
|
||||
content={t(
|
||||
'In Nostr, your private key IS your account. If you lose your private key, you lose your account forever.'
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>{t('Your Private Key')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={nsec}
|
||||
readOnly
|
||||
className="font-mono text-sm"
|
||||
onClick={(e) => e.currentTarget.select()}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={() => setNsec(generateNsec())}
|
||||
title={t('Generate new key')}
|
||||
>
|
||||
<RefreshCcw />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex gap-2 items-center">
|
||||
<Button onClick={handleDownload} className="w-full">
|
||||
<Download />
|
||||
{t('Download Backup File')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(nsec)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}}
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
>
|
||||
{copied ? <Check /> : <Copy />}
|
||||
{copied ? t('Copied to Clipboard') : t('Copy to Clipboard')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 ml-2">
|
||||
<Checkbox
|
||||
id="acknowledge-checkbox"
|
||||
checked={checkedSaveKey}
|
||||
onCheckedChange={(c) => setCheckedSaveKey(!!c)}
|
||||
/>
|
||||
<Label htmlFor="acknowledge-checkbox" className="cursor-pointer">
|
||||
{t('I already saved my private key securely.')}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" onClick={back} className="w-fit px-6">
|
||||
<ArrowLeft />
|
||||
{t('Back')}
|
||||
</Button>
|
||||
|
||||
<Button onClick={() => setStep('password')} className="flex-1" disabled={!checkedSaveKey}>
|
||||
{t('Continue')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// step === 'password'
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{renderStepIndicator()}
|
||||
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold mb-2">{t('Almost Done!')}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('Set a password to encrypt your key, or skip to finish')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<InfoCard
|
||||
title={t('Password Protection (Optional)')}
|
||||
content={t(
|
||||
'Setting a password encrypts your private key in this browser. You can skip this step, but we recommend setting one for added security.'
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="password-input">{t('Password (Optional)')}</Label>
|
||||
<Input
|
||||
id="password-input"
|
||||
type="password"
|
||||
placeholder={t('Enter password or leave empty to skip')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{password && (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="confirm-password-input">{t('Confirm Password')}</Label>
|
||||
<Input
|
||||
id="confirm-password-input"
|
||||
type="password"
|
||||
placeholder={t('Re-enter password')}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
{confirmPassword && !passwordsMatch && (
|
||||
<p className="text-xs text-red-500">{t('Passwords do not match')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setStep('generate')
|
||||
setPassword('')
|
||||
setConfirmPassword('')
|
||||
}}
|
||||
className="w-fit px-6"
|
||||
>
|
||||
<ArrowLeft />
|
||||
{t('Back')}
|
||||
</Button>
|
||||
<Button onClick={handleSignup} className="flex-1" disabled={!canSubmit}>
|
||||
{t('Finish Signup')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function generateNsec() {
|
||||
const sk = generateSecretKey()
|
||||
return nsecEncode(sk)
|
||||
}
|
||||
@@ -2,17 +2,15 @@ import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { isDevEnv } from '@/lib/utils'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useTheme } from '@/providers/ThemeProvider'
|
||||
import { NstartModal } from 'nstart-modal'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AccountList from '../AccountList'
|
||||
import GenerateNewAccount from './GenerateNewAccount'
|
||||
import NostrConnectLogin from './NostrConnectionLogin'
|
||||
import NpubLogin from './NpubLogin'
|
||||
import PrivateKeyLogin from './PrivateKeyLogin'
|
||||
import Signup from './Signup'
|
||||
|
||||
type TAccountManagerPage = 'nsec' | 'bunker' | 'generate' | 'npub' | null
|
||||
type TAccountManagerPage = 'nsec' | 'bunker' | 'npub' | 'signup' | null
|
||||
|
||||
export default function AccountManager({ close }: { close?: () => void }) {
|
||||
const [page, setPage] = useState<TAccountManagerPage>(null)
|
||||
@@ -23,10 +21,10 @@ export default function AccountManager({ close }: { close?: () => void }) {
|
||||
<PrivateKeyLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
|
||||
) : page === 'bunker' ? (
|
||||
<NostrConnectLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
|
||||
) : page === 'generate' ? (
|
||||
<GenerateNewAccount back={() => setPage(null)} onLoginSuccess={() => close?.()} />
|
||||
) : page === 'npub' ? (
|
||||
<NpubLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
|
||||
) : page === 'signup' ? (
|
||||
<Signup back={() => setPage(null)} onSignupSuccess={() => close?.()} />
|
||||
) : (
|
||||
<AccountManagerNav setPage={setPage} close={close} />
|
||||
)}
|
||||
@@ -41,9 +39,8 @@ function AccountManagerNav({
|
||||
setPage: (page: TAccountManagerPage) => void
|
||||
close?: () => void
|
||||
}) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { themeSetting } = useTheme()
|
||||
const { nip07Login, bunkerLogin, nsecLogin, ncryptsecLogin, accounts } = useNostr()
|
||||
const { t } = useTranslation()
|
||||
const { nip07Login, accounts } = useNostr()
|
||||
|
||||
return (
|
||||
<div onClick={(e) => e.stopPropagation()} className="flex flex-col gap-8">
|
||||
@@ -75,38 +72,8 @@ function AccountManagerNav({
|
||||
<div className="text-center text-muted-foreground text-sm font-semibold">
|
||||
{t("Don't have an account yet?")}
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const wizard = new NstartModal({
|
||||
baseUrl: 'https://nstart.me',
|
||||
an: 'Jumble',
|
||||
am: themeSetting === 'pure-black' ? 'dark' : themeSetting,
|
||||
al: i18n.language.slice(0, 2),
|
||||
onComplete: ({ nostrLogin }) => {
|
||||
if (!nostrLogin) return
|
||||
|
||||
if (nostrLogin.startsWith('bunker://')) {
|
||||
bunkerLogin(nostrLogin)
|
||||
} else if (nostrLogin.startsWith('ncryptsec')) {
|
||||
ncryptsecLogin(nostrLogin)
|
||||
} else if (nostrLogin.startsWith('nsec')) {
|
||||
nsecLogin(nostrLogin)
|
||||
}
|
||||
}
|
||||
})
|
||||
close?.()
|
||||
wizard.open()
|
||||
}}
|
||||
className="w-full mt-4"
|
||||
>
|
||||
{t('Sign up')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => setPage('generate')}
|
||||
className="w-full text-muted-foreground py-0 h-fit mt-1"
|
||||
>
|
||||
{t('or simply generate a private key')}
|
||||
<Button onClick={() => setPage('signup')} className="w-full mt-4">
|
||||
{t('Create New Account')}
|
||||
</Button>
|
||||
</div>
|
||||
{accounts.length > 0 && (
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { TriangleAlert } from 'lucide-react'
|
||||
|
||||
export default function AlertCard({ title, content }: { title: string; content: string }) {
|
||||
return (
|
||||
<div className="p-3 rounded-lg text-sm bg-amber-100/20 dark:bg-amber-950/20 border border-amber-500 text-amber-500 [&_svg]:size-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<TriangleAlert />
|
||||
<div className="font-medium">{title}</div>
|
||||
</div>
|
||||
<div className="pl-6">{content}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
36
src/components/InfoCard/index.tsx
Normal file
36
src/components/InfoCard/index.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import { CheckCircle2, Info, TriangleAlert } from 'lucide-react'
|
||||
|
||||
const ICON_MAP = {
|
||||
info: <Info />,
|
||||
success: <CheckCircle2 />,
|
||||
alert: <TriangleAlert />
|
||||
}
|
||||
|
||||
const VARIANT_STYLES = {
|
||||
info: 'bg-blue-100/20 dark:bg-blue-950/20 border border-blue-500 text-blue-500',
|
||||
success: 'bg-green-100/20 dark:bg-green-950/20 border border-green-500 text-green-500',
|
||||
alert: 'bg-amber-100/20 dark:bg-amber-950/20 border border-amber-500 text-amber-500'
|
||||
}
|
||||
|
||||
export default function InfoCard({
|
||||
title,
|
||||
content,
|
||||
icon,
|
||||
variant = 'info'
|
||||
}: {
|
||||
title: string
|
||||
content?: string
|
||||
icon?: React.ReactNode
|
||||
variant?: 'info' | 'success' | 'alert'
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('p-3 rounded-lg text-sm [&_svg]:size-4', VARIANT_STYLES[variant])}>
|
||||
<div className="flex items-center gap-2">
|
||||
{icon ?? ICON_MAP[variant]}
|
||||
<div className="font-medium">{title}</div>
|
||||
</div>
|
||||
{content && <div className="pl-6">{content}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TMailboxRelay } from '@/types'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AlertCard from '../AlertCard'
|
||||
import InfoCard from '../InfoCard'
|
||||
|
||||
export default function RelayCountWarning({ relays }: { relays: TMailboxRelay[] }) {
|
||||
const { t } = useTranslation()
|
||||
@@ -19,7 +19,8 @@ export default function RelayCountWarning({ relays }: { relays: TMailboxRelay[]
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertCard
|
||||
<InfoCard
|
||||
variant="alert"
|
||||
title={showReadWarning ? t('Too many read relays') : t('Too many write relays')}
|
||||
content={
|
||||
showReadWarning
|
||||
|
||||
@@ -8,7 +8,7 @@ import dayjs from 'dayjs'
|
||||
import { Eraser, X } from 'lucide-react'
|
||||
import { Dispatch, SetStateAction, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AlertCard from '../AlertCard'
|
||||
import InfoCard from '../InfoCard'
|
||||
|
||||
export default function PollEditor({
|
||||
pollCreateData,
|
||||
@@ -125,7 +125,8 @@ export default function PollEditor({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<AlertCard
|
||||
<InfoCard
|
||||
variant="alert"
|
||||
title={t('This is a poll note.')}
|
||||
content={t(
|
||||
'Unlike regular notes, polls are not widely supported and may not display on other clients.'
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function TooManyRelaysAlertDialog() {
|
||||
const dismissed = storage.getDismissedTooManyRelaysAlert()
|
||||
if (dismissed) return
|
||||
|
||||
if (relayList && (relayList.read.length > 4 || relayList.write.length > 4)) {
|
||||
if (relayList && (relayList.read.length > 5 || relayList.write.length > 5)) {
|
||||
setOpen(true)
|
||||
} else {
|
||||
setOpen(false)
|
||||
|
||||
@@ -25,7 +25,7 @@ const buttonVariants = cva(
|
||||
default: 'h-9 px-4 py-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-10 rounded-lg px-8',
|
||||
icon: 'h-9 w-9',
|
||||
icon: 'h-9 w-9 shrink-0',
|
||||
'titlebar-icon': 'h-10 w-10 shrink-0 rounded-xl [&_svg]:size-5'
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { kinds } from 'nostr-tools'
|
||||
import { TMailboxRelay } from './types'
|
||||
|
||||
export const JUMBLE_API_BASE_URL = 'https://api.jumble.social'
|
||||
|
||||
@@ -71,6 +72,13 @@ export const SEARCHABLE_RELAY_URLS = ['wss://relay.nostr.band/', 'wss://search.n
|
||||
|
||||
export const TRENDING_NOTES_RELAY_URLS = ['wss://trending.relays.land/']
|
||||
|
||||
export const NEW_USER_RELAY_LIST: TMailboxRelay[] = [
|
||||
{ url: 'wss://nos.lol/', scope: 'both' },
|
||||
{ url: 'wss://offchain.pub/', scope: 'both' },
|
||||
{ url: 'wss://relay.damus.io/', scope: 'both' },
|
||||
{ url: 'wss://nostr.mom/', scope: 'both' }
|
||||
]
|
||||
|
||||
export const GROUP_METADATA_EVENT_KIND = 39000
|
||||
|
||||
export const ExtendedKind = {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import LoginDialog from '@/components/LoginDialog'
|
||||
import { ApplicationDataKey, BIG_RELAY_URLS, ExtendedKind } from '@/constants'
|
||||
import { ApplicationDataKey, BIG_RELAY_URLS, ExtendedKind, NEW_USER_RELAY_LIST } from '@/constants'
|
||||
import {
|
||||
createDeletionRequestDraftEvent,
|
||||
createFollowListDraftEvent,
|
||||
@@ -614,14 +614,13 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
const setupNewUser = async (signer: ISigner) => {
|
||||
const relays = NEW_USER_RELAY_LIST.map((item) => item.url)
|
||||
await Promise.allSettled([
|
||||
client.publishEvent(BIG_RELAY_URLS, await signer.signEvent(createFollowListDraftEvent([]))),
|
||||
client.publishEvent(BIG_RELAY_URLS, await signer.signEvent(createMuteListDraftEvent([]))),
|
||||
client.publishEvent(relays, await signer.signEvent(createFollowListDraftEvent([]))),
|
||||
client.publishEvent(relays, await signer.signEvent(createMuteListDraftEvent([]))),
|
||||
client.publishEvent(
|
||||
BIG_RELAY_URLS,
|
||||
await signer.signEvent(
|
||||
createRelayListDraftEvent(BIG_RELAY_URLS.map((url) => ({ url, scope: 'both' })))
|
||||
)
|
||||
relays.concat(BIG_RELAY_URLS),
|
||||
await signer.signEvent(createRelayListDraftEvent(NEW_USER_RELAY_LIST))
|
||||
)
|
||||
])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user