src/features/account/utils.ts
Rama main · réplica de lectura del repositorio original.
import QRCode from 'qrcode'
import { createSiweMessage } from 'viem/siwe'
import { createPasskeyRegistrationOptions, linkWalletAddress, verifyPasskeyRegistration } from '@/lib/api/account'
import { requestWalletNonce } from '@/lib/auth-client'
export const accountTimeZoneStorageKey = 'realmroot.account.time-zone'
const supportedTimeZones =
(Intl as typeof Intl & { supportedValuesOf?: (key: 'timeZone') => string[] }).supportedValuesOf?.('timeZone') ?? []
export const accountTimeZones = ['UTC', ...supportedTimeZones.filter((timeZone) => timeZone !== 'UTC')]
export type TotpEnrollmentDisplay = {
qrCode: string | null
otpAuthUri: string | null
secret: string | null
backupCodes: string[]
}
export function readTotpEnrollment(value: unknown): TotpEnrollmentDisplay {
const record = asRecord(value)
const otpAuthUri =
readString(record.otpAuthUri) ??
readString(record.otpAuthURI) ??
readString(record.totpURI) ??
readString(record.totpUri) ??
readString(record.uri)
return {
qrCode: readString(record.qrCode) ?? readString(record.qrCodeUrl) ?? readString(record.qr),
otpAuthUri,
secret: readString(record.secret) ?? readOtpAuthSecret(otpAuthUri),
backupCodes: readStringArray(record.backupCodes),
}
}
function readOtpAuthSecret(uri: string | null) {
if (!uri) return null
try {
return readString(new URL(uri).searchParams.get('secret'))
} catch {
return null
}
}
export async function withTotpQrCode(enrollment: TotpEnrollmentDisplay): Promise<TotpEnrollmentDisplay> {
if (enrollment.qrCode || !enrollment.otpAuthUri) return enrollment
const svg = await QRCode.toString(enrollment.otpAuthUri, {
errorCorrectionLevel: 'M',
margin: 1,
type: 'svg',
width: 192,
})
return {
...enrollment,
qrCode: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`,
}
}
export function readStringArray(value: unknown): string[] {
return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string' && item.length > 0) : []
}
export async function enrollPasskey(name: string) {
const options = await createPasskeyRegistrationOptions({ name: name || undefined })
const credential = await createPasskeyCredential(options)
return verifyPasskeyRegistration({
response: credential,
name: name || undefined,
})
}
export async function enrollWallet(enabledChains: number[]) {
const ethereum = window.ethereum
if (!ethereum) throw new Error('No wallet provider was found in this browser.')
const accounts = await ethereum.request({ method: 'eth_requestAccounts' })
const walletAddress = readFirstString(accounts)
if (!walletAddress) throw new Error('No wallet account was selected.')
const chainId = readChainId(await ethereum.request({ method: 'eth_chainId' }))
if (!enabledChains.includes(chainId)) {
throw new Error(`This wallet network is not enabled. Switch to chain ${enabledChains[0]}.`)
}
const { nonce } = await requestWalletNonce({ walletAddress, chainId })
const message = createSiweMessage({
address: walletAddress as `0x${string}`,
chainId,
domain: window.location.host,
nonce,
statement: 'Link this wallet to Realmroot.',
uri: window.location.origin,
version: '1',
})
const signature = readString(await ethereum.request({ method: 'personal_sign', params: [message, walletAddress] }))
if (!signature) throw new Error('Wallet did not return a signature.')
return linkWalletAddress({ message, signature, walletAddress, chainId })
}
async function createPasskeyCredential(optionsResponse: unknown) {
if (!navigator.credentials?.create) throw new Error('Passkey registration is not supported by this browser.')
const credential = await navigator.credentials.create({ publicKey: passkeyCreationOptions(optionsResponse) })
if (!credential) throw new Error('Passkey registration was cancelled.')
return serializePasskeyCredential(credential)
}
function passkeyCreationOptions(optionsResponse: unknown): PublicKeyCredentialCreationOptions {
const response = asRecord(optionsResponse)
const options = asRecord(
response.publicKey ?? asRecord(response.options).publicKey ?? response.options ?? optionsResponse,
)
const user = asRecord(options.user)
return {
...options,
challenge: base64UrlToBuffer(readRequiredString(options.challenge, 'challenge')),
user: {
...user,
id: base64UrlToBuffer(readRequiredString(user.id, 'user.id')),
name: readRequiredString(user.name, 'user.name'),
displayName: readRequiredString(user.displayName, 'user.displayName'),
},
excludeCredentials: Array.isArray(options.excludeCredentials)
? options.excludeCredentials.map((credential) => {
const credentialRecord = asRecord(credential)
return {
...credentialRecord,
id: base64UrlToBuffer(readRequiredString(credentialRecord.id, 'excludeCredentials.id')),
} as PublicKeyCredentialDescriptor
})
: undefined,
} as PublicKeyCredentialCreationOptions
}
function serializePasskeyCredential(credential: Credential) {
const publicKeyCredential = credential as PublicKeyCredential
const response = publicKeyCredential.response as AuthenticatorAttestationResponse
return {
id: publicKeyCredential.id,
rawId: bufferToBase64Url(publicKeyCredential.rawId),
type: publicKeyCredential.type,
response: {
attestationObject: bufferToBase64Url(response.attestationObject),
clientDataJSON: bufferToBase64Url(response.clientDataJSON),
transports: response.getTransports?.() ?? [],
},
clientExtensionResults: publicKeyCredential.getClientExtensionResults?.() ?? {},
}
}
export function base64UrlToBuffer(value: string): ArrayBuffer {
const normalized = value.replaceAll('-', '+').replaceAll('_', '/')
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
const binary = atob(padded)
const bytes = new Uint8Array(binary.length)
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index)
return bytes.buffer
}
export function bufferToBase64Url(buffer: ArrayBuffer) {
const bytes = new Uint8Array(buffer)
let binary = ''
for (const byte of bytes) binary += String.fromCharCode(byte)
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '')
}
export function asRecord(value: unknown): Record<string, unknown> {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {}
}
export function readString(value: unknown) {
return typeof value === 'string' && value.length > 0 ? value : null
}
export function readFirstString(value: unknown) {
return Array.isArray(value) && typeof value[0] === 'string' ? value[0] : null
}
export function readChainId(value: unknown) {
if (typeof value === 'number') return value
if (typeof value === 'string' && value.startsWith('0x')) return Number.parseInt(value, 16)
if (typeof value === 'string') return Number(value)
throw new Error('Wallet did not return a chain ID.')
}
export function readRequiredString(value: unknown, field: string) {
if (typeof value !== 'string' || value.length === 0) {
throw new Error(`Passkey registration option ${field} is required.`)
}
return value
}
export function formatDate(value: string | Date) {
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeZone: readAccountTimeZone() }).format(
new Date(value),
)
}
export function readAccountTimeZone() {
const stored = window.localStorage.getItem(accountTimeZoneStorageKey)
if (stored && accountTimeZones.includes(stored)) return stored
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
}
export function saveAccountTimeZone(timeZone: string) {
if (!accountTimeZones.includes(timeZone)) {
throw new Error('Unsupported time zone.')
}
window.localStorage.setItem(accountTimeZoneStorageKey, timeZone)
}
export function readRedirectUrl(response: unknown) {
if (typeof response !== 'object' || response === null) return null
if ('url' in response && typeof response.url === 'string') return response.url
if ('redirectTo' in response && typeof response.redirectTo === 'string') return response.redirectTo
if ('callbackURL' in response && typeof response.callbackURL === 'string') return response.callbackURL
return null
}
export function formatSessionDevice(userAgent: string | null) {
if (!userAgent) return 'Unknown device'
if (!userAgent.includes('/')) return userAgent
const browser = userAgent.includes('Edg/')
? 'Edge'
: userAgent.includes('Chrome/')
? 'Chrome'
: userAgent.includes('Firefox/')
? 'Firefox'
: userAgent.includes('Safari/')
? 'Safari'
: 'Browser'
const platform = userAgent.includes('Mac OS X')
? 'macOS'
: userAgent.includes('Windows')
? 'Windows'
: userAgent.includes('Android')
? 'Android'
: userAgent.includes('iPhone') || userAgent.includes('iPad')
? 'iOS'
: null
return platform ? `${browser} on ${platform}` : `${browser} session`
}