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'),
login: (data: Body) => post<{ requires_totp?: boolean; challenge_token?: string; user?: User }>('/auth/login', data),
logout: () => post('/auth/logout'),
logoutOthers: () => post<{ success?: boolean; count?: number }>('/auth/logout-others'),
restoreMultiUserMode: () => post('/auth/restore-multi-user-mode'),
changePassword: (data: Body) => post('/auth/change-password', data),
acknowledgePrivacy: () => post('/auth/acknowledge-privacy'),

View File

@ -2,7 +2,7 @@ import { useEffect, useState, useCallback, type ComponentType, type ReactNode }
import { toast } from 'sonner';
import {
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';
import { api } from '@/api';
import { errMessage } from '@/lib/utils';
@ -15,6 +15,10 @@ import { Switch } from '@/components/ui/switch';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
} from '@/components/ui/dialog';
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from '@/components/ui/alert-dialog';
type Settings = Record<string, unknown>;
@ -152,15 +156,12 @@ function LoginHistoryModal({ history: providedHistory, open, onClose, onLoaded }
}) {
const [history, setHistory] = useState<LoginEntry[]>([]);
const [loading, setLoading] = useState(false);
const [confirmSignOut, setConfirmSignOut] = useState(false);
const [signingOut, setSigningOut] = useState(false);
useEffect(() => {
if (!open) return;
if (providedHistory?.length) {
setHistory(providedHistory);
return;
}
const fetchHistory = useCallback(() => {
setLoading(true);
api.loginHistory()
return api.loginHistory()
.then(raw => {
const rows = (raw as { history?: LoginEntry[] }).history ?? [];
setHistory(rows);
@ -171,7 +172,33 @@ function LoginHistoryModal({ history: providedHistory, open, onClose, onLoaded }
toast.error(errMessage(err, 'Failed to load login history.'));
})
.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 (
<Dialog open={open} onOpenChange={v => { if (!v) onClose(); }}>
@ -253,12 +280,45 @@ function LoginHistoryModal({ history: providedHistory, open, onClose, onLoaded }
})}
</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">
Showing up to 10 most recent events including failed attempts. Device ID is a short privacy-preserving identifier.
</p>
<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.
</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>
</Dialog>
);

View File

@ -89,16 +89,23 @@ router.post('/logout', requireAuth, (req, res) => {
router.post('/logout-all', requireAuth, (req, res) => {
// Delete ALL sessions for this user
invalidateOtherSessions(req.user.id, null); // null means delete all sessions
// Also clear the current session
logout(req.cookies?.[COOKIE_NAME]);
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.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
router.get('/me', requireAuth, (req, res) => {
const currentVersion = getAppVersion();