Add session management: see who's signed in, revoke a session

No visibility existed into who was currently signed in or a way to
force a device out. Sessions already live as files via
session-file-store, so this reads that store directly rather than
adding a new DB table: new Settings-adjacent "Sessions" page
(admin-only, alongside Users) lists every live session with the
user's name/email/resolved role, IP, a friendly "Browser on OS"
summary parsed from the user-agent, last-active time, and expiry, with
a Revoke button per row (extra confirmation if you revoke your own
current session, since that signs you out immediately).

IP and user-agent are now captured into the session at login
(auth/router.ts) since express-session doesn't track them itself.
session-file-store's own Store type doesn't declare its list()
method, so sessionStore.ts adds a narrow local interface for it rather
than losing type safety on the rest of the store.

Verified against a real session directory seeded through the actual
session-file-store APIs (not hand-written JSON): confirmed correct
field resolution including a session whose user row was later deleted
(role resolves to null instead of crashing), correctly excluded a
mid-OIDC-login session with no completed user yet, correctly excluded
an already-expired session, and confirmed revoke actually deletes the
right session file and only that one. Confirmed the real dev
database's mtime was untouched throughout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-21 20:29:29 +02:00
1 parent 1cae35a59e
commit 6d673db9ec
9 files changed
+318 -1

No files matched your search

