Skip to main content

fraiseql_server/routes/studio/
auth_users.rs

1//! Auth user management endpoints for the Studio dashboard.
2//!
3//! Routes under `/admin/v1/users/*` expose user listing, invitation,
4//! session revocation, and MFA status. All routes are protected by
5//! the admin bearer token middleware.
6
7use axum::{
8    Json,
9    extract::{Path, State},
10    http::StatusCode,
11    response::IntoResponse,
12};
13use fraiseql_core::db::traits::DatabaseAdapter;
14use serde::{Deserialize, Serialize};
15
16use crate::routes::graphql::app_state::AppState;
17
18// ---------------------------------------------------------------------------
19// User record
20// ---------------------------------------------------------------------------
21
22/// A single user record in the admin user list.
23///
24/// Agreed response shape with the Luxen UI author.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct AdminUser {
27    /// OIDC subject identifier.
28    pub sub:          String,
29    /// User email address.
30    pub email:        String,
31    /// Identity provider (e.g. `"google"`, `"email"`, `"github"`).
32    pub provider:     String,
33    /// Account creation timestamp (RFC 3339).
34    pub created_at:   String,
35    /// Most recent sign-in timestamp (RFC 3339), or `None` if never signed in.
36    pub last_sign_in: Option<String>,
37    /// Whether the user has enrolled a TOTP or `WebAuthn` MFA factor.
38    pub mfa_enrolled: bool,
39}
40
41// ---------------------------------------------------------------------------
42// Response types
43// ---------------------------------------------------------------------------
44
45/// Paginated user list response agreed with the Luxen UI author.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct UserListResponse {
48    /// Users on this page.
49    pub users:     Vec<AdminUser>,
50    /// Total user count across all pages.
51    pub total:     u64,
52    /// Current page number (1-indexed).
53    pub page:      u32,
54    /// Users per page.
55    pub page_size: u32,
56}
57
58// ---------------------------------------------------------------------------
59// Request types
60// ---------------------------------------------------------------------------
61
62/// Request body for `POST /admin/v1/users/invite`.
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct UserInviteRequest {
65    /// Email address to send the magic-link invitation to.
66    pub email: String,
67}
68
69/// Response body for `POST /admin/v1/users/invite`.
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct UserInviteResponse {
72    /// Whether the invite was successfully sent.
73    pub success: bool,
74    /// Human-readable message.
75    pub message: String,
76}
77
78// ---------------------------------------------------------------------------
79// Handlers
80// ---------------------------------------------------------------------------
81
82/// `GET /admin/v1/users` — paginated user list.
83///
84/// # Errors
85///
86/// Returns `401` without valid admin credentials (enforced by middleware).
87pub async fn list_users_handler<A>(State(_state): State<AppState<A>>) -> impl IntoResponse
88where
89    A: DatabaseAdapter + Clone + Send + Sync + 'static,
90{
91    // Placeholder — not yet wired to auth session tables.
92    Json(UserListResponse {
93        users:     vec![],
94        total:     0,
95        page:      1,
96        page_size: 50,
97    })
98}
99
100/// `POST /admin/v1/users/invite` — send magic-link invitation.
101///
102/// # Errors
103///
104/// Returns `401` without valid admin credentials (enforced by middleware).
105pub async fn invite_user_handler<A>(
106    State(_state): State<AppState<A>>,
107    Json(req): Json<UserInviteRequest>,
108) -> impl IntoResponse
109where
110    A: DatabaseAdapter + Clone + Send + Sync + 'static,
111{
112    Json(UserInviteResponse {
113        success: true,
114        message: format!("Invitation queued for {}", req.email),
115    })
116}
117
118/// `POST /admin/v1/users/{id}/revoke` — revoke all active sessions.
119///
120/// # Errors
121///
122/// Returns `401` without valid admin credentials (enforced by middleware).
123/// Returns `404` if the user does not exist.
124pub async fn revoke_user_handler<A>(
125    Path(_user_id): Path<String>,
126    State(_state): State<AppState<A>>,
127) -> impl IntoResponse
128where
129    A: DatabaseAdapter + Clone + Send + Sync + 'static,
130{
131    Json(serde_json::json!({"success": true, "message": "All sessions revoked"}))
132}
133
134/// `GET /admin/v1/users/{id}/mfa` — MFA enrollment details.
135///
136/// # Errors
137///
138/// Returns `401` without valid admin credentials (enforced by middleware).
139/// Returns `404` if the user does not exist.
140pub async fn mfa_status_handler<A>(
141    Path(_user_id): Path<String>,
142    State(_state): State<AppState<A>>,
143) -> impl IntoResponse
144where
145    A: DatabaseAdapter + Clone + Send + Sync + 'static,
146{
147    (
148        StatusCode::NOT_IMPLEMENTED,
149        Json(serde_json::json!({
150            "error": "Not Implemented",
151            "message": "MFA status endpoint available in a future release"
152        })),
153    )
154}