Files
study-bible-project/src/components/settings/DevicesSection.jsx
T
nmemmertandClaude Sonnet 5.5 bdb04cdedc
Build and Push Docker Image / build-and-push (push) Successful in 10m22s
Add cross-reference map, admin health page, passkeys and device list, settings tabs
- Reader: chapter cross-reference map (M) from IQ Bible's reference graph
- Admin: Health tab with disk use, snapshots (back up, download, restore), failure logs
- Passkey sign-in (WebAuthn), signed-in device list, sign out others, optional sign-in alerts
- Settings split into Account, Preferences, Notifications, Security, Your data tabs
- ntfy server field: catch a topic typed into the server box

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 12:52:49 -04:00

78 lines
4.1 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Settings → Security: where you're signed in, with a way to sign the others out, and an optional alert for new sign-ins.
import { useEffect, useState } from 'react';
import { useApp } from '../../context/AppContext.js';
import { listDevices, setLoginAlerts, signOutDevice, signOutOtherDevices } from '../../syncService.js';
const btn = 'rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50';
const METHODS = { password: 'password', passkey: 'passkey', mfa: 'password and code', register: 'new account' };
export function whenText(at, now = Date.now()) {
const m = Math.round((now - at) / 60000);
if (m < 2) return 'just now';
if (m < 120) return `${m} minutes ago`;
if (m < 2880) return `${Math.round(m / 60)} hours ago`;
return `${Math.round(m / 1440)} days ago`;
}
export default function DevicesSection() {
const { authUser, setAuthUser } = useApp();
const [devices, setDevices] = useState(null);
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const load = async () => { const r = await listDevices(); if (r.ok) setDevices(r.data); else setError(r.error ?? 'Could not load your devices.'); };
useEffect(() => { load(); }, []);
const signOut = async (d) => {
if (!window.confirm(`Sign out ${d.label}?`)) return;
const r = await signOutDevice(d.id);
if (!r.ok && r.status !== 404) setError(r.error ?? 'Could not sign it out.');
load();
};
const signOutOthers = async () => {
if (!window.confirm('Sign out every other device? They’ll need to sign in again.')) return;
setBusy(true);
const r = await signOutOtherDevices();
setBusy(false);
if (!r.ok) setError(r.error ?? 'Could not sign them out.');
load();
};
const toggleAlerts = async (e) => {
setError('');
const r = await setLoginAlerts(e.target.checked);
if (r.ok) setAuthUser({ ...authUser, loginAlerts: r.data.loginAlerts }); else setError(r.error ?? 'Could not change that.');
};
const others = (devices ?? []).filter((d) => !d.current);
return (
<section aria-label="Devices" className="space-y-3 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6">
<div>
<h2 className="text-base font-medium text-[var(--r-text)]">Where you’re signed in</h2>
<p className="mt-1 text-sm text-[var(--r-sub)]">If you don’t recognise one, sign it out and change your password.</p>
</div>
{devices === null && !error && <p className="text-sm text-[var(--r-sub)]">Loading…</p>}
{devices && (
<ul className="divide-y divide-[var(--r-line)] text-sm">
{devices.map((d) => (
<li key={d.id} className="flex flex-wrap items-center justify-between gap-2 py-2">
<span className="min-w-0">
<span className="font-medium">{d.label}</span>{d.current && <span className="ml-2 rounded-full bg-black/10 px-2 py-0.5 text-[11px]">This device</span>}
<span className="block text-xs text-[var(--r-sub)]">
{d.current ? 'Active now' : `Last active ${whenText(d.lastSeen)}`}{d.method ? ` · signed in with ${METHODS[d.method] ?? d.method}` : ''}{d.ip ? ` · ${d.ip}` : ''}
</span>
</span>
{!d.current && <button type="button" onClick={() => signOut(d)} className={btn}>Sign out</button>}
</li>
))}
</ul>
)}
{others.length > 0 && <button type="button" onClick={signOutOthers} disabled={busy} className={btn}>{busy ? 'Signing out…' : `Sign out the other ${others.length === 1 ? 'device' : `${others.length} devices`}`}</button>}
{error && <p role="alert" className="text-sm text-[var(--r-danger,#b42318)]">{error}</p>}
<label className="flex items-start gap-2 border-t border-[var(--r-line)] pt-3 text-sm">
<input type="checkbox" checked={!!authUser?.loginAlerts} onChange={toggleAlerts} className="mt-1" />
<span>Tell me when someone signs in<span className="block text-xs text-[var(--r-sub)]">Sends a message to your notification topic (set up under Notifications) each time a device signs in.</span></span>
</label>
</section>
);
}