Repository Public
1// Pieces shared by the shell and the account screens (sign-in, invitation, recovery, settings, administration).2import { useQueryClient } from '@tanstack/react-query';3import { type ReactNode, useId, useState } from 'react';4import { useLocation } from 'wouter';5import { patch, post } from '../api.ts';6import { Glyph } from '../glyphs.tsx';7import { getLocale, type Locale, setLocale, useT } from '../i18n.ts';8import { AUTH_KEY, PasskeyCancelled, withFreshAuth } from '../session.ts';9import { setTheme, useTheme } from '../theme.ts';10import type { AuthState } from '../types.ts';11import { Button, copyText, cx, IconButton, useToast } from '../ui.tsx';1213/** The SP Drive mark (same drawing as public/pwa/favicon.svg). */14export function Mark({ size = 32 }: { size?: number }) {15 const id = useId().replace(/:/g, '');16 return (17 <svg width={size} height={size} viewBox="0 0 64 64" aria-hidden="true" focusable="false" className="shrink-0">18 <defs><linearGradient id={id} x1="0" y1="0" x2="1" y2="1"><stop offset="0" stopColor="#4f46e5" /><stop offset="1" stopColor="#9b3dea" /></linearGradient></defs>19 <rect width="64" height="64" rx="15" fill={`url(#${id})`} />20 <path d="M14 30v-7a4 4 0 0 1 4-4h8.3a3 3 0 0 1 2.1.9L31.5 23H46a4 4 0 0 1 4 4v3z" fill="#fff" fillOpacity=".55" />21 <rect x="14" y="27" width="36" height="21" rx="5" fill="#fff" />22 <path d="M25.5 37.5l4.5 4.500 8.500-9" fill="none" stroke="#5b3fe6" strokeWidth="3.4" strokeLinecap="round" strokeLinejoin="round" />23 </svg>24 );25}2627/** Sign out, forget every cached answer of this account, and show the sign-in page. */28export function useSignOut(): () => Promise<void> {29 const qc = useQueryClient();30 const [, navigate] = useLocation();31 return async () => {32 await post('/auth/logout').catch(() => undefined); // offline or already expired: the local state is cleared anyway33 qc.setQueryData<AuthState>(AUTH_KEY, { authenticated: false });34 navigate('/login', { replace: true });35 qc.removeQueries({ predicate: (q) => q.queryKey[0] !== AUTH_KEY[0] });36 };37}38/** Change the interface language now, and remember it on the account when signed in. */39export function changeLocale(l: Locale, signedIn: boolean): void {40 setLocale(l);41 if (signedIn) void patch('/account', { locale: l }).catch(() => undefined);42}43export const otherLocale = (): Locale => (getLocale() === 'fr' ? 'en' : 'fr');4445export function ThemeToggle({ className }: { className?: string }) {46 const t = useT();47 const { dark } = useTheme();48 return <IconButton icon={dark ? 'sun' : 'moon'} label={dark ? t('account.theme.toLight') : t('account.theme.toDark')} onClick={() => setTheme(dark ? 'light' : 'dark')} className={className} />;49}50export function LanguageToggle({ signedIn = false }: { signedIn?: boolean }) {51 const t = useT();52 return (53 <button type="button" onClick={() => changeLocale(otherLocale(), signedIn)} aria-label={t('account.lang.switchTo')} title={t('account.lang.switchTo')}54 className="press inline-flex h-11 min-w-11 items-center justify-center gap-1.5 rounded-full px-3 text-sm font-semibold text-ink-2 hover:bg-hover hover:text-ink">55 <Glyph name="globe" size={18} /><span aria-hidden="true">{otherLocale().toUpperCase()}</span>56 </button>57 );58}5960/** Frame of the signed-out pages: the mark in its light, a large title, and one glass card for the action. */61export function AuthFrame({ title, subtitle, children, footer, wide }: { title: string; subtitle?: ReactNode; children: ReactNode; footer?: ReactNode; wide?: boolean }) {62 const t = useT();63 return (64 <div className="aurora safe-t safe-b flex min-h-dvh flex-col overflow-hidden bg-bg">65 <header className="flex items-center justify-between py-2 pr-3 pl-5 sm:px-6 sm:py-3">66 <span className="text-[15px] font-bold tracking-tight text-ink">{t('common.appName')}</span>67 <div className="flex items-center"><LanguageToggle /><ThemeToggle /></div>68 </header>69 <main className="flex flex-1 flex-col justify-center px-5 pt-4 pb-10">70 <div className={cx('anim-rise mx-auto w-full', wide ? 'max-w-lg' : 'max-w-md')}>71 <div className="mb-7 px-1">72 <span className="relative inline-flex">73 <span aria-hidden="true" className="grad absolute inset-1 rounded-3xl opacity-60 blur-2xl" />74 <span className="anim-float relative inline-flex"><Mark size={68} /></span>75 </span>76 <h1 className="mt-7 text-[34px] leading-[1.06] font-bold tracking-[-0.035em] text-ink sm:text-[40px]">{title}</h1>77 {subtitle ? <p className="mt-3 text-[17px] leading-relaxed text-ink-2">{subtitle}</p> : null}78 </div>79 <div className="glass rounded-[28px] p-5 shadow-pop sm:p-7">{children}</div>80 {footer ? <div className="mt-6 text-center text-[15px] text-ink-2">{footer}</div> : null}81 </div>82 </main>83 </div>84 );85}86/** Inline error, announced to assistive technology. */87export function Alert({ children, tone = 'danger' }: { children: ReactNode; tone?: 'danger' | 'warn' | 'info' }) {88 const c = { danger: 'bg-danger-soft text-danger', warn: 'bg-warn-soft text-warn', info: 'bg-accent-soft text-accent' }[tone];89 return (90 <div role={tone === 'danger' ? 'alert' : 'status'} className={cx('flex items-start gap-2.5 rounded-2xl px-3.5 py-3 text-sm leading-snug', c)}>91 <Glyph name={tone === 'info' ? 'info' : 'warning'} size={18} className="mt-px shrink-0" /><div className="min-w-0">{children}</div>92 </div>93 );94}95export const linkCls = 'font-semibold text-accent underline-offset-4 hover:underline';9697// --- recovery codes: shown once, to keep outside the device ---98function printCodes(title: string, lines: string[]): void {99 // A print-only block next to #root: nothing else of the page is printed, and no new window or frame is needed.100 const box = document.createElement('div'); box.id = 'spd-print';101 const h = document.createElement('h1'); h.textContent = title; box.appendChild(h);102 const pre = document.createElement('pre'); pre.textContent = lines.join('\n'); box.appendChild(pre);103 const style = document.createElement('style');104 style.textContent = '@media screen{#spd-print{display:none}}@media print{body>*:not(#spd-print){display:none!important}#spd-print{display:block;padding:24px;color:#000;background:#fff;font-family:ui-monospace,Menlo,monospace}#spd-print h1{font:600 16px system-ui,sans-serif;margin:0 0 16px}#spd-print pre{font-size:15px;line-height:1.9;margin:0}}';105 document.head.appendChild(style); document.body.appendChild(box);106 const clean = () => { box.remove(); style.remove(); window.removeEventListener('afterprint', clean); };107 window.addEventListener('afterprint', clean);108 window.print();109 setTimeout(clean, 60_000); // some browsers never fire afterprint110}111export function RecoveryCodes({ codes, email }: { codes: string[]; email?: string }) {112 const t = useT();113 const toast = useToast();114 const [copied, setCopied] = useState(false);115 const header = `${t('account.codes.fileTitle')}${email ? ` — ${email}` : ''}`;116 const text = [header, new Date().toISOString().slice(0, 10), '', ...codes, '', t('account.codes.fileNote'), ''].join('\n');117 const download = () => {118 const url = URL.createObjectURL(new Blob([text], { type: 'text/plain;charset=utf-8' }));119 const a = document.createElement('a'); a.href = url; a.download = 'sp-drive-codes-recuperation.txt'; document.body.appendChild(a); a.click(); a.remove();120 setTimeout(() => URL.revokeObjectURL(url), 5_000);121 };122 return (123 <div className="flex flex-col gap-3">124 <ol aria-label={t('account.codes.listLabel')} data-testid="recovery-codes" className="grid grid-cols-1 gap-x-6 gap-y-2 rounded-2xl bg-hover px-4 py-3.5 font-mono text-[13px] text-ink sm:grid-cols-2">125 {codes.map((c, i) => <li key={c} className="flex items-center gap-2 whitespace-nowrap"><span className="w-5 text-right text-xs text-ink-3" aria-hidden="true">{i + 1}</span><span className="select-all tracking-wide">{c}</span></li>)}126 </ol>127 <div className="flex flex-wrap gap-2">128 <Button icon={copied ? 'check' : 'copy'} onClick={async () => { if (await copyText(codes.join('\n'))) { setCopied(true); setTimeout(() => setCopied(false), 1800); } else toast(t('account.codes.copyFailed'), { tone: 'error' }); }}>{copied ? t('common.copied') : t('common.copy')}</Button>129 <Button icon="download" onClick={download}>{t('account.codes.download')}</Button>130 <Button icon="print" onClick={() => printCodes(header, codes)}>{t('account.codes.print')}</Button>131 </div>132 </div>133 );134}135/** The codes, an explanation, and a box to tick before going on: there is no second chance to see them. */136export function RecoveryCodesStep({ codes, email, onDone, doneLabel }: { codes: string[]; email?: string; onDone: () => void; doneLabel: string }) {137 const t = useT();138 const id = useId();139 const [kept, setKept] = useState(false);140 return (141 <div className="flex flex-col gap-4">142 <Alert tone="warn">{t('account.codes.once')}</Alert>143 <RecoveryCodes codes={codes} email={email} />144 <label htmlFor={id} className="flex min-h-10 cursor-pointer items-center gap-3 text-sm font-medium text-ink">145 <input id={id} type="checkbox" checked={kept} onChange={(e) => setKept(e.currentTarget.checked)} className="h-5 w-5 shrink-0 accent-[var(--accent)]" />146 {t('account.codes.kept')}147 </label>148 <Button variant="primary" size="lg" disabled={!kept} onClick={onDone} className="w-full">{doneLabel}</Button>149 </div>150 );151}152153/** "Chrome · macOS" from a user-agent string; the raw string when nothing is recognised. */154export function summariseUA(ua: string, unknown: string): { text: string; icon: 'device' | 'laptop' } {155 if (!ua) return { text: unknown, icon: 'laptop' };156 const os = /iPhone/.test(ua) ? 'iPhone' : /iPad/.test(ua) ? 'iPad' : /Android/.test(ua) ? 'Android' : /Mac OS X|Macintosh/.test(ua) ? 'macOS' : /Windows/.test(ua) ? 'Windows' : /CrOS/.test(ua) ? 'ChromeOS' : /Linux/.test(ua) ? 'Linux' : '';157 const browser = /Edg(e|A|iOS)?\//.test(ua) ? 'Edge' : /OPR\//.test(ua) ? 'Opera' : /Firefox\/|FxiOS\//.test(ua) ? 'Firefox' : /Chrome\/|CriOS\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : /curl\//.test(ua) ? 'curl' : '';158 const text = [browser, os].filter(Boolean).join(' · ');159 return { text: text || ua.slice(0, 60), icon: /iPhone|Android.*Mobile/.test(ua) ? 'device' : 'laptop' };160}161162/** A titled block of a settings or administration page. */163export function Section({ title, text, action, children }: { title: string; text?: ReactNode; action?: ReactNode; children: ReactNode }) {164 return (165 <section className="card">166 <header className="flex flex-wrap items-start justify-between gap-x-4 gap-y-3 px-5 pt-5 sm:px-6">167 <div className="min-w-0 flex-1 basis-56"><h2 className="text-[17px] font-bold tracking-tight text-ink">{title}</h2>{text ? <p className="mt-1 text-sm leading-relaxed text-ink-2">{text}</p> : null}</div>168 {action}169 </header>170 <div className="px-5 pt-3 pb-5 sm:px-6">{children}</div>171 </section>172 );173}174/** The large title of a screen, the same everywhere. */175export const pageTitle = 'text-[30px] leading-[1.1] font-bold tracking-[-0.03em] text-ink sm:text-[32px]';176/** Page frame inside the shell: a title, an optional line of text, and the content at a comfortable width. */177export function Page({ title, text, children, wide }: { title: string; text?: string; children: ReactNode; wide?: boolean }) {178 return (179 <div className={cx('pb-dock mx-auto flex w-full flex-col gap-5 px-4 pt-3 sm:px-6 sm:pt-7', wide ? 'max-w-6xl' : 'max-w-3xl')}>180 <div className="px-1"><h1 className={pageTitle}>{title}</h1>{text ? <p className="mt-1.5 text-[15px] text-ink-2">{text}</p> : null}</div>181 {children}182 </div>183 );184}185186/** A sensitive call: re-authenticate when the server asks; a dismissed passkey sheet becomes a plain readable error. */187export async function sensitive<T>(fn: () => Promise<T>, cancelledText: string): Promise<T> {188 try { return await withFreshAuth(fn); } catch (e) {189 if (e instanceof PasskeyCancelled) throw new Error(cancelledText);190 throw e;191 }192}193