From 82aa8948cfd6dfa03369da76d86dc98b3bccf98d Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Mon, 27 Oct 2025 11:32:22 +0100 Subject: [PATCH] tenants --- backend/src/openapi.rs | 9 ++- backend/src/routes/auth.rs | 58 +++++++++++++++- backend/src/routes/documents.rs | 5 +- backend/src/routes/mod.rs | 1 + backend/tests/common/mod.rs | 4 +- docs/api.txt | 6 +- frontend/src/index.jsx | 119 +++++++++++++++++++++++++++++--- 7 files changed, 180 insertions(+), 22 deletions(-) diff --git a/backend/src/openapi.rs b/backend/src/openapi.rs index bd2e4d9..ff9d2f3 100644 --- a/backend/src/openapi.rs +++ b/backend/src/openapi.rs @@ -539,6 +539,7 @@ pub mod schemas { pub access_token: String, pub token_type: String, pub expires_in: i64, + pub tenant: TenantSnippet, } #[derive(Serialize, Deserialize, ToSchema)] @@ -547,9 +548,15 @@ pub mod schemas { pub slug: String, } + #[derive(Serialize, Deserialize, ToSchema)] + pub struct TenantSnippet { + pub id: Uuid, + pub slug: String, + } + #[derive(Serialize, Deserialize, ToSchema)] pub struct TenantSelectionResponse { - pub selection_token: String, + pub access_token: String, pub tenants: Vec, } diff --git a/backend/src/routes/auth.rs b/backend/src/routes/auth.rs index 08e1d30..cd0166e 100644 --- a/backend/src/routes/auth.rs +++ b/backend/src/routes/auth.rs @@ -43,6 +43,7 @@ pub struct LoginResponse { pub access_token: String, pub token_type: String, pub expires_in: i64, + pub tenant: TenantSnippet, } #[derive(Serialize)] @@ -51,12 +52,23 @@ pub struct TenantSummary { pub slug: String, } +#[derive(Serialize)] +pub struct TenantSnippet { + pub id: Uuid, + pub slug: String, +} + #[derive(Serialize)] pub struct TenantSelectionResponse { - pub selection_token: String, + pub access_token: String, pub tenants: Vec, } +#[derive(Serialize)] +pub struct TenantListResponse { + pub tenants: Vec, +} + #[derive(Deserialize)] pub struct TenantSelectionRequest { pub tenant_id: Uuid, @@ -121,7 +133,7 @@ pub async fn login( .collect(); let response = Json(TenantSelectionResponse { - selection_token, + access_token: selection_token, tenants, }) .into_response(); @@ -250,6 +262,38 @@ pub async fn me(user: AuthenticatedUser) -> Json { Json(user) } +pub async fn list_tenants( + State(state): State, + auth: Option>>, +) -> AppResult> { + let bearer = auth.ok_or_else(AppError::unauthorized)?; + let token = bearer.token(); + + let user_id = match state.jwt.verify_token(token) { + Ok(claims) => claims.sub, + Err(_) => { + let claims = state + .jwt + .verify_tenant_selector_token(token) + .map_err(|_| AppError::unauthorized())?; + claims.sub + } + }; + + let mut conn = state.db_unscoped()?; + + let tenants = memberships_dsl::user_memberships + .inner_join(tenant_dsl::tenants) + .filter(memberships_dsl::user_id.eq(user_id)) + .select((tenant_dsl::id, tenant_dsl::slug)) + .load::<(Uuid, String)>(&mut conn)? + .into_iter() + .map(|(id, slug)| TenantSnippet { id, slug }) + .collect(); + + Ok(Json(TenantListResponse { tenants })) +} + fn issue_session( state: &AppState, conn: &mut PgConnection, @@ -262,6 +306,12 @@ fn issue_session( .generate_token(user.id, tenant_id, &user.username) .map_err(AppError::from)?; + let tenant_slug: String = tenant_dsl::tenants + .find(tenant_id) + .select(tenant_dsl::slug) + .first(conn) + .map_err(AppError::from)?; + let refresh_value = generate_refresh_token(); let refresh_hash = hash_refresh_token(&refresh_value); let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days); @@ -283,6 +333,10 @@ fn issue_session( access_token, token_type: "Bearer".to_string(), expires_in: state.config.jwt_expiry_minutes * 60, + tenant: TenantSnippet { + id: tenant_id, + slug: tenant_slug, + }, }) .into_response(); diff --git a/backend/src/routes/documents.rs b/backend/src/routes/documents.rs index b889626..e679401 100644 --- a/backend/src/routes/documents.rs +++ b/backend/src/routes/documents.rs @@ -411,10 +411,7 @@ pub async fn list_documents( .filter(|s| !s.is_empty()) .map(|s| s.to_owned()); - let mut include_descendants = include_descendants.unwrap_or_else(|| folder_id.is_some()); - if search_text.is_some() || tags_param.is_some() || correspondents_param.is_some() { - include_descendants = true; - } + let include_descendants = include_descendants.unwrap_or(true); match (folder_id, include_descendants) { (Some(folder_id), true) => { diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index feff80c..8f2947f 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -54,6 +54,7 @@ pub fn create_router(state: AppState) -> Router<()> { .route("/refresh", post(auth::refresh)) .route("/logout", post(auth::logout)) .route("/select-tenant", post(auth::select_tenant)) + .route("/tenants", get(auth::list_tenants)) .route("/me", get(auth::me)); let documents_routes = Router::new() diff --git a/backend/tests/common/mod.rs b/backend/tests/common/mod.rs index 410a7ca..b9b7cbf 100644 --- a/backend/tests/common/mod.rs +++ b/backend/tests/common/mod.rs @@ -328,7 +328,7 @@ impl TestApp { #[derive(Deserialize)] struct TenantSelectionResponse { - selection_token: String, + access_token: String, tenants: Vec, } @@ -350,7 +350,7 @@ impl TestApp { &SelectTenantPayload { tenant_id: target_tenant, }, - Some(&selection.selection_token), + Some(&selection.access_token), ) .await?; diff --git a/docs/api.txt b/docs/api.txt index 802065a..538eb45 100644 --- a/docs/api.txt +++ b/docs/api.txt @@ -5,8 +5,8 @@ Unless noted otherwise, endpoints below require a valid `Authorization: Bearer < Authentication -------------- -- POST /api/auth/login - Exchange username/password for an access token and refresh cookie (public). -- POST /api/auth/refresh - Rotate the refresh cookie and return a new access token (public, requires refresh cookie). +- POST /api/auth/login - Exchange username/password for an access token and refresh cookie (public). Returns the active tenant as `{ tenant: { id, slug } }`. When multiple tenants are available, the response contains an `access_token` (tenant-selector token) and tenant list instead. +- POST /api/auth/refresh - Rotate the refresh cookie and return a new access token (public, requires refresh cookie). Response also includes the current tenant `{ tenant: { id, slug } }`. - POST /api/auth/logout - Revoke the caller's refresh tokens and clear the cookie. - GET /api/auth/me - Return the authenticated principal payload. @@ -16,7 +16,7 @@ Health Documents --------- -- GET /api/documents - List or search documents. Optional filters: `folder_id` (defaults to root when omitted), `include_deleted`, `include_descendants` (defaults to true when a `folder_id` is provided and no other override is supplied), `query` (Quickwit full-text), `tags` (comma-separated tag UUIDs), and `correspondents` (comma-separated correspondent UUIDs). Each entry includes tags, correspondent assignments, and current version info. +- GET /api/documents - List or search documents. Optional filters: `folder_id` (defaults to root when omitted), `include_deleted`, `include_descendants` (defaults to true unless explicitly set to `false` without filters), `query` (Quickwit full-text), `tags` (comma-separated tag UUIDs), and `correspondents` (comma-separated correspondent UUIDs). Each entry includes tags, correspondent assignments, and current version info. - GET /api/documents/check?checksum= - Lightweight checksum preflight. Returns `exists=false` when no document with the supplied SHA-256 checksum is present; otherwise returns `exists=true` plus the current document metadata. - POST /api/documents - Upload a document via multipart form-data. Required field: `file`. Optional fields: `title`, `folder_id`, JSON `metadata`, JSON array `tag_ids`, JSON array `correspondents` (each with `correspondent_id` and `role`), and `issued_at` (RFC3339). When `title` is supplied, the stored filename becomes `<original_extension>`. Include `skip_existing=true` to receive `204 No Content` instead of reusing a matching document. - POST /api/documents/bulk/move - Move multiple documents to a target folder. diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx index 210e146..2de5e1b 100644 --- a/frontend/src/index.jsx +++ b/frontend/src/index.jsx @@ -63,6 +63,16 @@ const api = axios.create({ }); const STORED_TOKEN = window.localStorage.getItem('papercrate_token') || ''; +let STORED_TENANT = null; +try { + const rawTenant = window.localStorage.getItem('papercrate_tenant'); + if (rawTenant) { + STORED_TENANT = JSON.parse(rawTenant); + } +} catch ( + // eslint-disable-next-line no-empty + error +) {} if (STORED_TOKEN) { api.defaults.headers.common.Authorization = `Bearer ${STORED_TOKEN}`; } @@ -73,6 +83,8 @@ const initialAppState = { error: null, isRefreshing: false, tenantSelection: null, + tenant: STORED_TENANT, + tenants: [], }; const AppStateContext = React.createContext(null); @@ -81,7 +93,14 @@ const AppDispatchContext = React.createContext(null); const appStateReducer = (state, action) => { switch (action.type) { case 'LOGIN_REQUEST': - return { ...state, status: 'authenticating', error: null, tenantSelection: null }; + return { + ...state, + status: 'authenticating', + error: null, + tenantSelection: null, + tenant: null, + tenants: [], + }; case 'LOGIN_SUCCESS': return { ...state, @@ -89,6 +108,8 @@ const appStateReducer = (state, action) => { token: action.token, error: null, tenantSelection: null, + tenant: action.tenant || null, + tenants: state.tenants, }; case 'LOGIN_FAILURE': return { @@ -97,6 +118,8 @@ const appStateReducer = (state, action) => { error: action.error || null, isRefreshing: false, tenantSelection: null, + tenant: null, + tenants: [], }; case 'TENANT_SELECTION_REQUIRED': return { @@ -108,6 +131,8 @@ const appStateReducer = (state, action) => { selectionToken: action.selectionToken, tenants: action.tenants, }, + tenant: null, + tenants: [], }; case 'CLEAR_TENANT_SELECTION': return { @@ -116,6 +141,8 @@ const appStateReducer = (state, action) => { error: null, isRefreshing: false, tenantSelection: null, + tenant: null, + tenants: [], }; case 'BOOTSTRAP_START': return { ...state, status: 'bootstrapping', error: null }; @@ -132,6 +159,8 @@ const appStateReducer = (state, action) => { isRefreshing: false, status: state.status === 'logged-out' ? 'authenticated' : state.status, tenantSelection: null, + tenant: action.tenant || state.tenant || null, + tenants: state.tenants, }; case 'TOKEN_REFRESH_FAILURE': return { @@ -140,11 +169,26 @@ const appStateReducer = (state, action) => { error: action.error || null, isRefreshing: false, tenantSelection: null, + tenant: null, + tenants: [], }; case 'LOGOUT': - return { status: 'logged-out', token: '', error: null, isRefreshing: false, tenantSelection: null }; + return { + status: 'logged-out', + token: '', + error: null, + isRefreshing: false, + tenantSelection: null, + tenant: null, + tenants: [], + }; case 'RESET_ERROR': return { ...state, error: null }; + case 'SET_TENANTS': + return { + ...state, + tenants: Array.isArray(action.tenants) ? action.tenants : [], + }; default: return state; } @@ -164,6 +208,45 @@ const AppStateProvider = ({ children }) => { } }, [state.token]); + useEffect(() => { + if (state.tenant) { + try { + window.localStorage.setItem('papercrate_tenant', JSON.stringify(state.tenant)); + } catch (error) { + console.warn('Failed to persist tenant info', error); + } + } else { + window.localStorage.removeItem('papercrate_tenant'); + } + }, [state.tenant]); + + useEffect(() => { + let abort = false; + const loadTenants = async () => { + if (state.status !== 'authenticated' || !state.token) { + dispatch({ type: 'SET_TENANTS', tenants: [] }); + return; + } + try { + const { data } = await api.get('/auth/tenants'); + if (!abort) { + dispatch({ + type: 'SET_TENANTS', + tenants: Array.isArray(data?.tenants) ? data.tenants : [], + }); + } + } catch (error) { + if (!abort) { + console.warn('Failed to load tenant list', error); + } + } + }; + loadTenants(); + return () => { + abort = true; + }; + }, [state.status, state.token, dispatch]); + const stateValue = useMemo(() => state, [state]); return ( @@ -416,7 +499,11 @@ const AppLayout = () => { try { const { data } = await api.post('/auth/refresh'); if (data?.access_token) { - appDispatch({ type: 'TOKEN_REFRESH_SUCCESS', token: data.access_token }); + appDispatch({ + type: 'TOKEN_REFRESH_SUCCESS', + token: data.access_token, + tenant: data.tenant || null, + }); console.log('[Auth] Access token refreshed at', new Date().toISOString()); return data.access_token; } @@ -4196,10 +4283,10 @@ const AppLayout = () => { appDispatch({ type: 'LOGIN_REQUEST' }); const { data } = await api.post('/auth/login', payload); - if (data?.selection_token && Array.isArray(data?.tenants)) { + if (data?.access_token && Array.isArray(data?.tenants)) { appDispatch({ type: 'TENANT_SELECTION_REQUIRED', - selectionToken: data.selection_token, + selectionToken: data.access_token, tenants: data.tenants, }); setStatusMessage('Select a tenant to continue.', 'info'); @@ -4210,7 +4297,11 @@ const AppLayout = () => { throw new Error('Invalid login response.'); } - appDispatch({ type: 'LOGIN_SUCCESS', token: data.access_token }); + appDispatch({ + type: 'LOGIN_SUCCESS', + token: data.access_token, + tenant: data.tenant || null, + }); setStatusMessage('Login successful.', 'success'); } catch (error) { const message = error?.response?.data?.error || 'Login failed. Check credentials.'; @@ -5322,10 +5413,10 @@ const LoginRoute = () => { try { appDispatch({ type: 'LOGIN_REQUEST' }); const { data } = await api.post('/auth/login', payload); - if (data?.selection_token && Array.isArray(data?.tenants)) { + if (data?.access_token && Array.isArray(data?.tenants)) { appDispatch({ type: 'TENANT_SELECTION_REQUIRED', - selectionToken: data.selection_token, + selectionToken: data.access_token, tenants: data.tenants, }); setStatusMessage('Select a tenant to continue.', 'info'); @@ -5336,7 +5427,11 @@ const LoginRoute = () => { throw new Error('Invalid login response.'); } - appDispatch({ type: 'LOGIN_SUCCESS', token: data.access_token }); + appDispatch({ + type: 'LOGIN_SUCCESS', + token: data.access_token, + tenant: data.tenant || null, + }); setStatusMessage('Login successful.', 'success'); } catch (error) { const message = error?.response?.data?.error || 'Login failed. Check credentials.'; @@ -5369,7 +5464,11 @@ const LoginRoute = () => { throw new Error('Invalid tenant selection response.'); } - appDispatch({ type: 'LOGIN_SUCCESS', token: data.access_token }); + appDispatch({ + type: 'LOGIN_SUCCESS', + token: data.access_token, + tenant: data.tenant || null, + }); setStatusMessage('Login successful.', 'success'); } catch (error) { const message = error?.response?.data?.error || 'Failed to finalize login.';