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..ea88b46 --- /dev/null +++ b/src/routes/admin/me_sessions.rs @@ -0,0 +1,180 @@ +//! 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 tower_cookies::Cookies; +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, 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)?; + + let external_id = &admin_auth.identity.external_id; + if external_id.is_empty() { + 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. + // 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) + .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, + current_session_id, + })) +} + +/// 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(e) => { + return Err(AdminError::Internal(format!( + "Failed to look up 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 { + 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/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/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 666a037..87580f2 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": [ @@ -27792,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 23592c2..3313981 100644 --- a/ui/src/pages/AccountPage.stories.tsx +++ b/ui/src/pages/AccountPage.stories.tsx @@ -65,6 +65,41 @@ 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, + current_session_id: "sess-001", +}; + +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 +137,7 @@ export const Default: Story = { parameters: { msw: { handlers: [ + sessionsHandler, http.get("*/admin/v1/me/export", () => { return HttpResponse.json(mockExportData); }), @@ -115,6 +151,9 @@ export const Default: Story = { usage_records_deleted: 84, }); }), + http.delete("*/admin/v1/me/sessions/:sessionId", () => { + return HttpResponse.json({ sessions_revoked: 1 }); + }), ], }, }, @@ -124,6 +163,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 +184,7 @@ export const MinimalProfile: Story = { parameters: { msw: { handlers: [ + sessionsDisabledHandler, http.get("*/admin/v1/me/export", () => { return HttpResponse.json(mockExportData); }), @@ -166,6 +207,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..7e0141b 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 [revokingSessionIds, setRevokingSessionIds] = useState>(new Set()); // Export data query (only fetch when triggered) const { refetch: fetchExport, isFetching: isExporting } = useQuery({ @@ -62,6 +74,56 @@ export default function AccountPage() { enabled: false, // Don't auto-fetch }); + // Sessions + const { + data: sessions, + isLoading: sessionsLoading, + isError: sessionsError, + } = useQuery(meSessionsListOptions()); + + const deleteSessionMutation = useMutation({ + ...meSessionsDeleteOneMutation(), + onSuccess: (_data, variables) => { + setRevokingSessionIds((prev) => { + const next = new Set(prev); + next.delete(variables.path.session_id); + return next; + }); + if (variables.path.session_id === sessions?.current_session_id) { + logout(); + } else { + queryClient.invalidateQueries({ queryKey: meSessionsListQueryKey() }); + } + }, + 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 = 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 } }); + }; + // Delete account mutation const deleteMutation = useMutation({ ...meDeleteMutation(), @@ -237,6 +299,48 @@ export default function AccountPage() { + {/* Active Sessions */} + + + + + Active Sessions + + Devices where you are currently logged in + + + {sessionsLoading ? ( +
+ + +
+ ) : sessionsError ? ( +

Failed to load sessions. Please try again.

+ ) : 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 */}