Build and Push Docker Image / build-and-push (push) Successful in 10m22s
- 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>
78 lines
4.1 KiB
React
78 lines
4.1 KiB
React
// 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>
|
||
);
|
||
}
|