diff --git a/client/api.ts b/client/api.ts index 855b90b..aabd5a4 100644 --- a/client/api.ts +++ b/client/api.ts @@ -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'), diff --git a/client/pages/ProfilePage.tsx b/client/pages/ProfilePage.tsx index 10a318d..eb29756 100644 --- a/client/pages/ProfilePage.tsx +++ b/client/pages/ProfilePage.tsx @@ -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; @@ -152,15 +156,12 @@ function LoginHistoryModal({ history: providedHistory, open, onClose, onLoaded } }) { const [history, setHistory] = useState([]); 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 ( { if (!v) onClose(); }}> @@ -253,12 +280,45 @@ function LoginHistoryModal({ history: providedHistory, open, onClose, onLoaded } })} +
+ +
+

Showing up to 10 most recent events including failed attempts. Device ID is a short privacy-preserving identifier.

This information is shown only to you and is encrypted at rest. It is not shared with admins.

+ + + + + Sign out of other devices? + + This ends every other active session — anyone signed in on another browser or device + will be logged out. This device stays signed in. + + + + Cancel + { e.preventDefault(); handleSignOutOthers(); }} + disabled={signingOut} + > + {signingOut ? 'Signing out…' : 'Sign out others'} + + + +
); diff --git a/routes/auth.js b/routes/auth.js index 8838ea1..728982a 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -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();