feat(auth): add active sessions management
Some checks failed
CI / test (push) Failing after 33s

- store refresh session metadata in redis (ip/user-agent/created_at)

- add auth APIs: list sessions, revoke one, revoke all

- add web privacy UI for active sessions
This commit is contained in:
2026-03-08 11:41:03 +03:00
parent da73b79ee7
commit e685a38be6
7 changed files with 309 additions and 11 deletions

View File

@@ -1,7 +1,8 @@
import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { listBlockedUsers, updateMyProfile } from "../api/users";
import type { AuthUser } from "../chat/types";
import { listSessions, revokeAllSessions, revokeSession } from "../api/auth";
import type { AuthSession, AuthUser } from "../chat/types";
import { useAuthStore } from "../store/authStore";
import { AppPreferences, getAppPreferences, updateAppPreferences } from "../utils/preferences";
@@ -19,6 +20,8 @@ export function SettingsPanel({ open, onClose }: Props) {
const [blockedCount, setBlockedCount] = useState(0);
const [savingPrivacy, setSavingPrivacy] = useState(false);
const [allowPrivateMessages, setAllowPrivateMessages] = useState(true);
const [sessions, setSessions] = useState<AuthSession[]>([]);
const [sessionsLoading, setSessionsLoading] = useState(false);
const [profileDraft, setProfileDraft] = useState({
name: "",
username: "",
@@ -68,6 +71,33 @@ export function SettingsPanel({ open, onClose }: Props) {
};
}, [open, page]);
useEffect(() => {
if (!open || page !== "privacy") {
return;
}
let cancelled = false;
setSessionsLoading(true);
void (async () => {
try {
const rows = await listSessions();
if (!cancelled) {
setSessions(rows);
}
} catch {
if (!cancelled) {
setSessions([]);
}
} finally {
if (!cancelled) {
setSessionsLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [open, page]);
const title = useMemo(() => {
if (page === "general") return "General";
if (page === "notifications") return "Notifications";
@@ -255,6 +285,42 @@ export function SettingsPanel({ open, onClose }: Props) {
disabled={savingPrivacy}
/>
</section>
<section className="rounded border border-slate-700/70 bg-slate-800/50 p-3">
<div className="mb-2 flex items-center justify-between">
<p className="text-xs uppercase tracking-wide text-slate-400">Active Sessions</p>
<button
className="rounded bg-slate-700 px-2 py-1 text-xs"
onClick={async () => {
await revokeAllSessions();
setSessions([]);
}}
type="button"
>
Revoke all
</button>
</div>
{sessionsLoading ? <p className="text-xs text-slate-400">Loading sessions...</p> : null}
{!sessionsLoading && sessions.length === 0 ? <p className="text-xs text-slate-400">No active sessions</p> : null}
<div className="space-y-2">
{sessions.map((session) => (
<div className="rounded bg-slate-900/50 px-2 py-2" key={session.jti}>
<p className="truncate text-xs text-slate-300">{session.user_agent || "Unknown device"}</p>
<p className="truncate text-[11px] text-slate-400">{session.ip_address || "Unknown IP"}</p>
<p className="text-[11px] text-slate-500">{new Date(session.created_at).toLocaleString()}</p>
<button
className="mt-1 rounded bg-slate-700 px-2 py-1 text-[11px]"
onClick={async () => {
await revokeSession(session.jti);
setSessions((prev) => prev.filter((item) => item.jti !== session.jti));
}}
type="button"
>
Revoke
</button>
</div>
))}
</div>
</section>
</div>
) : null}
</div>
@@ -312,4 +378,3 @@ function CheckboxOption({
</label>
);
}