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:
parent
7e5e012434
commit
0e6f04c7bc
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue