1// The header's account control (account.mjs): "sign in" when signed out, 2// the listener's name when signed in. Its panel signs in with a passkey, 3// makes an account (a display name and a passkey on this device), and, 4// signed in, shows today's Jev budget, adds a passkey on another device, 5// and signs out; it also lists the AI apps connected through the hosted MCP 6// (/ai/), each one disconnectable. Nothing else on the page needs an account. 7import { useEffect, useState, useSyncExternalStore } from 'react'; 8import { account, addPasskey, createAccount, disconnectApp, listApps, passkeysSupported, signIn, signOut } from './account.mjs'; 9import { themeColor as ACCENT } from './site.mjs'; 10 11const useAccount = () => useSyncExternalStore(account.subscribe, account.get, account.get); 12 13const resetTime = (ms) => 14 new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZoneName: 'short' }); 15 16const SCOPE_WORDS = { play: 'plays in your tabs', publish: 'publishes as you' }; 17 18// The apps this listener connected (worker/src/oauth.ts), each with a 19// "disconnect": its tokens stop working at once. 20function ConnectedApps({ button }) { 21 const [apps, setApps] = useState(null); 22 const [error, setError] = useState(null); 23 const load = () => 24 listApps() 25 .then(setApps) 26 .catch((e) => setError(e.message)); 27 useEffect(() => { 28 load(); 29 }, []); 30 const disconnect = async (id) => { 31 setError(null); 32 try { 33 await disconnectApp(id); 34 await load(); 35 } catch (e) { 36 setError(e.message); 37 } 38 }; 39 return ( 40 <span className="flex flex-col gap-1 text-xs" data-testid="jev-apps"> 41 <span className="opacity-80"> 42 Connected apps (<a className="underline" href="/ai/">make songs with your AI</a>): 43 </span> 44 {apps === null && !error && <span className="opacity-60">…</span>} 45 {apps?.length === 0 && <span className="opacity-60">none yet</span>} 46 {apps?.map((a) => ( 47 <span key={a.id} className="flex items-center justify-between gap-2"> 48 <span title={a.published ? `published by ${a.published}` : `sends access to ${a.redirectHost ?? 'an app'}`}> 49 🤖 <b>{a.app}</b> · {a.scope.map((s) => SCOPE_WORDS[s] ?? s).join(', ')} 50 </span> 51 <button className={button} onClick={() => disconnect(a.id)}> 52 disconnect 53 </button> 54 </span> 55 ))} 56 {error && <span className="text-red-400">{error}</span>} 57 </span> 58 ); 59} 60 61export function AccountControl() { 62 const { ready, user, budget } = useAccount(); 63 const [open, setOpen] = useState(false); 64 const [name, setName] = useState(''); 65 const [busy, setBusy] = useState(null); // what is in progress, in words 66 const [error, setError] = useState(null); 67 const [note, setNote] = useState(null); 68 69 if (!ready) return null; 70 71 const run = (what, action, done) => async (e) => { 72 e?.preventDefault(); 73 if (busy) return; 74 setBusy(what); 75 setError(null); 76 setNote(null); 77 try { 78 await action(); 79 if (done) setNote(done); 80 else setOpen(false); 81 } catch (err) { 82 setError(err.message); 83 } finally { 84 setBusy(null); 85 } 86 }; 87 88 const supported = passkeysSupported(); 89 const button = 'border rounded px-2 py-1 hover:opacity-70 disabled:opacity-50'; 90 91 return ( 92 <span className="relative" data-testid="jev-account"> 93 <button 94 className="text-sm font-mono whitespace-nowrap hover:opacity-70" 95 style={{ color: ACCENT }} 96 onClick={() => setOpen(!open)} 97 title={user ? 'your account' : 'sign in with a passkey'} 98 > 99 {user ? `👤 ${user.displayName}` : '🔑 sign in'} 100 </button> 101 {open && ( 102 <span 103 className="absolute left-0 top-full mt-2 z-50 w-80 bg-background text-foreground border rounded px-3 py-2 shadow-lg text-sm font-mono whitespace-normal flex flex-col gap-2" 104 style={{ borderColor: error ? '#f87171' : ACCENT }} 105 > 106 {!supported ? ( 107 <span>This browser cannot use passkeys, so it cannot sign in. Everything else works without an account.</span> 108 ) : user ? ( 109 <> 110 <span> 111 Signed in as <b>{user.displayName}</b>. 112 </span> 113 {budget && ( 114 <span className="text-xs opacity-80" data-testid="jev-budget"> 115 Jev calls today: {budget.used} of {budget.limit} 116 {budget.resetsAt ? ` · resets ${resetTime(budget.resetsAt)}` : ''} 117 </span> 118 )} 119 <span className="flex flex-wrap gap-2"> 120 <button 121 className={button} 122 style={{ borderColor: ACCENT }} 123 disabled={!!busy} 124 onClick={run('adding…', addPasskey, 'Passkey added.')} 125 > 126 {busy === 'adding…' ? busy : 'add a passkey'} 127 </button> 128 <button className={button} disabled={!!busy} onClick={run('signing out…', signOut)}> 129 {busy === 'signing out…' ? busy : 'sign out'} 130 </button> 131 </span> 132 <ConnectedApps button={button} /> 133 </> 134 ) : ( 135 <> 136 <button 137 className={button} 138 style={{ borderColor: ACCENT }} 139 disabled={!!busy} 140 onClick={run('signing in…', signIn)} 141 > 142 {busy === 'signing in…' ? busy : 'sign in with a passkey'} 143 </button> 144 <span className="text-xs opacity-80"> 145 New here? Pick a name; your device keeps a passkey for this site. No password, no email. 146 </span> 147 <form className="flex gap-2" onSubmit={run('creating…', () => createAccount(name))}> 148 <input 149 className="grow min-w-0 bg-background border border-muted rounded px-2 py-1" 150 maxLength={40} 151 placeholder="display name" 152 value={name} 153 onChange={(e) => setName(e.target.value)} 154 autoComplete="nickname" 155 /> 156 <button 157 type="submit" 158 className={button} 159 style={{ borderColor: ACCENT }} 160 disabled={!!busy || !name.trim()} 161 > 162 {busy === 'creating…' ? busy : 'create'} 163 </button> 164 </form> 165 <span className="text-xs opacity-80"> 166 Signed in, Jev’s calls count against your own daily budget instead of a shared one. 167 </span> 168 <span className="text-xs opacity-80" data-testid="jev-public-note"> 169 Everything jevstrudel stores is public, on the jev panel’s data tab: your name, when you joined, your radio 170 history, your Jev budget use, and everything you write, including anything Jev holds back. Your passkey and 171 session never are. 172 </span> 173 </> 174 )} 175 {note && <span className="text-xs">{note}</span>} 176 {error && <span className="text-xs text-red-400">{error}</span>} 177 </span> 178 )} 179 </span> 180 ); 181}