From 5625784aaca6acc51b04546da0ad8f00ec26dc58 Mon Sep 17 00:00:00 2001 From: ScriptSmith Date: Wed, 18 Mar 2026 22:03:11 +1000 Subject: [PATCH 1/5] Track own sessions --- src/openapi.rs | 3 + src/routes/admin/me_sessions.rs | 152 +++++++++++++++++++++++++++ src/routes/admin/mod.rs | 7 +- ui/src/api/openapi.json | 86 +++++++++++++++ ui/src/pages/AccountPage.stories.tsx | 41 ++++++++ ui/src/pages/AccountPage.tsx | 74 ++++++++++++- 6 files changed, 359 insertions(+), 4 deletions(-) create mode 100644 src/routes/admin/me_sessions.rs diff --git a/src/openapi.rs b/src/openapi.rs index 360bc5b..dd0231c 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -401,6 +401,9 @@ requests_per_minute = 120 admin::me_api_keys::create, admin::me_api_keys::revoke, admin::me_api_keys::rotate, + // Self-service endpoints - Sessions + admin::me_sessions::list, + admin::me_sessions::delete_one, // Admin routes - Organizations admin::organizations::create, admin::organizations::get, diff --git a/src/routes/admin/me_sessions.rs b/src/routes/admin/me_sessions.rs new file mode 100644 index 0000000..1ce65e7 --- /dev/null +++ b/src/routes/admin/me_sessions.rs @@ -0,0 +1,152 @@ +//! Self-service session management endpoints. +//! +//! Allows users to view and revoke their own sessions at `/admin/v1/me/sessions`. + +use axum::{ + Extension, Json, + extract::{Path, State}, +}; +use serde_json::json; +use uuid::Uuid; + +use super::{AuditActor, error::AdminError, sessions::get_session_store}; +use crate::{ + AppState, + middleware::{AdminAuth, AuthzContext, ClientInfo}, + models::CreateAuditLog, + routes::admin::sessions::{SessionInfo, SessionListResponse, SessionsRevokedResponse}, + services::Services, +}; + +fn get_services(state: &AppState) -> Result<&Services, AdminError> { + state.services.as_ref().ok_or(AdminError::ServicesRequired) +} + +/// List current user's active sessions. +/// +/// Returns an empty list if enhanced session management is not enabled. +/// The `enhanced_enabled` field indicates whether session tracking is active. +#[cfg_attr(feature = "utoipa", utoipa::path( + get, + path = "/admin/v1/me/sessions", + tag = "me", + operation_id = "me_sessions_list", + responses( + (status = 200, description = "List of current user's sessions", body = SessionListResponse), + (status = 401, description = "Not authenticated", body = crate::openapi::ErrorResponse), + ) +))] +#[tracing::instrument(name = "admin.me.sessions.list", skip(state, admin_auth, authz))] +pub async fn list( + State(state): State, + Extension(admin_auth): Extension, + Extension(authz): Extension, +) -> Result, AdminError> { + authz.require("me", "read", None, None, None, None)?; + + let external_id = &admin_auth.identity.external_id; + if external_id.is_empty() { + return Ok(Json(SessionListResponse { + data: vec![], + enhanced_enabled: false, + })); + } + + let session_store = get_session_store(&state)?; + let enhanced_enabled = session_store.is_enhanced_enabled(); + + let sessions = session_store + .list_user_sessions(external_id) + .await + .map_err(|e| AdminError::Internal(format!("Failed to list sessions: {}", e)))?; + + let data = sessions + .into_iter() + .map(|s| SessionInfo { + id: s.id, + device: s.device, + created_at: s.created_at, + last_activity: s.last_activity, + expires_at: s.expires_at, + }) + .collect(); + + Ok(Json(SessionListResponse { + data, + enhanced_enabled, + })) +} + +/// Revoke a specific session belonging to the current user. +/// +/// Returns success even if the session doesn't exist (idempotent). +/// Returns 400 if the session belongs to a different user. +#[cfg_attr(feature = "utoipa", utoipa::path( + delete, + path = "/admin/v1/me/sessions/{session_id}", + tag = "me", + operation_id = "me_sessions_delete_one", + params(("session_id" = Uuid, Path, description = "Session ID to revoke")), + responses( + (status = 200, description = "Session revoked", body = SessionsRevokedResponse), + (status = 400, description = "Session does not belong to current user", body = crate::openapi::ErrorResponse), + (status = 401, description = "Not authenticated", body = crate::openapi::ErrorResponse), + ) +))] +#[tracing::instrument(name = "admin.me.sessions.delete_one", skip(state, admin_auth, authz), fields(%session_id))] +pub async fn delete_one( + State(state): State, + Extension(admin_auth): Extension, + Extension(authz): Extension, + Extension(client_info): Extension, + Path(session_id): Path, +) -> Result, AdminError> { + authz.require("me", "delete", None, None, None, None)?; + + let external_id = &admin_auth.identity.external_id; + let services = get_services(&state)?; + let actor = AuditActor::from(&admin_auth); + + let session_store = get_session_store(&state)?; + + // Verify session belongs to the current user + let session_existed = match session_store.get_session(session_id).await { + Ok(Some(session)) => { + if session.external_id != *external_id { + return Err(AdminError::BadRequest( + "Session does not belong to current user".to_string(), + )); + } + true + } + Ok(None) => false, + Err(_) => false, + }; + + let result = session_store.delete_session(session_id).await; + let sessions_revoked = if result.is_ok() && session_existed { + 1 + } else { + 0 + }; + + let _ = services + .audit_logs + .create(CreateAuditLog { + actor_type: actor.actor_type, + actor_id: actor.actor_id, + action: "session.self_delete_one".to_string(), + resource_type: "session".to_string(), + resource_id: session_id, + org_id: None, + project_id: None, + details: json!({ + "session_id": session_id, + }), + ip_address: client_info.ip_address, + user_agent: client_info.user_agent, + }) + .await; + + Ok(Json(SessionsRevokedResponse { sessions_revoked })) +} diff --git a/src/routes/admin/mod.rs b/src/routes/admin/mod.rs index 234bcce..23db3e2 100644 --- a/src/routes/admin/mod.rs +++ b/src/routes/admin/mod.rs @@ -13,6 +13,8 @@ mod error; pub mod me; pub mod me_api_keys; pub mod me_providers; +#[cfg(feature = "sso")] +pub mod me_sessions; pub mod model_pricing; pub mod org_rbac_policies; #[cfg(feature = "sso")] @@ -689,7 +691,10 @@ pub(crate) fn admin_v1_routes() -> Router { // SSO routes (only available when sso feature is enabled) #[cfg(feature = "sso")] let router = router - // User Sessions + // Self-service sessions (current user) + .route("/me/sessions", get(me_sessions::list)) + .route("/me/sessions/{session_id}", delete(me_sessions::delete_one)) + // User Sessions (admin) .route( "/users/{user_id}/sessions", get(sessions::list).delete(sessions::delete_all), diff --git a/ui/src/api/openapi.json b/ui/src/api/openapi.json index 666a037..b579158 100644 --- a/ui/src/api/openapi.json +++ b/ui/src/api/openapi.json @@ -2962,6 +2962,92 @@ } } }, + "/admin/v1/me/sessions": { + "get": { + "tags": [ + "me" + ], + "summary": "List current user's active sessions.", + "description": "Returns an empty list if enhanced session management is not enabled.\nThe `enhanced_enabled` field indicates whether session tracking is active.", + "operationId": "me_sessions_list", + "responses": { + "200": { + "description": "List of current user's sessions", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionListResponse" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/admin/v1/me/sessions/{session_id}": { + "delete": { + "tags": [ + "me" + ], + "summary": "Revoke a specific session belonging to the current user.", + "description": "Returns success even if the session doesn't exist (idempotent).\nReturns 400 if the session belongs to a different user.", + "operationId": "me_sessions_delete_one", + "parameters": [ + { + "name": "session_id", + "in": "path", + "description": "Session ID to revoke", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Session revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionsRevokedResponse" + } + } + } + }, + "400": { + "description": "Session does not belong to current user", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, "/admin/v1/me/usage": { "get": { "tags": [ diff --git a/ui/src/pages/AccountPage.stories.tsx b/ui/src/pages/AccountPage.stories.tsx index 23592c2..cc34501 100644 --- a/ui/src/pages/AccountPage.stories.tsx +++ b/ui/src/pages/AccountPage.stories.tsx @@ -65,6 +65,40 @@ const mockExportData = { exported_at: "2024-06-15T14:30:00Z", }; +const mockSessions = { + data: [ + { + id: "sess-001", + device: { + user_agent: "Mozilla/5.0 (X11; Linux x86_64) Chrome/120", + ip_address: "192.168.1.42", + device_description: "Chrome 120 on Linux", + }, + created_at: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), + last_activity: new Date(Date.now() - 5 * 60 * 1000).toISOString(), + expires_at: new Date(Date.now() + 22 * 60 * 60 * 1000).toISOString(), + }, + { + id: "sess-002", + device: { + user_agent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)", + ip_address: "10.0.0.5", + device_description: "Safari on iPhone", + }, + created_at: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), + last_activity: new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(), + expires_at: new Date(Date.now() + 12 * 60 * 60 * 1000).toISOString(), + }, + ], + enhanced_enabled: true, +}; + +const sessionsHandler = http.get("*/admin/v1/me/sessions", () => HttpResponse.json(mockSessions)); + +const sessionsDisabledHandler = http.get("*/admin/v1/me/sessions", () => + HttpResponse.json({ data: [], enhanced_enabled: false }) +); + const meta: Meta = { title: "Pages/AccountPage", component: AccountPage, @@ -102,6 +136,7 @@ export const Default: Story = { parameters: { msw: { handlers: [ + sessionsHandler, http.get("*/admin/v1/me/export", () => { return HttpResponse.json(mockExportData); }), @@ -115,6 +150,9 @@ export const Default: Story = { usage_records_deleted: 84, }); }), + http.delete("*/admin/v1/me/sessions/:sessionId", () => { + return HttpResponse.json({ sessions_revoked: 1 }); + }), ], }, }, @@ -124,6 +162,7 @@ export const Loading: Story = { parameters: { msw: { handlers: [ + sessionsHandler, http.get("*/admin/v1/me/export", async () => { await new Promise((resolve) => setTimeout(resolve, 100000)); return HttpResponse.json(mockExportData); @@ -144,6 +183,7 @@ export const MinimalProfile: Story = { parameters: { msw: { handlers: [ + sessionsDisabledHandler, http.get("*/admin/v1/me/export", () => { return HttpResponse.json(mockExportData); }), @@ -166,6 +206,7 @@ export const Error: Story = { parameters: { msw: { handlers: [ + sessionsHandler, http.get("*/admin/v1/me/export", () => { return HttpResponse.json( { error: { code: "internal_error", message: "Failed to export data" } }, diff --git a/ui/src/pages/AccountPage.tsx b/ui/src/pages/AccountPage.tsx index e0dba34..46b30aa 100644 --- a/ui/src/pages/AccountPage.tsx +++ b/ui/src/pages/AccountPage.tsx @@ -1,10 +1,20 @@ -import { useMutation, useQuery } from "@tanstack/react-query"; -import { Download, Trash2, User, AlertTriangle, HardDrive } from "lucide-react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Download, Trash2, User, AlertTriangle, HardDrive, Monitor } from "lucide-react"; +import { useState } from "react"; -import { meDeleteMutation, meExportOptions } from "@/api/generated/@tanstack/react-query.gen"; +import { + meDeleteMutation, + meExportOptions, + meSessionsListOptions, + meSessionsListQueryKey, + meSessionsDeleteOneMutation, +} from "@/api/generated/@tanstack/react-query.gen"; +import type { SessionInfo } from "@/api/generated/types.gen"; import { useAuth } from "@/auth"; import { Button } from "@/components/Button/Button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/Card/Card"; +import { SessionCard } from "@/components/Admin/SessionsPanel/SessionCard"; +import { Skeleton } from "@/components/Skeleton/Skeleton"; import { useToast } from "@/components/Toast/Toast"; import { useConfirm } from "@/components/ConfirmDialog/ConfirmDialog"; import { exportAllIndexedDBData, deleteIndexedDBDatabase } from "@/hooks/useIndexedDB"; @@ -55,6 +65,8 @@ export default function AccountPage() { const { user, logout } = useAuth(); const { toast } = useToast(); const confirm = useConfirm(); + const queryClient = useQueryClient(); + const [revokingSessionId, setRevokingSessionId] = useState(null); // Export data query (only fetch when triggered) const { refetch: fetchExport, isFetching: isExporting } = useQuery({ @@ -62,6 +74,23 @@ export default function AccountPage() { enabled: false, // Don't auto-fetch }); + // Sessions + const { data: sessions, isLoading: sessionsLoading } = useQuery(meSessionsListOptions()); + + const deleteSessionMutation = useMutation({ + ...meSessionsDeleteOneMutation(), + onSuccess: () => { + setRevokingSessionId(null); + queryClient.invalidateQueries({ queryKey: meSessionsListQueryKey() }); + }, + onError: () => setRevokingSessionId(null), + }); + + const handleRevokeSession = (sessionId: string) => { + setRevokingSessionId(sessionId); + deleteSessionMutation.mutate({ path: { session_id: sessionId } }); + }; + // Delete account mutation const deleteMutation = useMutation({ ...meDeleteMutation(), @@ -237,6 +266,45 @@ export default function AccountPage() { + {/* Active Sessions */} + + + + + Active Sessions + + Devices where you are currently logged in + + + {sessionsLoading ? ( +
+ + +
+ ) : sessions?.enhanced_enabled === false ? ( +

+ Session tracking is not enabled for this deployment. +

+ ) : sessions && sessions.data.length > 0 ? ( +
+

+ {sessions.data.length} active session{sessions.data.length !== 1 ? "s" : ""} +

+ {sessions.data.map((session: SessionInfo) => ( + + ))} +
+ ) : ( +

No active sessions.

+ )} +
+
+ {/* Data Export */} From 94d30c367d9ef6b87dff22c7e5a7f7b50103eda2 Mon Sep 17 00:00:00 2001 From: ScriptSmith Date: Thu, 19 Mar 2026 22:31:54 +1000 Subject: [PATCH 2/5] Review fixes --- src/routes/admin/me_sessions.rs | 6 +++++- ui/src/pages/AccountPage.tsx | 27 +++++++++++++++++++++------ 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/routes/admin/me_sessions.rs b/src/routes/admin/me_sessions.rs index 1ce65e7..bad0d52 100644 --- a/src/routes/admin/me_sessions.rs +++ b/src/routes/admin/me_sessions.rs @@ -120,7 +120,11 @@ pub async fn delete_one( true } Ok(None) => false, - Err(_) => false, + Err(e) => { + return Err(AdminError::Internal(format!( + "Failed to look up session: {e}" + ))); + } }; let result = session_store.delete_session(session_id).await; diff --git a/ui/src/pages/AccountPage.tsx b/ui/src/pages/AccountPage.tsx index 46b30aa..a165a86 100644 --- a/ui/src/pages/AccountPage.tsx +++ b/ui/src/pages/AccountPage.tsx @@ -66,7 +66,7 @@ export default function AccountPage() { const { toast } = useToast(); const confirm = useConfirm(); const queryClient = useQueryClient(); - const [revokingSessionId, setRevokingSessionId] = useState(null); + const [revokingSessionIds, setRevokingSessionIds] = useState>(new Set()); // Export data query (only fetch when triggered) const { refetch: fetchExport, isFetching: isExporting } = useQuery({ @@ -79,15 +79,30 @@ export default function AccountPage() { const deleteSessionMutation = useMutation({ ...meSessionsDeleteOneMutation(), - onSuccess: () => { - setRevokingSessionId(null); + onSuccess: (_data, variables) => { + setRevokingSessionIds((prev) => { + const next = new Set(prev); + next.delete(variables.path.session_id); + return next; + }); queryClient.invalidateQueries({ queryKey: meSessionsListQueryKey() }); }, - onError: () => setRevokingSessionId(null), + onError: (error, variables) => { + setRevokingSessionIds((prev) => { + const next = new Set(prev); + next.delete(variables.path.session_id); + return next; + }); + toast({ + title: "Failed to revoke session", + description: String(error), + type: "error", + }); + }, }); const handleRevokeSession = (sessionId: string) => { - setRevokingSessionId(sessionId); + setRevokingSessionIds((prev) => new Set(prev).add(sessionId)); deleteSessionMutation.mutate({ path: { session_id: sessionId } }); }; @@ -295,7 +310,7 @@ export default function AccountPage() { key={session.id} session={session} onRevoke={handleRevokeSession} - isRevoking={revokingSessionId === session.id} + isRevoking={revokingSessionIds.has(session.id)} /> ))} From 49513e1938385d2cc78c99777441587a959b4e1f Mon Sep 17 00:00:00 2001 From: ScriptSmith Date: Thu, 19 Mar 2026 22:54:49 +1000 Subject: [PATCH 3/5] Review fixes --- src/routes/admin/me_sessions.rs | 65 ++++++++++++------- src/routes/admin/sessions.rs | 6 ++ ui/src/api/openapi.json | 6 ++ .../Admin/SessionsPanel/SessionCard.tsx | 25 +++++-- ui/src/pages/AccountPage.stories.tsx | 1 + ui/src/pages/AccountPage.tsx | 13 +++- 6 files changed, 87 insertions(+), 29 deletions(-) diff --git a/src/routes/admin/me_sessions.rs b/src/routes/admin/me_sessions.rs index bad0d52..f5269fe 100644 --- a/src/routes/admin/me_sessions.rs +++ b/src/routes/admin/me_sessions.rs @@ -7,6 +7,7 @@ use axum::{ extract::{Path, State}, }; use serde_json::json; +use tower_cookies::Cookies; use uuid::Uuid; use super::{AuditActor, error::AdminError, sessions::get_session_store}; @@ -36,11 +37,15 @@ fn get_services(state: &AppState) -> Result<&Services, AdminError> { (status = 401, description = "Not authenticated", body = crate::openapi::ErrorResponse), ) ))] -#[tracing::instrument(name = "admin.me.sessions.list", skip(state, admin_auth, authz))] +#[tracing::instrument( + name = "admin.me.sessions.list", + skip(state, admin_auth, authz, cookies) +)] pub async fn list( State(state): State, Extension(admin_auth): Extension, Extension(authz): Extension, + cookies: Cookies, ) -> Result, AdminError> { authz.require("me", "read", None, None, None, None)?; @@ -49,12 +54,19 @@ pub async fn list( return Ok(Json(SessionListResponse { data: vec![], enhanced_enabled: false, + current_session_id: None, })); } let session_store = get_session_store(&state)?; let enhanced_enabled = session_store.is_enhanced_enabled(); + // Extract current session ID from cookie so the UI can highlight it + let session_config = state.config.auth.session_config_or_default(); + let current_session_id = cookies + .get(&session_config.cookie_name) + .and_then(|c| c.value().parse::().ok()); + let sessions = session_store .list_user_sessions(external_id) .await @@ -74,6 +86,7 @@ pub async fn list( Ok(Json(SessionListResponse { data, enhanced_enabled, + current_session_id, })) } @@ -127,30 +140,36 @@ pub async fn delete_one( } }; - let result = session_store.delete_session(session_id).await; - let sessions_revoked = if result.is_ok() && session_existed { - 1 - } else { - 0 + let sessions_revoked = match session_store.delete_session(session_id).await { + Ok(_) if session_existed => 1, + Ok(_) => 0, + Err(e) => { + return Err(AdminError::Internal(format!( + "Failed to delete session: {e}" + ))); + } }; - let _ = services - .audit_logs - .create(CreateAuditLog { - actor_type: actor.actor_type, - actor_id: actor.actor_id, - action: "session.self_delete_one".to_string(), - resource_type: "session".to_string(), - resource_id: session_id, - org_id: None, - project_id: None, - details: json!({ - "session_id": session_id, - }), - ip_address: client_info.ip_address, - user_agent: client_info.user_agent, - }) - .await; + if session_existed { + let _ = services + .audit_logs + .create(CreateAuditLog { + actor_type: actor.actor_type, + actor_id: actor.actor_id, + action: "session.self_delete_one".to_string(), + resource_type: "session".to_string(), + resource_id: session_id, + org_id: None, + project_id: None, + details: json!({ + "session_id": session_id, + "sessions_revoked": sessions_revoked, + }), + ip_address: client_info.ip_address, + user_agent: client_info.user_agent, + }) + .await; + } Ok(Json(SessionsRevokedResponse { sessions_revoked })) } diff --git a/src/routes/admin/sessions.rs b/src/routes/admin/sessions.rs index a7c5383..0192758 100644 --- a/src/routes/admin/sessions.rs +++ b/src/routes/admin/sessions.rs @@ -48,6 +48,10 @@ pub struct SessionListResponse { /// Whether enhanced session management is enabled. /// If false, the UI should show a message that session tracking is not enabled. pub enhanced_enabled: bool, + /// The session ID of the current request (if authenticated via session cookie). + /// Allows the UI to highlight the current session and warn before self-revocation. + #[serde(skip_serializing_if = "Option::is_none")] + pub current_session_id: Option, } /// Response after revoking sessions. @@ -153,6 +157,7 @@ pub async fn list( Ok(Json(SessionListResponse { data, enhanced_enabled, + current_session_id: None, })) } @@ -386,6 +391,7 @@ mod tests { let response = SessionListResponse { data: vec![], enhanced_enabled: false, + current_session_id: None, }; let json = serde_json::to_string(&response).unwrap(); diff --git a/ui/src/api/openapi.json b/ui/src/api/openapi.json index b579158..87580f2 100644 --- a/ui/src/api/openapi.json +++ b/ui/src/api/openapi.json @@ -27878,6 +27878,12 @@ "enhanced_enabled" ], "properties": { + "current_session_id": { + "type": "string", + "format": "uuid", + "description": "The session ID of the current request (if authenticated via session cookie).\nAllows the UI to highlight the current session and warn before self-revocation.", + "nullable": true + }, "data": { "type": "array", "items": { diff --git a/ui/src/components/Admin/SessionsPanel/SessionCard.tsx b/ui/src/components/Admin/SessionsPanel/SessionCard.tsx index df6e4c6..5bdec01 100644 --- a/ui/src/components/Admin/SessionsPanel/SessionCard.tsx +++ b/ui/src/components/Admin/SessionsPanel/SessionCard.tsx @@ -5,6 +5,7 @@ export interface SessionCardProps { session: SessionInfo; onRevoke?: (sessionId: string) => void; isRevoking?: boolean; + isCurrent?: boolean; } function formatRelativeTime(date: Date): string { @@ -37,13 +38,20 @@ function formatDateTime(dateStr: string): string { return date.toLocaleString(); } -export function SessionCard({ session, onRevoke, isRevoking = false }: SessionCardProps) { +export function SessionCard({ + session, + onRevoke, + isRevoking = false, + isCurrent = false, +}: SessionCardProps) { const createdAt = new Date(session.created_at); const expiresAt = new Date(session.expires_at); const lastActivity = session.last_activity ? new Date(session.last_activity) : null; return ( -
+
{/* Device Info */}
@@ -51,9 +59,16 @@ export function SessionCard({ session, onRevoke, isRevoking = false }: SessionCa
-

- {session.device?.device_description || "Unknown Device"} -

+
+

+ {session.device?.device_description || "Unknown Device"} +

+ {isCurrent && ( + + This device + + )} +
{session.device?.ip_address && (
diff --git a/ui/src/pages/AccountPage.stories.tsx b/ui/src/pages/AccountPage.stories.tsx index cc34501..3313981 100644 --- a/ui/src/pages/AccountPage.stories.tsx +++ b/ui/src/pages/AccountPage.stories.tsx @@ -91,6 +91,7 @@ const mockSessions = { }, ], enhanced_enabled: true, + current_session_id: "sess-001", }; const sessionsHandler = http.get("*/admin/v1/me/sessions", () => HttpResponse.json(mockSessions)); diff --git a/ui/src/pages/AccountPage.tsx b/ui/src/pages/AccountPage.tsx index a165a86..2c5d1e4 100644 --- a/ui/src/pages/AccountPage.tsx +++ b/ui/src/pages/AccountPage.tsx @@ -101,7 +101,17 @@ export default function AccountPage() { }, }); - const handleRevokeSession = (sessionId: string) => { + const handleRevokeSession = async (sessionId: string) => { + const isCurrent = sessionId === sessions?.current_session_id; + const confirmed = await confirm({ + title: isCurrent ? "Revoke current session?" : "Revoke session?", + message: isCurrent + ? "This is your current session. Revoking it will immediately log you out of this browser." + : "This will immediately log out the device associated with this session.", + confirmLabel: "Revoke", + variant: "destructive", + }); + if (!confirmed) return; setRevokingSessionIds((prev) => new Set(prev).add(sessionId)); deleteSessionMutation.mutate({ path: { session_id: sessionId } }); }; @@ -311,6 +321,7 @@ export default function AccountPage() { session={session} onRevoke={handleRevokeSession} isRevoking={revokingSessionIds.has(session.id)} + isCurrent={session.id === sessions.current_session_id} /> ))}
From 101010c2df18c76311d559e673da3879e59e793c Mon Sep 17 00:00:00 2001 From: ScriptSmith Date: Thu, 19 Mar 2026 23:13:52 +1000 Subject: [PATCH 4/5] Review fixes --- src/routes/admin/me_sessions.rs | 31 ++++++++++++++++++------------- ui/src/pages/AccountPage.tsx | 8 +++++++- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/src/routes/admin/me_sessions.rs b/src/routes/admin/me_sessions.rs index f5269fe..ea88b46 100644 --- a/src/routes/admin/me_sessions.rs +++ b/src/routes/admin/me_sessions.rs @@ -61,11 +61,16 @@ pub async fn list( let session_store = get_session_store(&state)?; let enhanced_enabled = session_store.is_enhanced_enabled(); - // Extract current session ID from cookie so the UI can highlight it - let session_config = state.config.auth.session_config_or_default(); - let current_session_id = cookies - .get(&session_config.cookie_name) - .and_then(|c| c.value().parse::().ok()); + // Extract current session ID from cookie so the UI can highlight it. + // Only include when enhanced sessions are enabled to avoid leaking the session UUID. + let current_session_id = if enhanced_enabled { + let session_config = state.config.auth.session_config_or_default(); + cookies + .get(&session_config.cookie_name) + .and_then(|c| c.value().parse::().ok()) + } else { + None + }; let sessions = session_store .list_user_sessions(external_id) @@ -140,14 +145,14 @@ pub async fn delete_one( } }; - let sessions_revoked = match session_store.delete_session(session_id).await { - Ok(_) if session_existed => 1, - Ok(_) => 0, - Err(e) => { - return Err(AdminError::Internal(format!( - "Failed to delete session: {e}" - ))); - } + let sessions_revoked = if !session_existed { + 0 + } else { + session_store + .delete_session(session_id) + .await + .map_err(|e| AdminError::Internal(format!("Failed to delete session: {e}")))?; + 1 }; if session_existed { diff --git a/ui/src/pages/AccountPage.tsx b/ui/src/pages/AccountPage.tsx index 2c5d1e4..91ccbe9 100644 --- a/ui/src/pages/AccountPage.tsx +++ b/ui/src/pages/AccountPage.tsx @@ -75,7 +75,11 @@ export default function AccountPage() { }); // Sessions - const { data: sessions, isLoading: sessionsLoading } = useQuery(meSessionsListOptions()); + const { + data: sessions, + isLoading: sessionsLoading, + isError: sessionsError, + } = useQuery(meSessionsListOptions()); const deleteSessionMutation = useMutation({ ...meSessionsDeleteOneMutation(), @@ -306,6 +310,8 @@ export default function AccountPage() {
+ ) : sessionsError ? ( +

Failed to load sessions. Please try again.

) : sessions?.enhanced_enabled === false ? (

Session tracking is not enabled for this deployment. From 7eec5601dab5a93f1eb8e759f021508ccabc511f Mon Sep 17 00:00:00 2001 From: ScriptSmith Date: Thu, 19 Mar 2026 23:29:57 +1000 Subject: [PATCH 5/5] Logout current session --- ui/src/pages/AccountPage.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ui/src/pages/AccountPage.tsx b/ui/src/pages/AccountPage.tsx index 91ccbe9..7e0141b 100644 --- a/ui/src/pages/AccountPage.tsx +++ b/ui/src/pages/AccountPage.tsx @@ -89,7 +89,11 @@ export default function AccountPage() { next.delete(variables.path.session_id); return next; }); - queryClient.invalidateQueries({ queryKey: meSessionsListQueryKey() }); + if (variables.path.session_id === sessions?.current_session_id) { + logout(); + } else { + queryClient.invalidateQueries({ queryKey: meSessionsListQueryKey() }); + } }, onError: (error, variables) => { setRevokingSessionIds((prev) => {