Skip to content

Commit 87030d3

Browse files
author
tevfik
committed
feat(web): server-side logout, sessions card, danger zone (delete account)
- AuthContext.logout() now POSTs /auth/logout best-effort before clearing local storage so the server can revoke the jti - Settings → General now renders SessionsCard (list + revoke active sessions) and DangerZoneCard (typed-DELETE confirm → /auth/user) - authService gains deleteAccount(); changePassword fix from prior commit (old_password/new_password) is exercised by the new UI
1 parent 400dcba commit 87030d3

3 files changed

Lines changed: 183 additions & 38 deletions

File tree

web/src/features/auth/services/authService.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,4 +101,10 @@ export const authService = {
101101
deletePushToken: async (id: string): Promise<void> => {
102102
await api.delete(`/auth/push-token/${id}`);
103103
},
104+
105+
/** Permanently delete the current user's account. Server revokes all
106+
* sessions before removing the user record. */
107+
deleteAccount: async (): Promise<void> => {
108+
await api.delete('/auth/user');
109+
},
104110
};

web/src/features/settings/pages/Settings.tsx

Lines changed: 164 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -498,44 +498,49 @@ export default function Settings() {
498498
)}
499499

500500
{activeTab === 'general' && (
501-
<Card>
502-
<CardHeader>
503-
<CardTitle>General Settings</CardTitle>
504-
<CardDescription>Application preferences and security.</CardDescription>
505-
</CardHeader>
506-
<CardContent className="space-y-6">
507-
<div className="space-y-4">
508-
<h3 className="text-lg font-medium">Change Password</h3>
509-
<form onSubmit={handleChangePassword} className="space-y-4 max-w-sm">
510-
<div className="space-y-2">
511-
<label htmlFor="old-password">Current Password</label>
512-
<Input
513-
id="old-password"
514-
type="password"
515-
value={oldPassword}
516-
onChange={(e) => setOldPassword(e.target.value)}
517-
placeholder="Your current password"
518-
autoComplete="current-password"
519-
/>
520-
</div>
521-
<div className="space-y-2">
522-
<label htmlFor="new-password">New Password</label>
523-
<Input
524-
id="new-password"
525-
type="password"
526-
value={newPassword}
527-
onChange={(e) => setNewPassword(e.target.value)}
528-
placeholder="Min. 8 characters"
529-
autoComplete="new-password"
530-
/>
531-
</div>
532-
<Button type="submit" disabled={!oldPassword || !newPassword || passwordMutation.isPending}>
533-
{passwordMutation.isPending ? 'Updating...' : 'Update Password'}
534-
</Button>
535-
</form>
536-
</div>
537-
</CardContent>
538-
</Card>
501+
<div className="space-y-6">
502+
<Card>
503+
<CardHeader>
504+
<CardTitle>General Settings</CardTitle>
505+
<CardDescription>Application preferences and security.</CardDescription>
506+
</CardHeader>
507+
<CardContent className="space-y-6">
508+
<div className="space-y-4">
509+
<h3 className="text-lg font-medium">Change Password</h3>
510+
<form onSubmit={handleChangePassword} className="space-y-4 max-w-sm">
511+
<div className="space-y-2">
512+
<label htmlFor="old-password">Current Password</label>
513+
<Input
514+
id="old-password"
515+
type="password"
516+
value={oldPassword}
517+
onChange={(e) => setOldPassword(e.target.value)}
518+
placeholder="Your current password"
519+
autoComplete="current-password"
520+
/>
521+
</div>
522+
<div className="space-y-2">
523+
<label htmlFor="new-password">New Password</label>
524+
<Input
525+
id="new-password"
526+
type="password"
527+
value={newPassword}
528+
onChange={(e) => setNewPassword(e.target.value)}
529+
placeholder="Min. 8 characters"
530+
autoComplete="new-password"
531+
/>
532+
</div>
533+
<Button type="submit" disabled={!oldPassword || !newPassword || passwordMutation.isPending}>
534+
{passwordMutation.isPending ? 'Updating...' : 'Update Password'}
535+
</Button>
536+
</form>
537+
</div>
538+
</CardContent>
539+
</Card>
540+
541+
<SessionsCard />
542+
<DangerZoneCard />
543+
</div>
539544
)}
540545

