summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/app.js
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js139
1 files changed, 138 insertions, 1 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 24ef43f..34f146a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -651,6 +651,52 @@ function Sidebar({ groups, presence, route, menuOpen, role }) {
// ── Login Page ───────────────────────────────────────────────────────────────
+/**
+ * Which hub, asked once on a desktop build.
+ *
+ * There is no default. A client that picks its own hub is a client that can be
+ * pointed at one, and the address is the whole of what the application trusts
+ * the hub for — its API, and nothing else: the interface comes from the package.
+ *
+ * Changing it restarts the window, because the address reaches the interface as
+ * a process argument. Reloading in place would leave it talking to the old hub
+ * with nothing on screen to say so.
+ */
+function FirstRunPage({ onSet }) {
+ const [url, setUrl] = useState('');
+ const [error, setError] = useState('');
+ const [busy, setBusy] = useState(false);
+
+ const submit = async (e) => {
+ e.preventDefault();
+ setError('');
+ setBusy(true);
+ try {
+ await window.meshbay.setHubBase(url.trim());
+ onSet();
+ } catch (err) {
+ setError(err.message || String(err));
+ setBusy(false);
+ }
+ };
+
+ return html`
+ <div class="auth-page">
+ <h2>${t('firstrun.title')}</h2>
+ <p class="settings-hint">${t('firstrun.hint')}</p>
+ <form onSubmit=${submit}>
+ <input type="url" placeholder="https://meshbay.org" required
+ value=${url} onInput=${e => setUrl(e.target.value)} />
+ <button type="submit" disabled=${busy}>
+ ${busy ? '…' : t('firstrun.btn')}
+ </button>
+ </form>
+ ${error && html`<p class="error-msg">${error}</p>`}
+ <p class="settings-hint">${t('firstrun.note')}</p>
+ </div>
+ `;
+}
+
function LoginPage() {
const auth = useAuth();
const [username, setUsername] = useState('');
@@ -4069,6 +4115,9 @@ function SettingsPage({ user, theme, onThemeChange, groups }) {
${keyBackend === 'unprotected_fallback' && html`
<p class="error-msg">${t('settings.keys_unprotected')}</p>
`}
+ ${keyBackend === 'unavailable' && html`
+ <p class="error-msg">${t('settings.keys_unavailable')}</p>
+ `}
</div>
`}
@@ -4485,6 +4534,13 @@ function App() {
const route = useRoute();
const [theme, setTheme] = useState(getInitialTheme);
const [user, setUser] = useState(loadAuth);
+ // A desktop build with a remembered device signs in without asking. Null
+ // until it has tried, so nothing renders a sign-in form the user is about to
+ // be taken past.
+ const [deviceTried, setDeviceTried] = useState(!platform.device.available);
+ // Native, and nowhere to talk to yet.
+ const [needsHub, setNeedsHub] = useState(
+ platform.isNative && !platform.hubBase());
const [groups, setGroups] = useState([]);
const [menuOpen, setMenuOpen] = useState(false);
const [notifications, setNotifications] = useState([]);
@@ -4597,12 +4653,79 @@ function App() {
});
}, [user]);
+ // Sign in with this device's key, once, at startup.
+ //
+ // The passphrase stays the account's credential and its recovery path; this
+ // is what saves entering it every launch. A refusal is not an error worth
+ // showing — the key may have been retired from another device, or the hub may
+ // have forgotten it — so it falls through to the ordinary form.
+ useEffect(() => {
+ // Nothing to do when a session was restored from storage, or when this is
+ // a browser. `user` is read once here on purpose: this runs at startup and
+ // must not re-fire when the session it just created lands.
+ if (deviceTried || user) { setDeviceTried(true); return; }
+ let cancelled = false;
+ (async () => {
+ try {
+ // `loadAuth` keeps the username even when the tokens in it are stale,
+ // and `app://meshbay` is a stable origin, so localStorage survives a
+ // relaunch. A fresh install has nothing here and asks for a passphrase,
+ // which is right: the first sign-in is what registers the device.
+ const saved = loadAuth();
+ const username = saved && saved.username;
+ if (!username) return;
+ const signed = await platform.device.sign(username);
+ if (!signed) return;
+ const data = await hubFetch('/v1/users/auth', {
+ method: 'POST',
+ body: { username, timestamp: signed.timestamp,
+ signature: signed.signature },
+ });
+ const me = await hubFetch('/v1/users/me', { token: data.access_token });
+ if (cancelled) return;
+ const u = { username, userId: me.user_id, token: data.access_token,
+ refreshToken: data.refresh_token, role: me.role };
+ setAuth(u);
+ setUser(u);
+ } catch {
+ // Falls through to the sign-in form, which is the honest outcome.
+ } finally {
+ if (!cancelled) setDeviceTried(true);
+ }
+ })();
+ return () => { cancelled = true; };
+ }, []);
+
useEffect(() => { setMenuOpen(false); }, [route]);
const changeTheme = useCallback((val) => {
setTheme(val);
}, []);
+ /**
+ * Register this device's hub key, once, after a passphrase sign-in.
+ *
+ * Deliberately not fatal: a hub that refuses it, or a machine with no key
+ * storage, means the passphrase is asked for again next time — which is
+ * exactly what a browser does, and is a worse experience rather than a
+ * broken one.
+ */
+ const registerThisDevice = useCallback(async (token) => {
+ if (!platform.device.available) return;
+ try {
+ const backend = await platform.secrets.backend();
+ if (backend === 'unavailable') return;
+ const pk = await platform.device.ensure();
+ if (!pk) return;
+ await hubFetch('/v1/users/devices', {
+ method: 'POST', token,
+ body: { pk_auth_ed25519: pk, label: t('device.this_device') },
+ });
+ } catch (err) {
+ console.warn('device not registered:', err.message);
+ }
+ }, []);
+
const authCtx = {
user,
login: async (username, password) => {
@@ -4625,6 +4748,10 @@ function App() {
}
const me = await hubFetch('/v1/users/me', { token });
const u = { username, userId: me.user_id, token, refreshToken, role: me.role };
+ // On a desktop build, remember this device so the next launch does not ask
+ // for the passphrase again. The key is generated and held by the main
+ // process; what travels here is only its public half.
+ await registerThisDevice(token);
// setAuth, not saveAuth: it is the one writer that also updates the copy
// hubFetch renews from. Storing the session without it left the renewal
// path with no refresh token to present.
@@ -4654,7 +4781,17 @@ function App() {
const refreshAuth = useCallback(() => refreshAccessToken(), []);
let page;
- if (route === '/login' || route === '/register') {
+ // A desktop build with no hub configured cannot do anything at all, so it
+ // asks before showing a sign-in form that could not work. Deliberately not
+ // defaulted to meshbay.org: a client that picks its own hub is a client that
+ // can be pointed at one.
+ if (needsHub) {
+ page = html`<${FirstRunPage} onSet=${() => setNeedsHub(false)} />`;
+ } else if (!deviceTried) {
+ // Signing in with this device's key. Showing a form here would be showing
+ // one the user is about to be taken past.
+ page = html`<p class="page-message">${t('status.connecting')}</p>`;
+ } else if (route === '/login' || route === '/register') {
page = route === '/register'
? html`<${RegisterPage} />`
: html`<${LoginPage} />`;