feat(settings): sign out of other devices (plan Tier 7)

- New POST /auth/logout-others keeps the current session and invalidates all
  others (invalidateOtherSessions with the current session id), writes a
  logout.others audit entry, and returns the count ended.
- api.logoutOthers(); a "Sign out of other devices" button in the Login History
  dialog, guarded by an AlertDialog confirmation. On success: toast with the
  count and refetch the history so it collapses to just this device.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-05 16:05:59 -05:00
parent 7e5e012434
commit 0e6f04c7bc
3 changed files with 80 additions and 12 deletions

View File

@ -136,6 +136,7 @@ export const api = {
authMode: () => get('/auth/mode'), authMode: () => get('/auth/mode'),
login: (data: Body) => post<{ requires_totp?: boolean; challenge_token?: string; user?: User }>('/auth/login', data), login: (data: Body) => post<{ requires_totp?: boolean; challenge_token?: string; user?: User }>('/auth/login', data),
logout: () => post('/auth/logout'), logout: () => post('/auth/logout'),
logoutOthers: () => post<{ success?: boolean; count?: number }>('/auth/logout-others'),
restoreMultiUserMode: () => post('/auth/restore-multi-user-mode'), restoreMultiUserMode: () => post('/auth/restore-multi-user-mode'),
changePassword: (data: Body) => post('/auth/change-password', data), changePassword: (data: Body) => post('/auth/change-password', data),
acknowledgePrivacy: () => post('/auth/acknowledge-privacy'), acknowledgePrivacy: () => post('/auth/acknowledge-privacy'),

View File

@ -2,7 +2,7 @@ import { useEffect, useState, useCallback, type ComponentType, type ReactNode }
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
User, Mail, KeyRound, ShieldCheck, Loader2, History, Monitor, Smartphone, ChevronRight, User, Mail, KeyRound, ShieldCheck, Loader2, History, Monitor, Smartphone, ChevronRight,
Bell, SendHorizontal, ScanLine, TriangleAlert, Copy, Check, Lock, Bell, SendHorizontal, ScanLine, TriangleAlert, Copy, Check, Lock, LogOut,
} from 'lucide-react'; } from 'lucide-react';
import { api } from '@/api'; import { api } from '@/api';
import { errMessage } from '@/lib/utils'; import { errMessage } from '@/lib/utils';
@ -15,6 +15,10 @@ import { Switch } from '@/components/ui/switch';
import { import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from '@/components/ui/alert-dialog';
type Settings = Record<string, unknown>; type Settings = Record<string, unknown>;
@ -152,15 +156,12 @@ function LoginHistoryModal({ history: providedHistory, open, onClose, onLoaded }
}) { }) {
const [history, setHistory] = useState<LoginEntry[]>([]); const [history, setHistory] = useState<LoginEntry[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [confirmSignOut, setConfirmSignOut] = useState(false);
const [signingOut, setSigningOut] = useState(false);
useEffect(() => { const fetchHistory = useCallback(() => {
if (!open) return;
if (providedHistory?.length) {
setHistory(providedHistory);
return;
}
setLoading(true); setLoading(true);
api.loginHistory() return api.loginHistory()
.then(raw => { .then(raw => {
const rows = (raw as { history?: LoginEntry[] }).history ?? []; const rows = (raw as { history?: LoginEntry[] }).history ?? [];
setHistory(rows); setHistory(rows);
@ -171,7 +172,33 @@ function LoginHistoryModal({ history: providedHistory, open, onClose, onLoaded }
toast.error(errMessage(err, 'Failed to load login history.')); toast.error(errMessage(err, 'Failed to load login history.'));
}) })
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [open, providedHistory, onLoaded]); }, [onLoaded]);
useEffect(() => {
if (!open) return;
if (providedHistory?.length) {
setHistory(providedHistory);
return;
}
fetchHistory();
}, [open, providedHistory, fetchHistory]);
async function handleSignOutOthers() {
setSigningOut(true);
try {
const res = await api.logoutOthers();
const n = res?.count ?? 0;
toast.success(n > 0
? `Signed out of ${n} other device${n === 1 ? '' : 's'}.`
: 'No other devices were signed in.');
setConfirmSignOut(false);
await fetchHistory(); // collapse the list to just this device
} catch (err) {
toast.error(errMessage(err, 'Failed to sign out other devices.'));
} finally {
setSigningOut(false);
}
}
return ( return (
<Dialog open={open} onOpenChange={v => { if (!v) onClose(); }}> <Dialog open={open} onOpenChange={v => { if (!v) onClose(); }}>
@ -253,12 +280,45 @@ function LoginHistoryModal({ history: providedHistory, open, onClose, onLoaded }
})} })}
</div> </div>
<div className="pt-2">
<Button
variant="outline"
size="sm"
className="w-full gap-1.5 text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => setConfirmSignOut(true)}
>
<LogOut className="h-3.5 w-3.5" />
Sign out of other devices
</Button>
</div>
<p className="text-[10px] text-muted-foreground/60 text-center pt-1"> <p className="text-[10px] text-muted-foreground/60 text-center pt-1">
Showing up to 10 most recent events including failed attempts. Device ID is a short privacy-preserving identifier. Showing up to 10 most recent events including failed attempts. Device ID is a short privacy-preserving identifier.
</p> </p>
<p className="text-[10px] text-muted-foreground/60 text-center"> <p className="text-[10px] text-muted-foreground/60 text-center">
This information is shown only to you and is encrypted at rest. It is not shared with admins. This information is shown only to you and is encrypted at rest. It is not shared with admins.
</p> </p>
<AlertDialog open={confirmSignOut} onOpenChange={setConfirmSignOut}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Sign out of other devices?</AlertDialogTitle>
<AlertDialogDescription>
This ends every other active session anyone signed in on another browser or device
will be logged out. This device stays signed in.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={signingOut}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => { e.preventDefault(); handleSignOutOthers(); }}
disabled={signingOut}
>
{signingOut ? 'Signing out…' : 'Sign out others'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@ -89,16 +89,23 @@ router.post('/logout', requireAuth, (req, res) => {
router.post('/logout-all', requireAuth, (req, res) => { router.post('/logout-all', requireAuth, (req, res) => {
// Delete ALL sessions for this user // Delete ALL sessions for this user
invalidateOtherSessions(req.user.id, null); // null means delete all sessions invalidateOtherSessions(req.user.id, null); // null means delete all sessions
// Also clear the current session // Also clear the current session
logout(req.cookies?.[COOKIE_NAME]); logout(req.cookies?.[COOKIE_NAME]);
logAudit({ user_id: req.user.id, action: 'logout.all', ip_address: req.ip, user_agent: req.get('user-agent') }); logAudit({ user_id: req.user.id, action: 'logout.all', ip_address: req.ip, user_agent: req.get('user-agent') });
res.clearCookie(COOKIE_NAME, { path: '/', ...cookieOpts(req), maxAge: undefined }); res.clearCookie(COOKIE_NAME, { path: '/', ...cookieOpts(req), maxAge: undefined });
res.json({ success: true }); res.json({ success: true });
}); });
// POST /api/auth/logout-others — sign out every OTHER device, keep this one signed in.
router.post('/logout-others', requireAuth, (req, res) => {
const result = invalidateOtherSessions(req.user.id, req.cookies?.[COOKIE_NAME]);
logAudit({ user_id: req.user.id, action: 'logout.others', ip_address: req.ip, user_agent: req.get('user-agent') });
res.json({ success: true, count: result?.changes || 0 });
});
// GET /api/auth/me // GET /api/auth/me
router.get('/me', requireAuth, (req, res) => { router.get('/me', requireAuth, (req, res) => {
const currentVersion = getAppVersion(); const currentVersion = getAppVersion();