541546
{/* Create Key Modal (Custom) */}
@@ -598,3 +603,124 @@ export default function Settings() {
598603
</div>
599604
);
600605
}
606+
607+
// ── Active Sessions ──────────────────────────────────────────────────────────
608+
609+
function SessionsCard() {
610+
const queryClient = useQueryClient();
611+
const { data: sessions = [], isLoading } = useQuery({
612+
queryKey: ['auth-sessions'],
613+
queryFn: authService.getSessions,
614+
});
615+
616+
const revokeMutation = useMutation({
617+
mutationFn: authService.revokeSession,
618+
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['auth-sessions'] }),
619+
onError: (err: any) =>
620+
alert(err?.response?.data?.error || 'Failed to revoke session'),
621+
});
622+
623+
return (
624+
<Card>
625+
<CardHeader>
626+
<CardTitle>Active Sessions</CardTitle>
627+
<CardDescription>
628+
Devices and browsers signed into your account. Revoke any you don't recognize.
629+
</CardDescription>
630+
</CardHeader>
631+
<CardContent>
632+
{isLoading ? (
633+
<p className="text-sm text-muted-foreground">Loading…</p>
634+
) : sessions.length === 0 ? (
635+
<p className="text-sm text-muted-foreground">No active sessions.</p>
636+
) : (
637+
<Table>
638+
<TableHeader>
639+
<TableRow>
640+
<TableHead>Device</TableHead>
641+
<TableHead>IP</TableHead>
642+
<TableHead>Created</TableHead>
643+
<TableHead>Expires</TableHead>
644+
<TableHead className="text-right">Action</TableHead>
645+
</TableRow>
646+
</TableHeader>
647+
<TableBody>
648+
{sessions.map((s) => (
649+
<TableRow key={s.jti}>
650+
<TableCell className="max-w-xs truncate" title={s.user_agent}>
651+
{s.user_agent || '—'}
652+
</TableCell>
653+
<TableCell>{s.ip || '—'}</TableCell>
654+
<TableCell>
655+
{s.created_at ? format(new Date(s.created_at), 'PP p') : '—'}
656+
</TableCell>
657+
<TableCell>
658+
{s.expires_at ? format(new Date(s.expires_at), 'PP p') : '—'}
659+
</TableCell>
660+
<TableCell className="text-right">
661+
<Button
662+
size="sm"
663+
variant="ghost"
664+
onClick={() => {
665+
if (window.confirm('Revoke this session?')) {
666+
revokeMutation.mutate(s.jti);
667+
}
668+
}}
669+
disabled={revokeMutation.isPending}
670+
>
671+
<Trash2 className="h-4 w-4" />
672+
</Button>
673+
</TableCell>
674+
</TableRow>
675+
))}
676+
</TableBody>
677+
</Table>
678+
)}
679+
</CardContent>
680+
</Card>
681+
);
682+
}
683+
684+
// ── Danger Zone (delete account) ─────────────────────────────────────────────
685+
686+
function DangerZoneCard() {
687+
const { logout } = useAuth();
688+
const deleteMutation = useMutation({
689+
mutationFn: authService.deleteAccount,
690+
onSuccess: () => {
691+
alert('Your account has been deleted. Goodbye.');
692+
logout();
693+
},
694+
onError: (err: any) =>
695+
alert(err?.response?.data?.error || 'Failed to delete account'),
696+
});
697+
698+
const handleDelete = () => {
699+
const confirm1 = window.prompt(
700+
'This permanently deletes your account, sessions, and personal data.\n\nType DELETE to confirm:',
701+
);
702+
if (confirm1 !== 'DELETE') return;
703+
if (!window.confirm('Last chance — delete account permanently?')) return;
704+
deleteMutation.mutate();
705+
};
706+
707+
return (
708+
<Card className="border-destructive/50">
709+
<CardHeader>
710+
<CardTitle className="text-destructive">Danger Zone</CardTitle>
711+
<CardDescription>
712+
Permanently delete your account and all associated data. This cannot be undone.
713+
</CardDescription>
714+
</CardHeader>
715+
<CardContent>
716+
<Button
717+
variant="destructive"
718+
onClick={handleDelete}
719+
disabled={deleteMutation.isPending}
720+
>
721+
{deleteMutation.isPending ? 'Deleting…' : 'Delete My Account'}
722+
</Button>
723+
</CardContent>
724+
</Card>
725+
);
726+
}

web/src/shared/context/AuthContext.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,19 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
7474
};
7575

7676
const logout = () => {
77+
// Best-effort: tell the server to revoke the JWT (jti) so a stolen
78+
// token can't be replayed. We don't await — the local cleanup below
79+
// must always run, even on offline / 401 / network failure.
80+
void (async () => {
81+
try {
82+
const { api } = await import('@/services/api');
83+
await api.post('/auth/logout');
84+
} catch {
85+
// Network/server failure is fine; server-side jti revocation
86+
// is a defense-in-depth measure on top of local clearing.
87+
}
88+
})();
89+
7790
['datum_token', 'datum_refresh_token', 'datum_user', 'datum_token_expiry'].forEach(key => {
7891
localStorage.removeItem(key);
7992
sessionStorage.removeItem(key);

0 commit comments

Comments
 (0)