From 2a08fe83aefdf9f60b7187a3ae80877d073f9ee9 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sun, 19 Jul 2026 16:06:28 +0200 Subject: [PATCH] feat(user): admin can promote external user + security on deletion promotion by admin of external user into internal possible deletion of a user request admin to enter it's email, this is to prevent any miss click --- frontend/src/lib/api/endpoints/admin.ts | 14 + frontend/src/routes/admin/+page.svelte | 327 ++++++++++++++---- frontend/static/locales/ar.json | 9 +- frontend/static/locales/de.json | 9 +- frontend/static/locales/en.json | 9 +- frontend/static/locales/es.json | 9 +- frontend/static/locales/fa.json | 9 +- frontend/static/locales/fr.json | 9 +- frontend/static/locales/hi.json | 9 +- frontend/static/locales/it.json | 9 +- frontend/static/locales/ja.json | 9 +- frontend/static/locales/ko.json | 9 +- frontend/static/locales/nl.json | 9 +- frontend/static/locales/pl.json | 9 +- frontend/static/locales/pt.json | 9 +- frontend/static/locales/ru.json | 9 +- frontend/static/locales/zh-TW.json | 9 +- frontend/static/locales/zh.json | 9 +- .../services/auth_application_service.rs | 111 ++++++ src/interfaces/api/handlers/admin_handler.rs | 47 +++ 20 files changed, 559 insertions(+), 84 deletions(-) diff --git a/frontend/src/lib/api/endpoints/admin.ts b/frontend/src/lib/api/endpoints/admin.ts index 955b9b93..8b7cea4f 100644 --- a/frontend/src/lib/api/endpoints/admin.ts +++ b/frontend/src/lib/api/endpoints/admin.ts @@ -318,6 +318,20 @@ export function deleteUser(userId: string): Promise { return mutate(`/api/admin/users/${userId}`, 'DELETE'); } +/** + * Promote a currently-external (grant-only) user to an internal + * account. The deployment must have magic-link login enabled — the + * admin doesn't set the target's password, so the promoted user + * needs some way to log in. Backend refuses with: + * * 400 — magic-link disabled deployment-wide + * * 403 — target is OIDC-linked + * * 404 — user not found + * * 409 — user is already internal + */ +export function promoteUserToInternal(userId: string): Promise { + return mutate(`/api/admin/users/${userId}/promote-to-internal`, 'POST'); +} + // ── Dashboard ─────────────────────────────────────────────────────────── export interface AdminDashboard { diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index bc58d719..f2436c34 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -21,6 +21,7 @@ migrationAction, reextractAudioMetadata, reextractPhotoMetadata, + promoteUserToInternal, resetUserPassword, saveOidc, savePluginRetention, @@ -130,6 +131,41 @@ confirmState = null; } + /* ── User-delete confirm modal ── + Destructive-action guard: the admin must re-type the target + user's email address to enable the Delete button. Prevents + fat-finger deletion — a single accidental click on the wrong + row won't wipe an account. The admin still bears final + responsibility; this is UX friction, not authorization. */ + let deleteUserModal = $state<{ userId: string; username: string; email: string } | null>(null); + let deleteUserEmailInput = $state(''); + let deleteUserBusy = $state(false); + const deleteUserEmailMatches = $derived( + deleteUserModal !== null && + deleteUserEmailInput.trim().toLowerCase() === deleteUserModal.email.toLowerCase() + ); + function openDeleteUser(u: User) { + deleteUserModal = { + userId: u.id, + username: u.username || u.email, + email: u.email + }; + deleteUserEmailInput = ''; + } + async function confirmDeleteUser() { + if (!deleteUserModal || !deleteUserEmailMatches) return; + deleteUserBusy = true; + try { + await deleteUser(deleteUserModal.userId); + deleteUserModal = null; + await loadUsers(); + } catch (e) { + reportError(e); + } finally { + deleteUserBusy = false; + } + } + type Tab = 'dashboard' | 'users' | 'drives' | 'plugins' | 'oidc' | 'storage' | 'smtp'; let tab = $state('dashboard'); @@ -818,16 +854,29 @@ } } - async function removeUser(u: User) { + function removeUser(u: User) { if (isSelf(u)) return; + openDeleteUser(u); + } + + // External → internal promotion. Confirms first because the mutation + // provisions a home drive + flips the is_external flag; irreversible + // via the admin UI (there's no demote endpoint on purpose). Backend + // refuses when magic-link login is disabled — surfaced as a toast. + async function promoteExternal(u: User) { + if (!u.is_external) return; if ( !(await showConfirm( - t('admin.confirm_delete_user', { name: u.username || u.email }, 'Delete user {{name}}?') + t( + 'admin.confirm_promote_user', + { name: u.username || u.email }, + 'Promote {{name}} to an internal user? This provisions a home drive and gives the account a normal storage envelope. The account keeps its identity; magic-link login stays the way in unless a password is set later.' + ) )) ) return; try { - await deleteUser(u.id); + await promoteUserToInternal(u.id); await loadUsers(); } catch (e) { reportError(e); @@ -2257,78 +2306,120 @@ -
-
-
70} - class:quota-fill--danger={pct > 90} - style:width="{Math.min(pct, 100)}%" - >
+ {#if u.is_external} + + — + {:else} +
+
+
70} + class:quota-fill--danger={pct > 90} + style:width="{Math.min(pct, 100)}%" + >
+
+ + {formatBytes(u.storage_used_bytes)} / {u.storage_quota_bytes > 0 + ? formatBytes(u.storage_quota_bytes) + : '∞'} +
- - {formatBytes(u.storage_used_bytes)} / {u.storage_quota_bytes > 0 - ? formatBytes(u.storage_quota_bytes) - : '∞'} - -
+ {/if} {timeAgo(u.last_login_at)} - - - {#if !isOidcUser(u)} + + +
+ + {#if u.is_external} + + {:else} + + {/if} + + {#if !isOidcUser(u) && !u.is_external} + + {:else} + + {/if} + - {/if} - - - + + + + +
{/each} @@ -3070,6 +3161,75 @@ {/snippet} + + (deleteUserModal = null)} +> + {#if deleteUserModal} +
{ + e.preventDefault(); + void confirmDeleteUser(); + }} + > +

+ {t( + 'admin.delete_user_warning', + { name: deleteUserModal.username }, + 'You are about to permanently delete "{{name}}". This will remove the account, revoke every session, and reap the personal drive. This cannot be undone.' + )} +

+ +
+ {/if} + {#snippet footer()} + + + {/snippet} +
+ `, `target_id = `. + pub async fn admin_promote_external_to_internal( + &self, + admin_id: Uuid, + target_id: Uuid, + ) -> Result { + let mut user = self.user_storage.get_user_by_id(target_id).await?; + + if !user.is_external() { + tracing::info!( + target: "audit", + event = "user.promote_rejected", + reason = "already_internal", + by = %admin_id, + target_id = %target_id, + "👮🏻‍♂️ admin-promote refused: target user is already internal", + ); + return Err(DomainError::new( + ErrorKind::Conflict, + "User", + "Account is already internal", + )); + } + + if user.is_oidc_user() { + tracing::info!( + target: "audit", + event = "user.promote_rejected", + reason = "oidc_user", + by = %admin_id, + target_id = %target_id, + "👮🏻‍♂️ admin-promote refused: OIDC-linked user is managed by the IdP", + ); + return Err(DomainError::new( + ErrorKind::AccessDenied, + "User", + "SSO/OIDC accounts are managed by your identity provider", + )); + } + + // Admin can't set a password on the target's behalf, so the + // upgraded account MUST have magic-link login available on the + // deployment — otherwise no login path exists post-promotion. + if !self.is_magic_link_login_allowed() { + tracing::info!( + target: "audit", + event = "user.promote_rejected", + reason = "no_login_path", + by = %admin_id, + target_id = %target_id, + "👮🏻‍♂️ admin-promote refused: magic-link login disabled and admin can't set the target's password", + ); + return Err(DomainError::new( + ErrorKind::InvalidInput, + "User", + "Cannot promote: magic-link login is disabled on this deployment, so the user would have no login path.", + )); + } + + let quota = self.capped_quota(&UserRole::User); + + user.promote_to_internal(None, quota).map_err(|e| { + DomainError::new( + ErrorKind::Conflict, + "User", + format!("Promote refused: {}", e), + ) + })?; + + let updated = self.user_storage.update_user(user).await?; + + // Invalidate the target's flags cache — same reason as the + // self-upgrade path. + self.user_flags_cache.invalidate(&target_id).await; + + if let Some(lc) = &self.user_lifecycle { + lc.dispatch_upgraded_to_internal(&updated).await; + } + + tracing::info!( + target: "audit", + event = "user.promoted_to_internal_by_admin", + by = %admin_id, + target_id = %target_id, + "👮🏻‍♂️ external user promoted to internal by admin", + ); + + Ok(UserDto::from(updated)) + } + pub async fn change_password( &self, user_id: Uuid, diff --git a/src/interfaces/api/handlers/admin_handler.rs b/src/interfaces/api/handlers/admin_handler.rs index 5e3bdfb2..c1881e91 100644 --- a/src/interfaces/api/handlers/admin_handler.rs +++ b/src/interfaces/api/handlers/admin_handler.rs @@ -11,6 +11,7 @@ use axum::{ use crate::application::dtos::drive_dto::DriveDto; use crate::application::dtos::grant_dto::{GrantDto, RoleDto, SubjectDto, SubjectTypeDto}; +use crate::application::dtos::user_dto::UserDto; use crate::application::dtos::plugin_dto::{ PluginInfoDto, PluginLogEntryDto, PluginLogPageDto, PluginLogQueryDto, PluginRetentionDto, SetEnabledDto, @@ -68,6 +69,10 @@ pub fn admin_routes() -> Router> { .route("/users/{id}/active", put(update_user_active)) .route("/users/{id}/quota", put(update_user_quota)) .route("/users/{id}/password", put(reset_user_password)) + .route( + "/users/{id}/promote-to-internal", + post(admin_promote_external_to_internal), + ) // Registration control .route("/settings/registration", put(set_registration_setting)) // Audio metadata @@ -1095,6 +1100,48 @@ pub async fn reset_user_password( )) } +/// POST /api/admin/users/{id}/promote-to-internal — flip an external +/// (grant-only) account into a normal internal account, provisioning +/// its personal drive on the way. The deployment MUST have magic-link +/// login enabled (the admin doesn't set the user's password on their +/// behalf, so the promoted user needs some way to log in). Refuses +/// OIDC-linked users and users who are already internal. +#[utoipa::path( + post, + path = "/api/admin/users/{id}/promote-to-internal", + params(("id" = String, Path, description = "Target user id")), + responses( + (status = 200, description = "User promoted", body = UserDto), + (status = 400, description = "Magic-link login is disabled on this deployment"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Admin required (or target is OIDC-linked)"), + (status = 404, description = "User not found"), + (status = 409, description = "User is already internal"), + ), + security(("bearerAuth" = [])), + tag = "admin" +)] +pub async fn admin_promote_external_to_internal( + State(state): State>, + auth_user: AuthUser, + Path(id): Path, +) -> Result { + let target_id = Uuid::parse_str(&id).map_err(|_| AppError::bad_request("Invalid UUID"))?; + + let auth = state + .auth_service + .as_ref() + .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; + + let dto = auth + .auth_application_service + .admin_promote_external_to_internal(auth_user.id, target_id) + .await + .map_err(AppError::from)?; + + Ok((StatusCode::OK, Json(dto))) +} + // ============================================================================ // Registration Control // ============================================================================