From f331dbf0ee4873ddecc81280c721e8bb71bd71f7 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Tue, 14 Jul 2026 04:12:48 +0200 Subject: [PATCH 1/2] feat(account): upgrade external to internal --- frontend/src/lib/api/endpoints/auth.ts | 38 +++ .../src/routes/shared-with-me/+page.svelte | 84 +++++++ frontend/src/routes/upgrade/+page.svelte | 205 ++++++++++++++++ src/application/dtos/user_dto.rs | 17 ++ src/application/ports/user_lifecycle.rs | 28 +++ .../services/auth_application_service.rs | 144 +++++++++++- src/application/services/folder_service.rs | 11 + .../services/user_lifecycle_service.rs | 35 +++ src/domain/entities/entity_errors.rs | 4 + src/domain/entities/user.rs | 41 ++++ .../repositories/pg/user_pg_repository.rs | 15 +- src/interfaces/api/handlers/auth_handler.rs | 116 ++++++++- tests/api/auth_upgrade_to_internal.hurl | 221 ++++++++++++++++++ tests/api/run.sh | 1 + 14 files changed, 957 insertions(+), 3 deletions(-) create mode 100644 frontend/src/routes/upgrade/+page.svelte create mode 100644 tests/api/auth_upgrade_to_internal.hurl diff --git a/frontend/src/lib/api/endpoints/auth.ts b/frontend/src/lib/api/endpoints/auth.ts index d5e7cdc0..c4e9cc9f 100644 --- a/frontend/src/lib/api/endpoints/auth.ts +++ b/frontend/src/lib/api/endpoints/auth.ts @@ -199,6 +199,44 @@ export async function register(email: string, password?: string, username?: stri } } +/** + * Convert the authenticated external user into a full internal account. + * Server flips `is_external` to false, provisions a personal drive via + * the lifecycle hook, and returns the updated `User`. + * + * Password is optional — see backend `UpgradeToInternalDto`: + * * If the deployment offers magic-link login, blank password is + * accepted (user remains magic-link-only after upgrade). + * * Otherwise a password is required — the backend refuses with 400 + * `error_type = "PasswordRequired"` and the SPA surfaces the + * server message. + * + * Uses `apiFetch` (unlike register/login) because the caller IS + * authenticated; a 401 here IS a genuine "session expired" and the + * refresh interceptor is the right response. + */ +export async function upgradeToInternal(password?: string): Promise { + const body: Record = {}; + if (password) body.password = password; + const res = await apiFetch('/api/auth/upgrade-to-internal', { + method: 'POST', + credentials: 'same-origin', + headers: { ...JSON_HEADERS, ...getCsrfHeaders() }, + body: JSON.stringify(body) + }); + if (!res.ok) { + const { errorType, message } = await parseErrorBody(res); + throw new ApiError( + res.status, + res.statusText, + '/api/auth/upgrade-to-internal', + errorType, + message + ); + } + return (await res.json()) as User; +} + export type MagicLinkResult = 'sent' | 'unavailable'; /** diff --git a/frontend/src/routes/shared-with-me/+page.svelte b/frontend/src/routes/shared-with-me/+page.svelte index 32c4d142..b57c17d6 100644 --- a/frontend/src/routes/shared-with-me/+page.svelte +++ b/frontend/src/routes/shared-with-me/+page.svelte @@ -13,6 +13,14 @@ type ResourceEntry } from '$lib/components/ResourceList.svelte'; import { t } from '$lib/i18n/index.svelte'; + import { session } from '$lib/stores/session.svelte'; + + // External users landing here are the natural audience for the + // "upgrade to a full account" prompt — they don't own a drive of + // their own, this view IS their entry point. Internal users don't + // see the banner even when their /shared-with-me happens to be + // non-empty (they already have a drive; nothing to upgrade). + const showUpgradeBanner = $derived(session.isExternalUser); let raw = $state([]); let cursor = $state(undefined); @@ -137,6 +145,27 @@ {t('nav.shared_with_me', 'Shared with me')} · OxiCloud +{#if showUpgradeBanner} +
+
+ {t('upgrade.banner_title', 'Get your own storage')} + {t( + 'upgrade.banner_body', + "You're using a guest account. Upgrade to get a personal drive and start uploading files." + )} +
+ + {t('upgrade.banner_cta', 'Upgrade')} + +
+{/if} + {/if} + + diff --git a/frontend/src/routes/upgrade/+page.svelte b/frontend/src/routes/upgrade/+page.svelte new file mode 100644 index 00000000..02151c6a --- /dev/null +++ b/frontend/src/routes/upgrade/+page.svelte @@ -0,0 +1,205 @@ + + + + {t('upgrade.title', 'Upgrade to a full account')} + + +
+
+ + +

{t('upgrade.title', 'Upgrade to a full account')}

+

+ {t( + 'upgrade.lede', + 'Get your own storage and start uploading files. Your existing shares stay untouched.' + )} +

+ + {#if successHint} +
{successHint}
+ {/if} + {#if error} + + {/if} + +
+
+ +
+ +
+
+ + {#if password.length > 0} +
+ +
+ +
+ {#if matchState} +
+ {matchState === 'ok' + ? t('auth.passwords_match', 'Passwords match') + : t('auth.passwords_mismatch', "Passwords don't match")} +
+ {/if} +
+ {/if} + + +
+ +
+ +
+
+
diff --git a/src/application/dtos/user_dto.rs b/src/application/dtos/user_dto.rs index 7e8c2b53..28f3b2b8 100644 --- a/src/application/dtos/user_dto.rs +++ b/src/application/dtos/user_dto.rs @@ -229,6 +229,23 @@ pub struct RefreshTokenDto { pub refresh_token: String, } +/// Body for `POST /api/auth/upgrade-to-internal`. Converts an +/// authenticated external user into an internal user with their own +/// personal drive. +/// +/// `password` is optional — semantics decided per deployment: +/// * If `magic_link` is in `OXICLOUD_AUTH_METHODS` (and OIDC isn't +/// enabled) → password can be omitted; user remains magic-link-only +/// for login after upgrade. +/// * Otherwise → password is required; refusal returns 400 +/// `error_type = "PasswordRequired"`. Without it the upgraded user +/// would have no login path. +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct UpgradeToInternalDto { + #[serde(default)] + pub password: Option, +} + /// Authenticated current user data (for use in application services) #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] pub struct CurrentUser { diff --git a/src/application/ports/user_lifecycle.rs b/src/application/ports/user_lifecycle.rs index e927a3cf..de92a46d 100644 --- a/src/application/ports/user_lifecycle.rs +++ b/src/application/ports/user_lifecycle.rs @@ -193,4 +193,32 @@ pub trait UserLifecycleHook: Send + Sync { mode: DeletionMode, tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, ) -> Result<(), DomainError>; + + /// Fires after `AuthApplicationService::upgrade_to_internal` + /// successfully persists `is_external = false` on the user row — + /// the external → internal conversion path. The `user` argument + /// reflects the POST-upgrade state (`is_external() == false`, + /// `storage_quota_bytes > 0`, `password_hash` maybe stamped). + /// + /// Load-bearing implementations: + /// * `PersonalDriveLifecycleHook` → provisions the home drive + /// (would have short-circuited on `on_user_created` because + /// the user was external at creation). + /// * `AuditLifecycleHook` → emits `event="auth.user_upgraded"`. + /// + /// Default: no-op. Hooks that don't care about upgrade don't need + /// to opt in — this keeps the trait extension backwards-compatible + /// with existing implementations. Do NOT reuse `on_user_created` + /// for this event: hooks that observe `last_login_at().is_none()` + /// as "first ever" or that clean up magic-link tokens + /// (`ExternalIdentityLifecycleHook`) would mis-fire. + /// + /// Idempotency: fires exactly once per successful upgrade transition + /// (guarded by `is_external` toggling). A retried upgrade after a + /// crash would hit the `AlreadyInternal` guard in the service and + /// this hook wouldn't fire again — so hooks may assume "first + /// upgrade" semantics. + async fn on_upgraded_to_internal(&self, _user: &User) -> Result<(), DomainError> { + Ok(()) + } } diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index d6cf8da4..94f5fb36 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -1,5 +1,6 @@ use crate::application::dtos::user_dto::{ - AuthResponseDto, ChangePasswordDto, LoginDto, RefreshTokenDto, RegisterDto, UserDto, + AuthResponseDto, ChangePasswordDto, LoginDto, RefreshTokenDto, RegisterDto, + UpgradeToInternalDto, UserDto, }; use crate::application::ports::auth_ports::{ OidcIdClaims, OidcServicePort, PasswordHasherPort, SessionStoragePort, TokenServicePort, @@ -1233,6 +1234,147 @@ impl AuthApplicationService { Ok(revoked_count) } + /// External → internal account upgrade. + /// + /// Contract: + /// * Caller must be authenticated as the user being upgraded. + /// Session-elevation is not required — being logged in as + /// yourself IS the proof of intent. + /// * User must be `is_external = true` — else the entity refuses + /// with `UserError::AlreadyInternal`, surfaced as `error_type = + /// "AlreadyInternal"` (409). + /// * OIDC-linked users are refused (the IdP owns their identity). + /// * If `dto.password` is `None`, the deployment MUST have magic- + /// link login enabled — otherwise the upgraded user would have + /// no login path. Refused with `error_type = "PasswordRequired"` + /// (400) in that case. + /// * Domain-allowlist check lives at the HANDLER layer, mirroring + /// the register handler — the service doesn't hold that config. + /// + /// On success: + /// * User's `is_external` flipped to `false`. + /// * `password_hash` set from the provided password (Argon2id) or + /// left as-is (magic-link-only upgrade). + /// * `storage_quota_bytes` set to the default user quota (capped + /// by disk). + /// * `PersonalDriveLifecycleHook::on_upgraded_to_internal` runs and + /// provisions the home drive + root folder + owner grant via the + /// atomic CTE. Failure at this step is logged but the row update + /// stands — the next login's `on_user_login` safety-net retries + /// provisioning. + /// * `user_flags_cache` invalidated eagerly so per-request guards + /// (WebDAV / CalDAV / CardDAV) observe the new `is_external` + /// within cache-round-trip time, not the 30-second TTL. + /// * Audit log emits `event="user.upgraded_to_internal"` via the + /// `AuditLifecycleHook` on the dispatched event. + pub async fn upgrade_to_internal( + &self, + caller_id: Uuid, + dto: UpgradeToInternalDto, + ) -> Result { + let mut user = self.user_storage.get_user_by_id(caller_id).await?; + + // Precondition: caller is currently external. Fast-path 409 so + // the audit log carries a clear reason before the entity's own + // guard fires. + if !user.is_external() { + tracing::info!( + target: "audit", + event = "user.upgrade_rejected", + reason = "already_internal", + user_id = %user.id(), + username = %user.display_for_audit(), + "👮🏻‍♂️ upgrade refused: user is already internal", + ); + return Err(DomainError::new( + ErrorKind::Conflict, + "User", + "Account is already internal", + )); + } + + // OIDC-linked: never. The IdP owns identity and role. + if user.is_oidc_user() { + tracing::info!( + target: "audit", + event = "user.upgrade_rejected", + reason = "oidc_user", + user_id = %user.id(), + "👮🏻‍♂️ upgrade 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", + )); + } + + // Password policy composite: + // * Provided → validate + hash. + // * Omitted → only accepted when magic-link login is on + // for this deployment (otherwise no login path post-upgrade). + let password_hash = match dto.password.as_deref() { + Some(pw) if !pw.is_empty() => { + if pw.len() < 8 { + return Err(DomainError::new( + ErrorKind::InvalidInput, + "User", + "Password must be at least 8 characters long", + )); + } + Some(self.password_hasher.hash_password(pw).await?) + } + _ => { + if !self.is_magic_link_login_allowed() { + tracing::info!( + target: "audit", + event = "user.upgrade_rejected", + reason = "password_required", + user_id = %user.id(), + "👮🏻‍♂️ upgrade refused: password omitted but magic-link login is not available on this deployment", + ); + return Err(DomainError::new( + ErrorKind::InvalidInput, + "User", + "Password is required — magic-link login is not enabled on this deployment", + )); + } + None + } + }; + + // Quota policy: same as a fresh regular-user signup. + let quota = self.capped_quota(&UserRole::User); + + user.promote_to_internal(password_hash, quota) + .map_err(|e| { + // The entity refuses `AlreadyInternal` here belt-and-braces + // against a race with a concurrent upgrade; the pre-check + // above already covers the intended path. + DomainError::new( + ErrorKind::Conflict, + "User", + format!("Upgrade refused: {}", e), + ) + })?; + + let updated = self.user_storage.update_user(user).await?; + + // Invalidate the flags cache so subsequent per-request guards + // observe the new `is_external=false` without waiting for the + // 30-second TTL. Same pattern as `change_user_role`. + self.user_flags_cache.invalidate(&caller_id); + + // Dispatch — home-drive provisioning happens here. Log-and- + // continue: a provisioning failure leaves the row updated and + // the next login's safety-net (`on_user_login`) retries. + if let Some(lc) = &self.user_lifecycle { + lc.dispatch_upgraded_to_internal(&updated).await; + } + + Ok(UserDto::from(updated)) + } + pub async fn change_password( &self, user_id: Uuid, diff --git a/src/application/services/folder_service.rs b/src/application/services/folder_service.rs index a1add88f..0073a346 100644 --- a/src/application/services/folder_service.rs +++ b/src/application/services/folder_service.rs @@ -962,6 +962,17 @@ impl UserLifecycleHook for PersonalDriveLifecycleHook { self.provision_if_needed(user).await } + /// External → internal upgrade. `on_user_created` fired at signup + /// with `is_external=true` and short-circuited in + /// `provision_if_needed`. The user is now internal — same helper + /// runs, but this time the `is_external` guard passes through and + /// the atomic CTE creates their default drive + root folder + + /// owner grant. Idempotent by construction: a rerun after a partial + /// failure hits the `find_default_for_user` short-circuit. + async fn on_upgraded_to_internal(&self, user: &User) -> Result<(), DomainError> { + self.provision_if_needed(user).await + } + async fn on_user_logout(&self, _user: &User, _reason: LogoutReason) -> Result<(), DomainError> { // Drives don't react to logout. Explicit no-op per the // "no defaults" convention. diff --git a/src/application/services/user_lifecycle_service.rs b/src/application/services/user_lifecycle_service.rs index de1db8b4..e47ff81c 100644 --- a/src/application/services/user_lifecycle_service.rs +++ b/src/application/services/user_lifecycle_service.rs @@ -62,6 +62,28 @@ impl UserLifecycleService { } } + /// Upgraded: log-and-continue. Called by + /// `AuthApplicationService::upgrade_to_internal` after the + /// `is_external = false` UPDATE persists. Same log-and-continue + /// semantics as `dispatch_created` — the row is already updated, + /// hook failure at (e.g.) home-drive provisioning is recoverable + /// on the next login via `PersonalDriveLifecycleHook::on_user_login` + /// (its safety-net path already handles the "user is internal but + /// no drive yet" case idempotently). + pub async fn dispatch_upgraded_to_internal(&self, user: &User) { + for h in &self.hooks { + if let Err(e) = h.on_upgraded_to_internal(user).await { + tracing::error!( + target: "user_lifecycle", + hook = h.name(), + user_id = %user.id(), + error = %e, + "on_upgraded_to_internal failed; drive provisioning will retry on next login" + ); + } + } + } + /// Login: log-and-continue. Same reasoning as `dispatch_created`. /// Must fire BEFORE `user.register_login()` so that hooks observing /// `last_login_at().is_none()` correctly detect the first-ever login. @@ -199,6 +221,19 @@ impl UserLifecycleHook for AuditLifecycleHook { ); Ok(()) } + + async fn on_upgraded_to_internal(&self, user: &User) -> Result<(), DomainError> { + // Post-upgrade state — `is_external` is already `false` here + // (the service persisted before dispatching), so we don't log + // it as a field; the event name carries the transition. + tracing::info!( + target: "audit", + event = "user.upgraded_to_internal", + user_id = %user.id(), + username = %user.display_for_audit(), + ); + Ok(()) + } } // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/domain/entities/entity_errors.rs b/src/domain/entities/entity_errors.rs index 96368558..213582f1 100644 --- a/src/domain/entities/entity_errors.rs +++ b/src/domain/entities/entity_errors.rs @@ -79,6 +79,9 @@ pub enum UserError { ValidationError(String), /// Authentication error AuthenticationError(String), + /// Upgrade path: the user is already internal — cannot re-upgrade. + /// Surfaced by the service as `error_type = "AlreadyInternal"`. + AlreadyInternal, } impl Display for UserError { @@ -88,6 +91,7 @@ impl Display for UserError { UserError::InvalidPassword(msg) => write!(f, "Invalid password: {}", msg), UserError::ValidationError(msg) => write!(f, "Validation error: {}", msg), UserError::AuthenticationError(msg) => write!(f, "Authentication error: {}", msg), + UserError::AlreadyInternal => write!(f, "User is already an internal account"), } } } diff --git a/src/domain/entities/user.rs b/src/domain/entities/user.rs index e44a70d8..f4e77e96 100644 --- a/src/domain/entities/user.rs +++ b/src/domain/entities/user.rs @@ -512,6 +512,47 @@ impl User { } } + /// Promote a currently-external user to an internal account. + /// Atomically flips the invariant-linked fields: + /// * `is_external` → false + /// * `password_hash` → provided (Some) or preserved (None) + /// * `storage_quota_bytes` → quota (external users had 0; DB CHECK + /// `users_external_no_storage` enforces the pair before this call + /// and would refuse a non-zero quota on an external row — the + /// write MUST flip `is_external` first, which happens + /// transactionally at persist time via the sqlx UPDATE). + /// + /// Password is `Option` because the service allows password- + /// less upgrades when magic-link login is available on the + /// deployment. When `None`, `password_hash` stays as it was (either + /// NULL, or a hash left over from an admin-created invitation — + /// externals don't authenticate with it either way). + /// + /// Refuses if the caller is already internal — the upgrade path + /// only makes sense on `is_external = true` users. Service pre- + /// checks `user.is_external()` before calling; this guard is + /// belt-and-braces against a race. + /// + /// Admin combo is impossible by construction: external + admin was + /// refused at creation (see `User::new`), so a promoted external + /// user always retains their `UserRole::User` — role isn't changed. + pub fn promote_to_internal( + &mut self, + password_hash: Option, + storage_quota_bytes: i64, + ) -> UserResult<()> { + if !self.is_external { + return Err(UserError::AlreadyInternal); + } + self.is_external = false; + if let Some(hash) = password_hash { + self.password_hash = Some(hash); + } + self.storage_quota_bytes = storage_quota_bytes; + self.updated_at = Utc::now(); + Ok(()) + } + pub fn set_image(&mut self, image: Option) { self.image = image; self.updated_at = Utc::now(); diff --git a/src/infrastructure/repositories/pg/user_pg_repository.rs b/src/infrastructure/repositories/pg/user_pg_repository.rs index 2a18a771..5fa36c5c 100644 --- a/src/infrastructure/repositories/pg/user_pg_repository.rs +++ b/src/infrastructure/repositories/pg/user_pg_repository.rs @@ -472,7 +472,19 @@ impl UserRepository for UserPgRepository { family_name = $13, email_verified_at = $14, preferred_locale = $15, - notify_on_share = $16 + notify_on_share = $16, + -- Include `is_external` so the external → + -- internal upgrade path + -- (`AuthApplicationService::upgrade_to_internal`) + -- can flip this flag. Previously omitted + -- because no code path mutated it after + -- creation. The DB CHECK + -- `users_external_no_storage` + -- (`is_external=false OR quota=0`) is + -- satisfied by the upgrade because it + -- writes both fields in the same UPDATE: + -- `is_external=false, quota>0`. + is_external = $17 WHERE id = $1 "#, ) @@ -492,6 +504,7 @@ impl UserRepository for UserPgRepository { .bind(user_clone.email_verified_at()) .bind(user_clone.preferred_locale()) .bind(user_clone.notify_on_share()) + .bind(user_clone.is_external()) .execute(&mut **tx) .await .map_err(Self::map_sqlx_error)?; diff --git a/src/interfaces/api/handlers/auth_handler.rs b/src/interfaces/api/handlers/auth_handler.rs index b2878a98..f47ecdf4 100644 --- a/src/interfaces/api/handlers/auth_handler.rs +++ b/src/interfaces/api/handlers/auth_handler.rs @@ -12,7 +12,8 @@ use uuid::Uuid; use crate::application::dtos::user_dto::{ AuthResponseDto, ChangePasswordDto, LoginDto, OidcCallbackQueryDto, OidcExchangeDto, - OidcProviderInfoDto, RefreshTokenDto, RegisterDto, SetupAdminDto, UserDto, + OidcProviderInfoDto, RefreshTokenDto, RegisterDto, SetupAdminDto, UpgradeToInternalDto, + UserDto, }; use crate::application::services::auth_application_service::{OidcCallbackResult, RegisterResult}; use crate::common::di::AppState; @@ -45,6 +46,7 @@ pub fn auth_protected_routes() -> Router> { .route("/me/image", put(update_user_image)) .route("/me/profile", patch(update_profile)) .route("/change-password", put(change_password)) + .route("/upgrade-to-internal", post(upgrade_to_internal)) .route("/logout", post(logout)) } @@ -639,6 +641,118 @@ pub async fn change_password( Ok(StatusCode::OK) } +/// Convert the authenticated external user into a full internal +/// account. The caller must currently be `is_external = true`; on +/// success, `is_external` is flipped to `false`, a personal drive is +/// provisioned (atomic CTE via `PersonalDriveLifecycleHook`), and the +/// user's flags cache is invalidated so subsequent per-request guards +/// see the new state within cache-round-trip time. +/// +/// Password policy: +/// * If the deployment offers magic-link login +/// (`OXICLOUD_AUTH_METHODS` includes `magic_link` AND OIDC is not +/// enabled AND SMTP is wired), the body's `password` field is +/// optional — an upgraded user without a password stays magic- +/// link-only for login. +/// * Otherwise, `password` is required — refused with 400 +/// `error_type = "PasswordRequired"`. +/// +/// Domain gate: the caller's email domain MUST be in +/// `OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS` (when non-empty). +/// Otherwise invitations would become a bypass of the operator's +/// self-registration policy. Refused with 403 +/// `error_type = "RegistrationDomainNotAllowed"`. +/// +/// Response: the updated `UserDto` (post-upgrade view — `is_external` +/// is false, `storage_quota_bytes` is set). +#[utoipa::path( + post, + path = "/api/auth/upgrade-to-internal", + request_body = UpgradeToInternalDto, + responses( + (status = 200, description = "Upgrade succeeded", body = UserDto), + (status = 400, description = "Password missing / too short"), + (status = 401, description = "Not authenticated"), + (status = 403, description = "OIDC user, or domain not in allowlist"), + (status = 409, description = "Already internal"), + ), + security(("bearerAuth" = [])), + tag = "auth" +)] +pub async fn upgrade_to_internal( + State(state): State>, + CurrentUserId(user_id): CurrentUserId, + Json(dto): Json, +) -> Result { + let auth_service = state + .auth_service + .as_ref() + .ok_or_else(|| AppError::internal_error("Authentication service not configured"))?; + + // Domain gate. Mirrors the register handler + // (`OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS`). Rationale: an + // internal-user invitation must NOT become a way around the + // operator's self-registration policy. If a domain isn't + // allowlisted for register, it shouldn't be allowed for upgrade + // either. External users on non-allowlisted domains remain + // external — they can still act on shared resources but never own + // a drive of their own on this deployment. + let allow_list = &state.core.config.auth.registration_allowed_email_domains; + if !allow_list.is_empty() { + // The service re-fetches the user inside `upgrade_to_internal`; + // one extra id-lookup here just to extract the email is cheap + // and keeps the domain check at the same layer as the register + // handler for consistency. + let email = auth_service + .auth_application_service + .get_user_by_id(user_id) + .await + .map(|dto| dto.email)?; + let domain = email + .split('@') + .nth(1) + .map(|d| d.trim().to_ascii_lowercase()) + .unwrap_or_default(); + if domain.is_empty() || !allow_list.iter().any(|d| d == &domain) { + tracing::info!( + target: "audit", + event = "user.upgrade_rejected", + reason = "domain_not_allowed", + user_id = %user_id, + domain = %domain, + "👮🏻‍♂️ upgrade refused: email domain not in \ + OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS" + ); + return Err(AppError::new( + StatusCode::FORBIDDEN, + "This deployment does not accept new accounts from your email domain.", + "RegistrationDomainNotAllowed", + )); + } + } + + let updated = auth_service + .auth_application_service + .upgrade_to_internal(user_id, dto) + .await + .map_err(|err| match err.message.as_str() { + "Account is already internal" => { + AppError::new(StatusCode::CONFLICT, err.message.clone(), "AlreadyInternal") + } + "SSO/OIDC accounts are managed by your identity provider" => { + AppError::new(StatusCode::FORBIDDEN, err.message.clone(), "ManagedByIdP") + } + m if m.starts_with("Password is required") => AppError::new( + StatusCode::BAD_REQUEST, + err.message.clone(), + "PasswordRequired", + ), + _ => AppError::from(err), + })?; + + Ok((StatusCode::OK, Json(updated))) +} + /// Update the caller's profile (PR 24). /// /// Fields are individually optional — absent = no change. Username is diff --git a/tests/api/auth_upgrade_to_internal.hurl b/tests/api/auth_upgrade_to_internal.hurl new file mode 100644 index 00000000..ff429ec8 --- /dev/null +++ b/tests/api/auth_upgrade_to_internal.hurl @@ -0,0 +1,221 @@ +# ============================================================= +# OxiCloud — external → internal account upgrade +# ============================================================= +# Covers the `POST /api/auth/upgrade-to-internal` endpoint end-to-end: +# admin-creates an external user, external user logs in, calls upgrade, +# lands on an internal account with a personal drive. +# +# Cross-cutting invariants pinned: +# * `is_external` flip is persisted (not just returned). +# * `PersonalDriveLifecycleHook.on_upgraded_to_internal` runs — a new +# default personal drive appears via `/api/drives`. +# * Idempotency: a second upgrade returns 409 `AlreadyInternal`. +# * Domain gate mirrors register: an off-allowlist email is refused +# with 403 `RegistrationDomainNotAllowed`. +# * `OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS` is +# `example.com,example.test` in tests/common/server.env. +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Step 1 — Admin login (needed to admin-create users + reach +# the delete endpoint for cleanup at the end). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "{{username}}", "password": "{{password}}" } + +HTTP 200 +[Captures] +alice_token: jsonpath "$.access_token" + + +# ───────────────────────────────────────────────────────────── +# Step 2 — Admin creates an external user `bob-upgrade` with a +# temp password so this test can log in as him without +# going through the magic-link invitation flow (that +# path is exercised elsewhere in external_users.hurl). +# The temp password is real — admin_create_user hashes +# it even for externals — but bob's `is_external=true` +# means he has no drive yet. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/admin/users +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ + "username": "bob-upgrade", + "email": "bob-upgrade@example.com", + "password": "TempExtPass1!", + "role": "user", + "is_external": true +} + +HTTP 201 +[Captures] +bob_user_id: jsonpath "$.id" + + +# ───────────────────────────────────────────────────────────── +# Step 3 — Bob logs in with the temp password. Baseline: he can +# authenticate. Assert `is_external: true` on the /me +# response so a later /me post-upgrade proves the flip. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "bob-upgrade", "password": "TempExtPass1!" } + +HTTP 200 +[Captures] +bob_token: jsonpath "$.access_token" + +GET {{base_url}}/api/auth/me +Authorization: Bearer {{bob_token}} + +HTTP 200 +[Asserts] +jsonpath "$.is_external" == true +jsonpath "$.storage_quota_bytes" == 0 + + +# ───────────────────────────────────────────────────────────── +# Step 4 — Bob calls upgrade with a NEW password. Response is +# the updated UserDto (is_external=false, quota set). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/upgrade-to-internal +Authorization: Bearer {{bob_token}} +Content-Type: application/json +{ "password": "NewInternalPass1!" } + +HTTP 200 +[Asserts] +jsonpath "$.is_external" == false +jsonpath "$.storage_quota_bytes" > 0 + + +# ───────────────────────────────────────────────────────────── +# Step 5 — /me confirms the flip persisted (not just returned). +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/auth/me +Authorization: Bearer {{bob_token}} + +HTTP 200 +[Asserts] +jsonpath "$.is_external" == false +jsonpath "$.storage_quota_bytes" > 0 + + +# ───────────────────────────────────────────────────────────── +# Step 6 — Bob's NEW password works. Proves the password hash +# was persisted (not just held in memory) and the old +# temp password no longer authenticates. Fetches a +# fresh token so the rest of the test uses a session +# whose JWT claims already reflect the upgrade. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "bob-upgrade", "password": "NewInternalPass1!" } + +HTTP 200 +[Captures] +bob_token_after: jsonpath "$.access_token" + +# Old password no longer works. +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "bob-upgrade", "password": "TempExtPass1!" } + +HTTP 403 + + +# ───────────────────────────────────────────────────────────── +# Step 7 — Drive provisioning. Bob's default personal drive +# shows up on /api/drives. Before upgrade externals +# have none; after upgrade the lifecycle hook created +# exactly one via the atomic CTE. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/drives +Authorization: Bearer {{bob_token_after}} + +HTTP 200 +[Asserts] +jsonpath "$" isCollection +jsonpath "$[0].kind" == "personal" + + +# ───────────────────────────────────────────────────────────── +# Step 8 — Idempotency: a second upgrade returns 409 +# `AlreadyInternal`. The service pre-checks +# `is_external`; the entity's `promote_to_internal` +# has a matching guard. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/upgrade-to-internal +Authorization: Bearer {{bob_token_after}} +Content-Type: application/json +{ "password": "AnotherPass1!" } + +HTTP 409 +[Asserts] +jsonpath "$.error_type" == "AlreadyInternal" + + +# ───────────────────────────────────────────────────────────── +# Step 9 — Domain gate. Create an external user on a domain +# OUTSIDE the allowlist, log in, attempt upgrade, get +# 403 `RegistrationDomainNotAllowed`. Rationale +# documented in the handler: invitations must not +# become a bypass of the operator's registration +# policy. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/admin/users +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ + "username": "carol-offdomain", + "email": "carol@offdomain.invalid", + "password": "TempExtPass1!", + "role": "user", + "is_external": true +} + +HTTP 201 +[Captures] +carol_user_id: jsonpath "$.id" + +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "carol-offdomain", "password": "TempExtPass1!" } + +HTTP 200 +[Captures] +carol_token: jsonpath "$.access_token" + +POST {{base_url}}/api/auth/upgrade-to-internal +Authorization: Bearer {{carol_token}} +Content-Type: application/json +{ "password": "NewInternalPass1!" } + +HTTP 403 +[Asserts] +jsonpath "$.error_type" == "RegistrationDomainNotAllowed" + +# Carol is still external — the refusal didn't half-flip anything. +GET {{base_url}}/api/auth/me +Authorization: Bearer {{carol_token}} + +HTTP 200 +[Asserts] +jsonpath "$.is_external" == true + + +# ───────────────────────────────────────────────────────────── +# Cleanup — admin deletes both test users. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/admin/users/{{bob_user_id}} +Authorization: Bearer {{alice_token}} + +HTTP * + +DELETE {{base_url}}/api/admin/users/{{carol_user_id}} +Authorization: Bearer {{alice_token}} + +HTTP * diff --git a/tests/api/run.sh b/tests/api/run.sh index a6508540..3288ae29 100755 --- a/tests/api/run.sh +++ b/tests/api/run.sh @@ -149,6 +149,7 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test "$API_DIR/user_ui_preferences.hurl" \ "$API_DIR/auth_session_lifecycle.hurl" \ "$API_DIR/auth_magic_link_login.hurl" \ + "$API_DIR/auth_upgrade_to_internal.hurl" \ "$API_DIR/registration.hurl" \ "$API_DIR/nc_status_capabilities.hurl" \ "$API_DIR/nc_login_flow_v2.hurl" \ From 1fa1966fbe3db8fcbcdfa122dd7495b2af0e1c5a Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Tue, 14 Jul 2026 11:40:16 +0200 Subject: [PATCH 2/2] feat(upgrate): add i18n for account upgade --- frontend/static/locales/ar.json | 17 +++++++++++++++++ frontend/static/locales/de.json | 17 +++++++++++++++++ frontend/static/locales/en.json | 17 +++++++++++++++++ frontend/static/locales/es.json | 17 +++++++++++++++++ frontend/static/locales/fa.json | 17 +++++++++++++++++ frontend/static/locales/fr.json | 17 +++++++++++++++++ frontend/static/locales/hi.json | 17 +++++++++++++++++ frontend/static/locales/it.json | 17 +++++++++++++++++ frontend/static/locales/ja.json | 17 +++++++++++++++++ frontend/static/locales/ko.json | 17 +++++++++++++++++ frontend/static/locales/nl.json | 17 +++++++++++++++++ frontend/static/locales/pl.json | 17 +++++++++++++++++ frontend/static/locales/pt.json | 17 +++++++++++++++++ frontend/static/locales/ru.json | 17 +++++++++++++++++ frontend/static/locales/zh-TW.json | 17 +++++++++++++++++ frontend/static/locales/zh.json | 17 +++++++++++++++++ 16 files changed, 272 insertions(+) diff --git a/frontend/static/locales/ar.json b/frontend/static/locales/ar.json index e7507f2d..13aebe8f 100644 --- a/frontend/static/locales/ar.json +++ b/frontend/static/locales/ar.json @@ -1155,5 +1155,22 @@ }, "preferences": { "save_failed": "تعذّر حفظ تفضيلك. حاول مرة أخرى." + }, + "upgrade": { + "title": "الترقية إلى حساب كامل", + "lede": "احصل على مساحة تخزين خاصة بك وابدأ في رفع الملفات. تبقى مشاركاتك الحالية دون تغيير.", + "busy": "جارٍ الترقية…", + "submit": "ترقية حسابي", + "cancel": "ليس الآن — العودة إلى المشارك معي", + "success": "تمت ترقية حسابك. جارٍ التوجيه إلى ملفاتك…", + "error": "فشلت الترقية.", + "password_required": "كلمة المرور مطلوبة — لا يوفر هذا الإصدار تسجيل الدخول عبر رابط بريد إلكتروني.", + "password_too_short": "يجب أن تتكون كلمة المرور من 8 أحرف على الأقل.", + "oidc_user": "تُدار حسابات SSO/OIDC من قبل مزود الهوية الخاص بك. الترقية غير متوفرة.", + "domain_not_allowed": "لا يقبل هذا الإصدار حسابات جديدة من نطاق بريدك الإلكتروني. تواصل مع المسؤول لتفعيله.", + "banner_aria": "دعوة إلى الترقية", + "banner_title": "احصل على مساحة تخزين خاصة بك", + "banner_body": "أنت تستخدم حساب ضيف. قم بالترقية للحصول على قرص شخصي وبدء رفع الملفات.", + "banner_cta": "ترقية" } } diff --git a/frontend/static/locales/de.json b/frontend/static/locales/de.json index 51ea05ee..4ef943db 100644 --- a/frontend/static/locales/de.json +++ b/frontend/static/locales/de.json @@ -1155,5 +1155,22 @@ }, "preferences": { "save_failed": "Ihre Einstellung konnte nicht gespeichert werden. Bitte versuchen Sie es erneut." + }, + "upgrade": { + "title": "Auf vollständiges Konto upgraden", + "lede": "Erhalten Sie Ihren eigenen Speicher und beginnen Sie, Dateien hochzuladen. Ihre bestehenden Freigaben bleiben unverändert.", + "busy": "Upgrade läuft…", + "submit": "Mein Konto upgraden", + "cancel": "Nicht jetzt — zurück zu den Freigaben", + "success": "Ihr Konto wurde upgegradet. Weiterleitung zu Ihren Dateien…", + "error": "Upgrade fehlgeschlagen.", + "password_required": "Ein Passwort ist erforderlich — diese Instanz bietet keine E-Mail-Link-Anmeldung.", + "password_too_short": "Das Passwort muss mindestens 8 Zeichen lang sein.", + "oidc_user": "SSO/OIDC-Konten werden von Ihrem Identitätsanbieter verwaltet. Ein Upgrade ist nicht verfügbar.", + "domain_not_allowed": "Diese Instanz akzeptiert keine neuen Konten von Ihrer E-Mail-Domäne. Wenden Sie sich an den Administrator, um dies zu aktivieren.", + "banner_aria": "Upgrade-Aufforderung", + "banner_title": "Erhalten Sie Ihren eigenen Speicher", + "banner_body": "Sie verwenden ein Gast-Konto. Upgraden Sie, um einen persönlichen Speicher zu erhalten und Dateien hochzuladen.", + "banner_cta": "Upgraden" } } diff --git a/frontend/static/locales/en.json b/frontend/static/locales/en.json index fe4b1c13..8fddb339 100644 --- a/frontend/static/locales/en.json +++ b/frontend/static/locales/en.json @@ -1556,5 +1556,22 @@ }, "preferences": { "save_failed": "Couldn't save your preference. Please try again." + }, + "upgrade": { + "title": "Upgrade to a full account", + "lede": "Get your own storage and start uploading files. Your existing shares stay untouched.", + "busy": "Upgrading…", + "submit": "Upgrade my account", + "cancel": "Not now — back to shared with me", + "success": "Your account has been upgraded. Redirecting to your files…", + "error": "Upgrade failed.", + "password_required": "Password is required — this deployment does not offer email-link login.", + "password_too_short": "Password must be at least 8 characters long.", + "oidc_user": "SSO/OIDC accounts are managed by your identity provider. Upgrade is not available.", + "domain_not_allowed": "This deployment does not accept new accounts from your email domain. Contact the administrator to enable it.", + "banner_aria": "Upgrade prompt", + "banner_title": "Get your own storage", + "banner_body": "You're using a guest account. Upgrade to get a personal drive and start uploading files.", + "banner_cta": "Upgrade" } } diff --git a/frontend/static/locales/es.json b/frontend/static/locales/es.json index 866e95f6..2ba49c5a 100644 --- a/frontend/static/locales/es.json +++ b/frontend/static/locales/es.json @@ -1170,5 +1170,22 @@ }, "preferences": { "save_failed": "No se pudo guardar tu preferencia. Inténtalo de nuevo." + }, + "upgrade": { + "title": "Pasar a una cuenta completa", + "lede": "Consigue tu propio almacenamiento y empieza a subir archivos. Tus recursos compartidos existentes permanecen intactos.", + "busy": "Actualizando…", + "submit": "Actualizar mi cuenta", + "cancel": "Ahora no — volver a compartidos conmigo", + "success": "Tu cuenta ha sido actualizada. Redirigiendo a tus archivos…", + "error": "La actualización falló.", + "password_required": "Se requiere contraseña — esta instancia no ofrece inicio de sesión por enlace de correo.", + "password_too_short": "La contraseña debe tener al menos 8 caracteres.", + "oidc_user": "Las cuentas SSO/OIDC son gestionadas por tu proveedor de identidad. La actualización no está disponible.", + "domain_not_allowed": "Esta instancia no acepta nuevas cuentas desde tu dominio de correo. Contacta al administrador para habilitarlo.", + "banner_aria": "Aviso de actualización", + "banner_title": "Consigue tu propio almacenamiento", + "banner_body": "Estás usando una cuenta invitada. Actualiza para obtener una unidad personal y empezar a subir archivos.", + "banner_cta": "Actualizar" } } diff --git a/frontend/static/locales/fa.json b/frontend/static/locales/fa.json index 2587f273..a0237947 100644 --- a/frontend/static/locales/fa.json +++ b/frontend/static/locales/fa.json @@ -1155,5 +1155,22 @@ }, "preferences": { "save_failed": "ذخیره ترجیح شما ممکن نشد. لطفاً دوباره تلاش کنید." + }, + "upgrade": { + "title": "ارتقا به حساب کامل", + "lede": "فضای ذخیره‌سازی مخصوص خود را دریافت کنید و بارگذاری فایل‌ها را آغاز کنید. اشتراک‌گذاری‌های موجود شما بدون تغییر باقی می‌مانند.", + "busy": "در حال ارتقا…", + "submit": "ارتقای حساب من", + "cancel": "الان نه — بازگشت به به‌اشتراک‌گذاشته‌شده با من", + "success": "حساب شما ارتقا یافت. در حال هدایت به فایل‌های شما…", + "error": "ارتقا ناموفق بود.", + "password_required": "رمز عبور لازم است — این استقرار ورود با لینک ایمیل را ارائه نمی‌دهد.", + "password_too_short": "رمز عبور باید حداقل ۸ کاراکتر باشد.", + "oidc_user": "حساب‌های SSO/OIDC توسط ارائه‌دهنده هویت شما مدیریت می‌شوند. ارتقا در دسترس نیست.", + "domain_not_allowed": "این استقرار حساب‌های جدید از دامنه ایمیل شما را نمی‌پذیرد. برای فعال‌سازی با مدیر تماس بگیرید.", + "banner_aria": "دعوت به ارتقا", + "banner_title": "فضای ذخیره‌سازی مخصوص خود را دریافت کنید", + "banner_body": "شما از حساب مهمان استفاده می‌کنید. برای دریافت درایو شخصی و بارگذاری فایل‌ها ارتقا دهید.", + "banner_cta": "ارتقا" } } diff --git a/frontend/static/locales/fr.json b/frontend/static/locales/fr.json index ae743ffa..21566ec2 100644 --- a/frontend/static/locales/fr.json +++ b/frontend/static/locales/fr.json @@ -1155,5 +1155,22 @@ }, "preferences": { "save_failed": "Impossible d'enregistrer votre préférence. Veuillez réessayer." + }, + "upgrade": { + "title": "Passer à un compte complet", + "lede": "Obtenez votre propre espace de stockage et commencez à téléverser des fichiers. Vos partages existants restent intacts.", + "busy": "Mise à niveau…", + "submit": "Mettre à niveau mon compte", + "cancel": "Pas maintenant — retour aux partages reçus", + "success": "Votre compte a été mis à niveau. Redirection vers vos fichiers…", + "error": "La mise à niveau a échoué.", + "password_required": "Un mot de passe est requis — cette instance ne propose pas la connexion par lien e-mail.", + "password_too_short": "Le mot de passe doit contenir au moins 8 caractères.", + "oidc_user": "Les comptes SSO/OIDC sont gérés par votre fournisseur d'identité. La mise à niveau n'est pas disponible.", + "domain_not_allowed": "Cette instance n'accepte pas de nouveaux comptes depuis votre domaine e-mail. Contactez l'administrateur pour l'activer.", + "banner_aria": "Invitation à mettre à niveau", + "banner_title": "Obtenez votre propre espace", + "banner_body": "Vous utilisez un compte invité. Passez à un compte complet pour obtenir un disque personnel et téléverser des fichiers.", + "banner_cta": "Mettre à niveau" } } diff --git a/frontend/static/locales/hi.json b/frontend/static/locales/hi.json index 1c8ab929..741eb56c 100644 --- a/frontend/static/locales/hi.json +++ b/frontend/static/locales/hi.json @@ -1155,5 +1155,22 @@ }, "preferences": { "save_failed": "आपकी वरीयता सहेजी नहीं जा सकी। कृपया पुनः प्रयास करें।" + }, + "upgrade": { + "title": "पूर्ण खाते में अपग्रेड करें", + "lede": "अपना खुद का स्टोरेज पाएं और फ़ाइलें अपलोड करना शुरू करें। आपके मौजूदा शेयर अछूते रहते हैं।", + "busy": "अपग्रेड हो रहा है…", + "submit": "मेरा खाता अपग्रेड करें", + "cancel": "अभी नहीं — मेरे साथ साझा पर वापस", + "success": "आपका खाता अपग्रेड कर दिया गया। आपकी फ़ाइलों पर पुनर्निर्देशित किया जा रहा है…", + "error": "अपग्रेड विफल रहा।", + "password_required": "पासवर्ड आवश्यक है — यह इंस्टेंस ईमेल-लिंक लॉगिन प्रदान नहीं करता।", + "password_too_short": "पासवर्ड कम से कम 8 अक्षरों का होना चाहिए।", + "oidc_user": "SSO/OIDC खाते आपके पहचान प्रदाता द्वारा प्रबंधित होते हैं। अपग्रेड उपलब्ध नहीं है।", + "domain_not_allowed": "यह इंस्टेंस आपके ईमेल डोमेन से नए खाते स्वीकार नहीं करता। इसे सक्षम करने के लिए व्यवस्थापक से संपर्क करें।", + "banner_aria": "अपग्रेड सूचना", + "banner_title": "अपना खुद का स्टोरेज पाएं", + "banner_body": "आप अतिथि खाते का उपयोग कर रहे हैं। व्यक्तिगत ड्राइव पाने और फ़ाइलें अपलोड करना शुरू करने के लिए अपग्रेड करें।", + "banner_cta": "अपग्रेड" } } diff --git a/frontend/static/locales/it.json b/frontend/static/locales/it.json index c7cea99a..456fae85 100644 --- a/frontend/static/locales/it.json +++ b/frontend/static/locales/it.json @@ -1155,5 +1155,22 @@ }, "preferences": { "save_failed": "Impossibile salvare la preferenza. Riprova." + }, + "upgrade": { + "title": "Passa a un account completo", + "lede": "Ottieni il tuo spazio di archiviazione e inizia a caricare file. Le tue condivisioni esistenti rimangono intatte.", + "busy": "Aggiornamento…", + "submit": "Aggiorna il mio account", + "cancel": "Non ora — torna a condivisi con me", + "success": "Il tuo account è stato aggiornato. Reindirizzamento ai tuoi file…", + "error": "Aggiornamento non riuscito.", + "password_required": "La password è obbligatoria — questa istanza non offre l'accesso tramite link email.", + "password_too_short": "La password deve avere almeno 8 caratteri.", + "oidc_user": "Gli account SSO/OIDC sono gestiti dal tuo provider di identità. L'aggiornamento non è disponibile.", + "domain_not_allowed": "Questa istanza non accetta nuovi account dal tuo dominio email. Contatta l'amministratore per abilitarlo.", + "banner_aria": "Invito all'aggiornamento", + "banner_title": "Ottieni il tuo spazio di archiviazione", + "banner_body": "Stai utilizzando un account ospite. Passa a un account completo per ottenere un'unità personale e caricare file.", + "banner_cta": "Aggiorna" } } diff --git a/frontend/static/locales/ja.json b/frontend/static/locales/ja.json index bd98e479..5eb3d257 100644 --- a/frontend/static/locales/ja.json +++ b/frontend/static/locales/ja.json @@ -1155,5 +1155,22 @@ }, "preferences": { "save_failed": "設定を保存できませんでした。もう一度お試しください。" + }, + "upgrade": { + "title": "フルアカウントにアップグレード", + "lede": "自分専用のストレージを取得して、ファイルのアップロードを始めましょう。既存の共有はそのまま維持されます。", + "busy": "アップグレード中…", + "submit": "アカウントをアップグレード", + "cancel": "今はしない — 共有に戻る", + "success": "アカウントがアップグレードされました。ファイルへリダイレクト中…", + "error": "アップグレードに失敗しました。", + "password_required": "パスワードが必要です — このデプロイメントはメールリンクによるログインを提供していません。", + "password_too_short": "パスワードは8文字以上である必要があります。", + "oidc_user": "SSO/OIDCアカウントはIDプロバイダーによって管理されます。アップグレードは利用できません。", + "domain_not_allowed": "このデプロイメントはあなたのメールドメインからの新規アカウントを受け付けていません。有効化するには管理者にお問い合わせください。", + "banner_aria": "アップグレードの案内", + "banner_title": "自分専用のストレージを取得", + "banner_body": "ゲストアカウントを使用しています。個人用ドライブを取得してファイルをアップロードするにはアップグレードしてください。", + "banner_cta": "アップグレード" } } diff --git a/frontend/static/locales/ko.json b/frontend/static/locales/ko.json index f997b27a..1effd0b0 100644 --- a/frontend/static/locales/ko.json +++ b/frontend/static/locales/ko.json @@ -1552,5 +1552,22 @@ }, "preferences": { "save_failed": "환경설정을 저장할 수 없습니다. 다시 시도하세요." + }, + "upgrade": { + "title": "정식 계정으로 업그레이드", + "lede": "자신만의 저장 공간을 확보하고 파일 업로드를 시작하세요. 기존 공유는 그대로 유지됩니다.", + "busy": "업그레이드 중…", + "submit": "내 계정 업그레이드", + "cancel": "나중에 — 나와 공유됨으로 돌아가기", + "success": "계정이 업그레이드되었습니다. 파일로 이동 중…", + "error": "업그레이드에 실패했습니다.", + "password_required": "비밀번호가 필요합니다 — 이 서버는 이메일 링크 로그인을 제공하지 않습니다.", + "password_too_short": "비밀번호는 8자 이상이어야 합니다.", + "oidc_user": "SSO/OIDC 계정은 신원 제공자가 관리합니다. 업그레이드를 사용할 수 없습니다.", + "domain_not_allowed": "이 서버는 이 이메일 도메인에서 새 계정을 허용하지 않습니다. 활성화하려면 관리자에게 문의하세요.", + "banner_aria": "업그레이드 안내", + "banner_title": "자신만의 저장 공간 확보", + "banner_body": "게스트 계정을 사용 중입니다. 개인 드라이브를 얻고 파일을 업로드하려면 업그레이드하세요.", + "banner_cta": "업그레이드" } } diff --git a/frontend/static/locales/nl.json b/frontend/static/locales/nl.json index 1ea56df4..374f0ec1 100644 --- a/frontend/static/locales/nl.json +++ b/frontend/static/locales/nl.json @@ -1155,5 +1155,22 @@ }, "preferences": { "save_failed": "Kon je voorkeur niet opslaan. Probeer het opnieuw." + }, + "upgrade": { + "title": "Upgraden naar een volledig account", + "lede": "Krijg je eigen opslag en begin met het uploaden van bestanden. Je bestaande gedeelde items blijven onaangeroerd.", + "busy": "Upgraden…", + "submit": "Mijn account upgraden", + "cancel": "Niet nu — terug naar met mij gedeeld", + "success": "Je account is geüpgraded. Doorsturen naar je bestanden…", + "error": "Upgraden mislukt.", + "password_required": "Wachtwoord is verplicht — deze installatie biedt geen inloggen via e-maillink.", + "password_too_short": "Wachtwoord moet minimaal 8 tekens lang zijn.", + "oidc_user": "SSO/OIDC-accounts worden beheerd door je identiteitsprovider. Upgraden is niet beschikbaar.", + "domain_not_allowed": "Deze installatie accepteert geen nieuwe accounts vanuit jouw e-maildomein. Neem contact op met de beheerder om het in te schakelen.", + "banner_aria": "Upgrademelding", + "banner_title": "Krijg je eigen opslag", + "banner_body": "Je gebruikt een gastaccount. Upgrade om een persoonlijke schijf te krijgen en bestanden te uploaden.", + "banner_cta": "Upgraden" } } diff --git a/frontend/static/locales/pl.json b/frontend/static/locales/pl.json index c3e2d94d..373015ff 100644 --- a/frontend/static/locales/pl.json +++ b/frontend/static/locales/pl.json @@ -1155,5 +1155,22 @@ }, "preferences": { "save_failed": "Nie udało się zapisać ustawienia. Spróbuj ponownie." + }, + "upgrade": { + "title": "Rozszerz do pełnego konta", + "lede": "Uzyskaj własną przestrzeń i zacznij przesyłać pliki. Twoje istniejące udostępnienia pozostają nienaruszone.", + "busy": "Rozszerzanie…", + "submit": "Rozszerz moje konto", + "cancel": "Nie teraz — powrót do udostępnionych mi", + "success": "Twoje konto zostało rozszerzone. Przekierowywanie do plików…", + "error": "Rozszerzenie nie powiodło się.", + "password_required": "Hasło jest wymagane — ta instancja nie oferuje logowania przez link e-mail.", + "password_too_short": "Hasło musi mieć co najmniej 8 znaków.", + "oidc_user": "Konta SSO/OIDC są zarządzane przez Twojego dostawcę tożsamości. Rozszerzenie jest niedostępne.", + "domain_not_allowed": "Ta instancja nie akceptuje nowych kont z Twojej domeny e-mail. Skontaktuj się z administratorem, aby ją włączyć.", + "banner_aria": "Zachęta do rozszerzenia", + "banner_title": "Uzyskaj własną przestrzeń", + "banner_body": "Używasz konta gościa. Rozszerz, aby uzyskać osobisty dysk i przesyłać pliki.", + "banner_cta": "Rozszerz" } } diff --git a/frontend/static/locales/pt.json b/frontend/static/locales/pt.json index 6e24d8ea..5bcaa2fd 100644 --- a/frontend/static/locales/pt.json +++ b/frontend/static/locales/pt.json @@ -1155,5 +1155,22 @@ }, "preferences": { "save_failed": "Não foi possível salvar sua preferência. Tente novamente." + }, + "upgrade": { + "title": "Atualizar para conta completa", + "lede": "Obtenha seu próprio armazenamento e comece a enviar arquivos. Seus compartilhamentos existentes permanecem intactos.", + "busy": "Atualizando…", + "submit": "Atualizar minha conta", + "cancel": "Agora não — voltar a compartilhados comigo", + "success": "Sua conta foi atualizada. Redirecionando para seus arquivos…", + "error": "Falha na atualização.", + "password_required": "A senha é obrigatória — esta instância não oferece login por link de e-mail.", + "password_too_short": "A senha deve ter pelo menos 8 caracteres.", + "oidc_user": "Contas SSO/OIDC são gerenciadas pelo seu provedor de identidade. A atualização não está disponível.", + "domain_not_allowed": "Esta instância não aceita novas contas do seu domínio de e-mail. Contate o administrador para habilitar.", + "banner_aria": "Solicitação de atualização", + "banner_title": "Obtenha seu próprio armazenamento", + "banner_body": "Você está usando uma conta de convidado. Atualize para obter uma unidade pessoal e enviar arquivos.", + "banner_cta": "Atualizar" } } diff --git a/frontend/static/locales/ru.json b/frontend/static/locales/ru.json index 51eb4f6c..6c8f9c63 100644 --- a/frontend/static/locales/ru.json +++ b/frontend/static/locales/ru.json @@ -1155,5 +1155,22 @@ }, "preferences": { "save_failed": "Не удалось сохранить настройку. Повторите попытку." + }, + "upgrade": { + "title": "Обновить до полной учётной записи", + "lede": "Получите собственное хранилище и начните загружать файлы. Существующие общие ресурсы останутся без изменений.", + "busy": "Обновление…", + "submit": "Обновить мою учётную запись", + "cancel": "Не сейчас — вернуться к общему со мной", + "success": "Ваша учётная запись обновлена. Перенаправление к файлам…", + "error": "Обновление не удалось.", + "password_required": "Требуется пароль — этот сервер не предлагает вход по ссылке в письме.", + "password_too_short": "Пароль должен содержать не менее 8 символов.", + "oidc_user": "Учётные записи SSO/OIDC управляются вашим провайдером идентификации. Обновление недоступно.", + "domain_not_allowed": "Этот сервер не принимает новые учётные записи с вашего почтового домена. Свяжитесь с администратором, чтобы включить это.", + "banner_aria": "Приглашение к обновлению", + "banner_title": "Получите своё хранилище", + "banner_body": "Вы используете гостевую учётную запись. Обновите, чтобы получить личный диск и загружать файлы.", + "banner_cta": "Обновить" } } diff --git a/frontend/static/locales/zh-TW.json b/frontend/static/locales/zh-TW.json index e11a5469..957c5aab 100644 --- a/frontend/static/locales/zh-TW.json +++ b/frontend/static/locales/zh-TW.json @@ -1155,5 +1155,22 @@ }, "preferences": { "save_failed": "無法儲存偏好設定。請再試一次。" + }, + "upgrade": { + "title": "升級為完整帳號", + "lede": "取得您自己的儲存空間並開始上傳檔案。您現有的共享保持不變。", + "busy": "升級中…", + "submit": "升級我的帳號", + "cancel": "暫不 — 返回共享給我", + "success": "您的帳號已升級。正在跳轉到您的檔案…", + "error": "升級失敗。", + "password_required": "需要密碼 — 此部署未提供電子郵件連結登入。", + "password_too_short": "密碼必須至少 8 個字元。", + "oidc_user": "SSO/OIDC 帳號由您的身分提供者管理。無法升級。", + "domain_not_allowed": "此部署不接受來自您電子郵件網域的新帳號。請聯絡管理員啟用。", + "banner_aria": "升級提示", + "banner_title": "取得您自己的儲存空間", + "banner_body": "您正在使用訪客帳號。升級以取得個人雲端硬碟並開始上傳檔案。", + "banner_cta": "升級" } } diff --git a/frontend/static/locales/zh.json b/frontend/static/locales/zh.json index 37372777..990fcacb 100644 --- a/frontend/static/locales/zh.json +++ b/frontend/static/locales/zh.json @@ -1155,5 +1155,22 @@ }, "preferences": { "save_failed": "无法保存偏好设置。请重试。" + }, + "upgrade": { + "title": "升级为完整账户", + "lede": "获得您自己的存储空间并开始上传文件。您现有的共享保持不变。", + "busy": "升级中…", + "submit": "升级我的账户", + "cancel": "暂不 — 返回共享给我", + "success": "您的账户已升级。正在跳转到您的文件…", + "error": "升级失败。", + "password_required": "需要密码 — 此部署未提供邮件链接登录。", + "password_too_short": "密码必须至少 8 个字符。", + "oidc_user": "SSO/OIDC 账户由您的身份提供商管理。无法升级。", + "domain_not_allowed": "此部署不接受来自您邮箱域的新账户。请联系管理员启用。", + "banner_aria": "升级提示", + "banner_title": "获得您自己的存储空间", + "banner_body": "您正在使用访客账户。升级以获得个人云盘并开始上传文件。", + "banner_cta": "升级" } }