+9
View File
@@ -4,6 +4,7 @@ import { api, type CurrentUser, type UserRole, UnauthorizedError } from "./api/c
import Login from "./pages/Login";
import Dashboard from "./pages/Dashboard";
import Users from "./pages/Users";
import Sessions from "./pages/Sessions";
import AuditLog from "./pages/AuditLog";
import DiagLog from "./pages/DiagLog";
import Secrets from "./pages/Secrets";
@@ -99,6 +100,14 @@ export default function App() {
</RequireRole>
}
/>
<Route
path="/sessions"
element={
<RequireRole user={user} minRole="admin">
<Sessions />
</RequireRole>
}
/>
<Route
path="/audit-log"
element={
+16
View File
@@ -18,6 +18,18 @@ export interface UserRecord {
lastLoginAt: string | null;
}
export interface SessionSummary {
id: string;
sub: string;
email: string | null;
name: string | null;
role: UserRole | null;
ip: string | null;
userAgent: string | null;
lastAccess: string;
expiresAt: string | null;
}
export interface AuditLogEntry {
id: number;
actorUserId: number | null;
@@ -587,6 +599,10 @@ export const api = {
body: JSON.stringify({ role }),
}),
},
sessions: {
list: () => request<{ sessions: SessionSummary[]; currentSessionId: string }>("/api/sessions"),
revoke: (id: string) => request<void>(`/api/sessions/${encodeURIComponent(id)}`, { method: "DELETE" }),
},
auditLog: {
list: (limit = 200) => request<{ entries: AuditLogEntry[] }>(`/api/audit-log?limit=${limit}`),
},
+2
View File
@@ -14,6 +14,7 @@ import {
IconServerCog,
IconDatabase,
IconUsers,
IconDevices,
IconHistory,
IconStethoscope,
IconSettings,
@@ -46,6 +47,7 @@ const NAV_ITEMS: NavItem[] = [
{ to: "/gitea", label: "Gitea", icon: <IconBrandGit size={20} /> },
{ to: "/integrations", label: "Integrations", icon: <IconPlugConnected size={20} /> },
{ to: "/users", label: "Users", icon: <IconUsers size={20} />, minRole: "admin" },
{ to: "/sessions", label: "Sessions", icon: <IconDevices size={20} />, minRole: "admin" },
{ to: "/audit-log", label: "Audit Log", icon: <IconHistory size={20} />, minRole: "operator" },
{ to: "/diag-log", label: "Diagnostic Log", icon: <IconStethoscope size={20} />, minRole: "admin" },
{ to: "/settings", label: "Settings", icon: <IconSettings size={20} />, minRole: "admin" },
+169
View File
@@ -0,0 +1,169 @@
import { useEffect, useState } from "react";
import { api, type SessionSummary } from "../api/client";
import { formatDateTime } from "../utils/date";
import { useSortable } from "../hooks/useSortable";
import SortableTh from "../components/SortableTh";
import { downloadCsv } from "../utils/csv";
function friendlyUserAgent(ua: string | null): string {
if (!ua) return "—";
const os = /Windows/.test(ua)
? "Windows"
: /Mac OS X/.test(ua)
? "macOS"
: /Android/.test(ua)
? "Android"
: /iPhone|iPad/.test(ua)
? "iOS"
: /Linux/.test(ua)
? "Linux"
: "unknown OS";
const browser = /Edg\//.test(ua)
? "Edge"
: /Chrome\//.test(ua)
? "Chrome"
: /Firefox\//.test(ua)
? "Firefox"
: /Safari\//.test(ua)
? "Safari"
: "unknown browser";
return `${browser} on ${os}`;
}
export default function Sessions() {
const [sessions, setSessions] = useState<SessionSummary[] | null>(null);
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [revokingId, setRevokingId] = useState<string | null>(null);
function load() {
api.sessions
.list()
.then((res) => {
setSessions(res.sessions);
setCurrentSessionId(res.currentSessionId);
})
.catch((err) => setError(String(err)));
}
useEffect(load, []);
async function revoke(s: SessionSummary) {
const isSelf = s.id === currentSessionId;
const label = s.name ?? s.email ?? s.sub;
const confirmMsg = isSelf
? `This is your current session — revoking it will sign you out immediately. Continue?`
: `Revoke ${label}'s session? They'll be signed out immediately.`;
if (!confirm(confirmMsg)) return;
setError(null);
setRevokingId(s.id);
try {
await api.sessions.revoke(s.id);
if (isSelf) {
window.location.href = "/";
return;
}
load();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setRevokingId(null);
}
}
const { sorted, sortKey, sortDir, requestSort } = useSortable(sessions);
function exportCsv() {
if (!sorted) return;
downloadCsv(
"sessions.csv",
["Name", "Email", "Role", "IP", "Device", "Last active", "Expires"],
sorted.map((s) => [
s.name ?? "",
s.email ?? "",
s.role ?? "",
s.ip ?? "",
friendlyUserAgent(s.userAgent),
formatDateTime(new Date(s.lastAccess)),
s.expiresAt ? formatDateTime(new Date(s.expiresAt)) : "",
]),
);
}
return (
<>
<div className="d-flex align-items-center mb-3">
<div>
<h2 className="page-title mb-0">Sessions</h2>
<div className="text-secondary small mt-1">Everyone currently signed in, across every device.</div>
</div>
<button className="btn btn-outline-secondary ms-auto" onClick={exportCsv} disabled={!sorted || sorted.length === 0}>
Export CSV
</button>
</div>
{error && <div className="alert alert-danger">{error}</div>}
<div className="card">
<div className="table-responsive">
<table className="table table-vcenter card-table">
<thead>
<tr>
<SortableTh<SessionSummary> label="Name" sortKeyName="name" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<SessionSummary> label="Email" sortKeyName="email" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<SessionSummary> label="Role" sortKeyName="role" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<SessionSummary> label="IP" sortKeyName="ip" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<th>Device</th>
<SortableTh<SessionSummary>
label="Last active"
sortKeyName="lastAccess"
activeKey={sortKey}
direction={sortDir}
onSort={requestSort}
/>
<SortableTh<SessionSummary>
label="Expires"
sortKeyName="expiresAt"
activeKey={sortKey}
direction={sortDir}
onSort={requestSort}
/>
<th className="w-1">Actions</th>
</tr>
</thead>
<tbody>
{sorted?.map((s) => (
<tr key={s.id}>
<td>
{s.name ?? "—"}
{s.id === currentSessionId && <span className="badge bg-blue-lt text-blue ms-2">This device</span>}
</td>
<td>{s.email ?? "—"}</td>
<td>{s.role ?? <span className="text-secondary">unknown user</span>}</td>
<td>{s.ip ?? "—"}</td>
<td className="text-secondary">{friendlyUserAgent(s.userAgent)}</td>
<td>{formatDateTime(new Date(s.lastAccess))}</td>
<td>{s.expiresAt ? formatDateTime(new Date(s.expiresAt)) : "—"}</td>
<td>
<button
className="btn btn-sm btn-outline-danger"
onClick={() => revoke(s)}
disabled={revokingId === s.id}
>
{revokingId === s.id ? "Revoking…" : "Revoke"}
</button>
</td>
</tr>
))}
{sorted?.length === 0 && (
<tr>
<td colSpan={8} className="text-secondary text-center">
No active sessions.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</>
);
}