jevstrudel.git / website / src / jev / AccountControl.jsx
AccountControl.jsx181 lines · 7.1 KB · raw
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}