feat: bunker login
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
export const StorageKey = {
|
||||
THEME_SETTING: 'themeSetting',
|
||||
RELAY_GROUPS: 'relayGroups',
|
||||
ACCOUNT: 'account'
|
||||
ACCOUNTS: 'accounts'
|
||||
}
|
||||
|
||||
@@ -51,6 +51,9 @@ export type TElectronWindow = {
|
||||
}
|
||||
|
||||
export type TAccount = {
|
||||
signerType: 'nsec' | 'browser-nsec' | 'nip-07'
|
||||
pubkey: string
|
||||
signerType: 'nsec' | 'browser-nsec' | 'nip-07' | 'bunker'
|
||||
nsec?: string
|
||||
bunker?: string
|
||||
bunkerClientSecretKey?: string
|
||||
}
|
||||
|
||||
47
src/renderer/src/components/LoginDialog/BunkerLogin.tsx
Normal file
47
src/renderer/src/components/LoginDialog/BunkerLogin.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Button } from '@renderer/components/ui/button'
|
||||
import { Input } from '@renderer/components/ui/input'
|
||||
import { useNostr } from '@renderer/providers/NostrProvider'
|
||||
import { Loader } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function BunkerLogin({ onLoginSuccess }: { onLoginSuccess: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
const { bunkerLogin } = useNostr()
|
||||
const [pending, setPending] = useState(false)
|
||||
const [bunkerInput, setBunkerInput] = useState('')
|
||||
const [errMsg, setErrMsg] = useState<string | null>(null)
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setBunkerInput(e.target.value)
|
||||
setErrMsg(null)
|
||||
}
|
||||
|
||||
const handleLogin = () => {
|
||||
if (bunkerInput === '') return
|
||||
|
||||
setPending(true)
|
||||
bunkerLogin(bunkerInput)
|
||||
.then(() => onLoginSuccess())
|
||||
.catch((err) => setErrMsg(err.message))
|
||||
.finally(() => setPending(false))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
placeholder="bunker://..."
|
||||
value={bunkerInput}
|
||||
onChange={handleInputChange}
|
||||
className={errMsg ? 'border-destructive' : ''}
|
||||
/>
|
||||
{errMsg && <div className="text-xs text-destructive pl-3">{errMsg}</div>}
|
||||
</div>
|
||||
<Button onClick={handleLogin} disabled={pending}>
|
||||
<Loader className={pending ? 'animate-spin' : 'hidden'} />
|
||||
{t('Login')}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import { IS_ELECTRON } from '@renderer/lib/env'
|
||||
import { useNostr } from '@renderer/providers/NostrProvider'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { Dispatch, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import BunkerLogin from './BunkerLogin'
|
||||
import PrivateKeyLogin from './NsecLogin'
|
||||
|
||||
export default function LoginDialog({
|
||||
@@ -19,7 +21,8 @@ export default function LoginDialog({
|
||||
open: boolean
|
||||
setOpen: Dispatch<boolean>
|
||||
}) {
|
||||
const [loginMethod, setLoginMethod] = useState<'nsec' | 'nip07' | null>(null)
|
||||
const { t } = useTranslation()
|
||||
const [loginMethod, setLoginMethod] = useState<'nsec' | 'nip07' | 'bunker' | null>(null)
|
||||
const { nip07Login } = useNostr()
|
||||
|
||||
return (
|
||||
@@ -39,15 +42,28 @@ export default function LoginDialog({
|
||||
</div>
|
||||
<PrivateKeyLogin onLoginSuccess={() => setOpen(false)} />
|
||||
</>
|
||||
) : loginMethod === 'bunker' ? (
|
||||
<>
|
||||
<div
|
||||
className="absolute left-4 top-4 opacity-70 hover:opacity-100 cursor-pointer"
|
||||
onClick={() => setLoginMethod(null)}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</div>
|
||||
<BunkerLogin onLoginSuccess={() => setOpen(false)} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{!IS_ELECTRON && !!window.nostr && (
|
||||
<Button onClick={() => nip07Login().then(() => setOpen(false))} className="w-full">
|
||||
Login with NIP-07
|
||||
{t('Login with Browser Extension')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="secondary" onClick={() => setLoginMethod('bunker')} className="w-full">
|
||||
{t('Login with Bunker')}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setLoginMethod('nsec')} className="w-full">
|
||||
Login with Private Key
|
||||
{t('Login with Private Key')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -84,6 +84,9 @@ export default {
|
||||
'Your nsec will be encrypted using the {{backend}}.':
|
||||
'Your nsec will be encrypted using the {{backend}}.',
|
||||
'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x.':
|
||||
'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x.'
|
||||
'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x.',
|
||||
'Login with Browser Extension': 'Login with Browser Extension',
|
||||
'Login with Bunker': 'Login with Bunker',
|
||||
'Login with Private Key': 'Login with Private Key'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,9 @@ export default {
|
||||
'此设备上没有可用的密码管理工具。您的密钥将不受保护',
|
||||
'Your nsec will be encrypted using the {{backend}}.': '您的密钥将使用 {{backend}} 加密',
|
||||
'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x.':
|
||||
'使用私钥登录是不安全的。建议使用浏览器插件进行登录,例如 alby、nostr-keyx 或 nos2x'
|
||||
'使用私钥登录是不安全的。建议使用浏览器插件进行登录,例如 alby、nostr-keyx 或 nos2x',
|
||||
'Login with Browser Extension': '浏览器插件登录',
|
||||
'Login with Bunker': 'Bunker 登录',
|
||||
'Login with Private Key': '私钥登录'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,9 +39,11 @@ export function FollowListProvider({ children }: { children: React.ReactNode })
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (isReady || !accountPubkey) return
|
||||
if (!accountPubkey) return
|
||||
|
||||
const init = async () => {
|
||||
setIsReady(false)
|
||||
setFollowListEvent(undefined)
|
||||
const event = await client.fetchFollowListEvent(accountPubkey)
|
||||
setFollowListEvent(event)
|
||||
setIsReady(true)
|
||||
|
||||
44
src/renderer/src/providers/NostrProvider/bunker.signer.ts
Normal file
44
src/renderer/src/providers/NostrProvider/bunker.signer.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { ISigner, TDraftEvent } from '@common/types'
|
||||
import { generateSecretKey } from 'nostr-tools'
|
||||
import { BunkerSigner as NBunkerSigner, parseBunkerInput } from 'nostr-tools/nip46'
|
||||
|
||||
export class BunkerSigner implements ISigner {
|
||||
signer: NBunkerSigner | null = null
|
||||
clientSecretKey: Uint8Array
|
||||
|
||||
constructor(clientSecretKey?: Uint8Array) {
|
||||
this.clientSecretKey = clientSecretKey ?? generateSecretKey()
|
||||
}
|
||||
|
||||
async login(bunker: string): Promise<string> {
|
||||
const bunkerPointer = await parseBunkerInput(bunker)
|
||||
if (!bunkerPointer) {
|
||||
throw new Error('Invalid bunker')
|
||||
}
|
||||
|
||||
this.signer = new NBunkerSigner(this.clientSecretKey, bunkerPointer, {
|
||||
onauth: (url) => {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
})
|
||||
await this.signer.connect()
|
||||
return await this.signer.getPublicKey()
|
||||
}
|
||||
|
||||
async getPublicKey() {
|
||||
if (!this.signer) {
|
||||
throw new Error('Not logged in')
|
||||
}
|
||||
return this.signer.getPublicKey()
|
||||
}
|
||||
|
||||
async signEvent(draftEvent: TDraftEvent) {
|
||||
if (!this.signer) {
|
||||
throw new Error('Not logged in')
|
||||
}
|
||||
return this.signer.signEvent({
|
||||
...draftEvent,
|
||||
pubkey: await this.signer.getPublicKey()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ISigner, TDraftEvent } from '@common/types'
|
||||
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
|
||||
import LoginDialog from '@renderer/components/LoginDialog'
|
||||
import { useToast } from '@renderer/hooks'
|
||||
import { useFetchRelayList } from '@renderer/hooks/useFetchRelayList'
|
||||
@@ -10,6 +11,7 @@ import { Event, kinds } from 'nostr-tools'
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
import { useRelaySettings } from '../RelaySettingsProvider'
|
||||
import { BrowserNsecSigner } from './browser-nsec.signer'
|
||||
import { BunkerSigner } from './bunker.signer'
|
||||
import { Nip07Signer } from './nip-07.signer'
|
||||
import { NsecSigner } from './nsec.signer'
|
||||
|
||||
@@ -18,8 +20,9 @@ type TNostrContext = {
|
||||
pubkey: string | null
|
||||
setPubkey: (pubkey: string) => void
|
||||
nsecLogin: (nsec: string) => Promise<string>
|
||||
logout: () => Promise<void>
|
||||
nip07Login: () => Promise<void>
|
||||
bunkerLogin: (bunker: string) => Promise<string>
|
||||
logout: () => Promise<void>
|
||||
/**
|
||||
* Default publish the event to current relays, user's write relays and additional relays
|
||||
*/
|
||||
@@ -50,7 +53,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
const account = await storage.getAccountInfo()
|
||||
const [account] = await storage.getAccounts()
|
||||
if (!account) {
|
||||
if (isElectron(window) || !window.nostr) {
|
||||
return setIsReady(true)
|
||||
@@ -64,14 +67,20 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
setPubkey(pubkey)
|
||||
setSigner(nip07Signer)
|
||||
return setIsReady(true)
|
||||
setIsReady(true)
|
||||
return await storage.setAccounts([{ pubkey, signerType: 'nip-07' }])
|
||||
}
|
||||
|
||||
if (account.pubkey) {
|
||||
setPubkey(account.pubkey)
|
||||
}
|
||||
|
||||
if (account.signerType === 'nsec') {
|
||||
const nsecSigner = new NsecSigner()
|
||||
const pubkey = await nsecSigner.getPublicKey()
|
||||
if (!pubkey) {
|
||||
await storage.setAccountInfo(null)
|
||||
setPubkey(null)
|
||||
await storage.setAccounts([])
|
||||
return setIsReady(true)
|
||||
}
|
||||
setPubkey(pubkey)
|
||||
@@ -81,7 +90,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
if (account.signerType === 'browser-nsec') {
|
||||
if (!account.nsec) {
|
||||
await storage.setAccountInfo(null)
|
||||
setPubkey(null)
|
||||
await storage.setAccounts([])
|
||||
return setIsReady(true)
|
||||
}
|
||||
const browserNsecSigner = new BrowserNsecSigner()
|
||||
@@ -95,7 +105,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
const nip07Signer = new Nip07Signer()
|
||||
const pubkey = await nip07Signer.getPublicKey()
|
||||
if (!pubkey) {
|
||||
await storage.setAccountInfo(null)
|
||||
setPubkey(null)
|
||||
await storage.setAccounts([])
|
||||
return setIsReady(true)
|
||||
}
|
||||
setPubkey(pubkey)
|
||||
@@ -103,11 +114,25 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
return setIsReady(true)
|
||||
}
|
||||
|
||||
await storage.setAccountInfo(null)
|
||||
if (account.signerType === 'bunker') {
|
||||
if (!account.bunker || !account.bunkerClientSecretKey) {
|
||||
setPubkey(null)
|
||||
await storage.setAccounts([])
|
||||
return setIsReady(true)
|
||||
}
|
||||
const bunkerSigner = new BunkerSigner(hexToBytes(account.bunkerClientSecretKey))
|
||||
const pubkey = await bunkerSigner.login(account.bunker)
|
||||
setPubkey(pubkey)
|
||||
setSigner(bunkerSigner)
|
||||
return setIsReady(true)
|
||||
}
|
||||
|
||||
await storage.setAccounts([])
|
||||
return setIsReady(true)
|
||||
}
|
||||
init().catch(() => {
|
||||
storage.setAccountInfo(null)
|
||||
setPubkey(null)
|
||||
storage.setAccounts([])
|
||||
setIsReady(true)
|
||||
})
|
||||
}, [])
|
||||
@@ -119,14 +144,14 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
if (!pubkey) {
|
||||
throw new Error(reason ?? 'invalid nsec')
|
||||
}
|
||||
await storage.setAccountInfo({ signerType: 'nsec' })
|
||||
await storage.setAccounts([{ pubkey, signerType: 'nsec' }])
|
||||
setPubkey(pubkey)
|
||||
setSigner(nsecSigner)
|
||||
return pubkey
|
||||
}
|
||||
const browserNsecSigner = new BrowserNsecSigner()
|
||||
const pubkey = browserNsecSigner.login(nsec)
|
||||
await storage.setAccountInfo({ signerType: 'browser-nsec', nsec })
|
||||
await storage.setAccounts([{ pubkey, signerType: 'browser-nsec', nsec }])
|
||||
setPubkey(pubkey)
|
||||
setSigner(browserNsecSigner)
|
||||
return pubkey
|
||||
@@ -139,7 +164,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
if (!pubkey) {
|
||||
throw new Error('You did not allow to access your pubkey')
|
||||
}
|
||||
await storage.setAccountInfo({ signerType: 'nip-07' })
|
||||
await storage.setAccounts([{ pubkey, signerType: 'nip-07' }])
|
||||
setPubkey(pubkey)
|
||||
setSigner(nip07Signer)
|
||||
} catch (err) {
|
||||
@@ -152,6 +177,27 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
}
|
||||
|
||||
const bunkerLogin = async (bunker: string) => {
|
||||
const bunkerSigner = new BunkerSigner()
|
||||
const pubkey = await bunkerSigner.login(bunker)
|
||||
if (!pubkey) {
|
||||
throw new Error('Invalid bunker')
|
||||
}
|
||||
const bunkerUrl = new URL(bunker)
|
||||
bunkerUrl.searchParams.delete('secret')
|
||||
await storage.setAccounts([
|
||||
{
|
||||
pubkey,
|
||||
signerType: 'bunker',
|
||||
bunker: bunkerUrl.toString(),
|
||||
bunkerClientSecretKey: bytesToHex(bunkerSigner.clientSecretKey)
|
||||
}
|
||||
])
|
||||
setPubkey(pubkey)
|
||||
setSigner(bunkerSigner)
|
||||
return pubkey
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
if (signer instanceof NsecSigner) {
|
||||
await signer.logout()
|
||||
@@ -159,7 +205,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
signer.logout()
|
||||
}
|
||||
setPubkey(null)
|
||||
await storage.setAccountInfo(null)
|
||||
await storage.setAccounts([])
|
||||
}
|
||||
|
||||
const signEvent = async (draftEvent: TDraftEvent) => {
|
||||
@@ -207,6 +253,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
setPubkey,
|
||||
nsecLogin,
|
||||
nip07Login,
|
||||
bunkerLogin,
|
||||
logout,
|
||||
publish,
|
||||
signHttpAuth,
|
||||
|
||||
@@ -42,7 +42,7 @@ class StorageService {
|
||||
private initPromise!: Promise<void>
|
||||
private relayGroups: TRelayGroup[] = []
|
||||
private themeSetting: TThemeSetting = 'system'
|
||||
private account: TAccount | null = null
|
||||
private accounts: TAccount[] = []
|
||||
private storage: Storage = new Storage()
|
||||
|
||||
constructor() {
|
||||
@@ -58,8 +58,8 @@ class StorageService {
|
||||
this.relayGroups = relayGroupsStr ? JSON.parse(relayGroupsStr) : DEFAULT_RELAY_GROUPS
|
||||
this.themeSetting =
|
||||
((await this.storage.getItem(StorageKey.THEME_SETTING)) as TThemeSetting) ?? 'system'
|
||||
const accountStr = await this.storage.getItem(StorageKey.ACCOUNT)
|
||||
this.account = accountStr ? JSON.parse(accountStr) : null
|
||||
const accountsStr = await this.storage.getItem(StorageKey.ACCOUNTS)
|
||||
this.accounts = accountsStr ? JSON.parse(accountsStr) : []
|
||||
}
|
||||
|
||||
async getRelayGroups() {
|
||||
@@ -84,19 +84,19 @@ class StorageService {
|
||||
this.themeSetting = themeSetting
|
||||
}
|
||||
|
||||
async getAccountInfo() {
|
||||
async getAccounts() {
|
||||
await this.initPromise
|
||||
return this.account
|
||||
return this.accounts
|
||||
}
|
||||
|
||||
async setAccountInfo(account: TAccount | null) {
|
||||
async setAccounts(accounts: TAccount[]) {
|
||||
await this.initPromise
|
||||
if (account === null) {
|
||||
await this.storage.removeItem(StorageKey.ACCOUNT)
|
||||
if (accounts === null) {
|
||||
await this.storage.removeItem(StorageKey.ACCOUNTS)
|
||||
} else {
|
||||
await this.storage.setItem(StorageKey.ACCOUNT, JSON.stringify(account))
|
||||
await this.storage.setItem(StorageKey.ACCOUNTS, JSON.stringify(accounts))
|
||||
}
|
||||
this.account = account
|
||||
this.accounts = accounts
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user