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'),
|
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'),
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,13 @@ router.post('/logout-all', requireAuth, (req, res) => {
|
||||||
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();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue