diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index f76ee8b..6ba0b42 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -48,6 +48,8 @@ export interface Session { preferredChannel?: string preferredChannelVerified?: boolean isAdmin?: boolean + active?: boolean + status?: 'active' | 'deactivated' accessJwt: string refreshJwt: string } diff --git a/frontend/src/routes/Dashboard.svelte b/frontend/src/routes/Dashboard.svelte index 311d671..afa8518 100644 --- a/frontend/src/routes/Dashboard.svelte +++ b/frontend/src/routes/Dashboard.svelte @@ -84,6 +84,12 @@ {/if} + {#if auth.session.status === 'deactivated' || auth.session.active === false} +
+ Account Deactivated +

Your account is currently deactivated. This typically happens during account migration. Some features may be limited until your account is reactivated.

+
+ {/if}

Account Overview

@@ -93,6 +99,9 @@ {#if auth.session.isAdmin} Admin {/if} + {#if auth.session.status === 'deactivated' || auth.session.active === false} + Deactivated + {/if}
DID
{auth.session.did}
@@ -301,6 +310,11 @@ background: var(--accent); color: white; } + .badge.deactivated { + background: var(--warning-bg); + color: var(--warning-text); + border: 1px solid #d4a03c; + } .nav-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); @@ -341,4 +355,20 @@ padding: 4rem; color: var(--text-secondary); } + .deactivated-banner { + background: var(--warning-bg); + border: 1px solid #d4a03c; + border-radius: 8px; + padding: 1rem 1.5rem; + margin-bottom: 2rem; + } + .deactivated-banner strong { + color: var(--warning-text); + font-size: 1rem; + } + .deactivated-banner p { + margin: 0.5rem 0 0 0; + color: var(--warning-text); + font-size: 0.875rem; + } diff --git a/src/api/admin/invite.rs b/src/api/admin/invite.rs index 52bb3a7..351ca4d 100644 --- a/src/api/admin/invite.rs +++ b/src/api/admin/invite.rs @@ -78,6 +78,7 @@ pub struct InviteCodeUseInfo { #[derive(Serialize)] pub struct GetInviteCodesOutput { + #[serde(skip_serializing_if = "Option::is_none")] pub cursor: Option, pub codes: Vec, } diff --git a/src/api/identity/account.rs b/src/api/identity/account.rs index 105926b..b994c43 100644 --- a/src/api/identity/account.rs +++ b/src/api/identity/account.rs @@ -364,26 +364,154 @@ pub async fn create_account( .into_response(); } }; - let exists_query = sqlx::query!("SELECT 1 as one FROM users WHERE handle = $1", short_handle) - .fetch_optional(&mut *tx) - .await; - match exists_query { - Ok(Some(_)) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "HandleTaken", "message": "Handle already taken"})), + if is_migration { + let existing_account: Option<(uuid::Uuid, String, Option>)> = + sqlx::query_as( + "SELECT id, handle, deactivated_at FROM users WHERE did = $1 FOR UPDATE", ) - .into_response(); + .bind(&did) + .fetch_optional(&mut *tx) + .await + .unwrap_or(None); + if let Some((account_id, old_handle, deactivated_at)) = existing_account { + if deactivated_at.is_some() { + info!(did = %did, old_handle = %old_handle, new_handle = %short_handle, "Preparing existing account for inbound migration"); + let update_result: Result<_, sqlx::Error> = sqlx::query( + "UPDATE users SET handle = $1 WHERE id = $2", + ) + .bind(short_handle) + .bind(account_id) + .execute(&mut *tx) + .await; + if let Err(e) = update_result { + if let Some(db_err) = e.as_database_error() { + if db_err.constraint().map(|c| c.contains("handle")).unwrap_or(false) { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "HandleTaken", "message": "Handle already taken by another account"})), + ) + .into_response(); + } + } + error!("Error reactivating account: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + if let Err(e) = tx.commit().await { + error!("Error committing reactivation: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + let key_row: Option<(Vec, i32)> = sqlx::query_as( + "SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1", + ) + .bind(account_id) + .fetch_optional(&state.db) + .await + .unwrap_or(None); + let secret_key_bytes = match key_row { + Some((key_bytes, encryption_version)) => { + match crate::config::decrypt_key(&key_bytes, Some(encryption_version)) { + Ok(k) => k, + Err(e) => { + error!("Error decrypting key for reactivated account: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + } + } + None => { + error!("No signing key found for reactivated account"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": "Account signing key not found"})), + ) + .into_response(); + } + }; + let access_meta = match crate::auth::create_access_token_with_metadata(&did, &secret_key_bytes) { + Ok(m) => m, + Err(e) => { + error!("Error creating access token: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + let refresh_meta = match crate::auth::create_refresh_token_with_metadata(&did, &secret_key_bytes) { + Ok(m) => m, + Err(e) => { + error!("Error creating refresh token: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + let session_result: Result<_, sqlx::Error> = sqlx::query( + "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at) VALUES ($1, $2, $3, $4, $5)", + ) + .bind(&did) + .bind(&access_meta.jti) + .bind(&refresh_meta.jti) + .bind(access_meta.expires_at) + .bind(refresh_meta.expires_at) + .execute(&state.db) + .await; + if let Err(e) = session_result { + error!("Error creating session: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + return ( + StatusCode::OK, + Json(CreateAccountOutput { + handle: full_handle.clone(), + did, + access_jwt: Some(access_meta.token), + refresh_jwt: Some(refresh_meta.token), + verification_required: false, + verification_channel: "email".to_string(), + }), + ) + .into_response(); + } else { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "AccountAlreadyExists", "message": "An active account with this DID already exists"})), + ) + .into_response(); + } } - Err(e) => { - error!("Error checking handle: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } - Ok(None) => {} + } + let exists_result: Option<(i32,)> = sqlx::query_as( + "SELECT 1 FROM users WHERE handle = $1 AND deactivated_at IS NULL", + ) + .bind(short_handle) + .fetch_optional(&mut *tx) + .await + .unwrap_or(None); + if exists_result.is_some() { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "HandleTaken", "message": "Handle already taken"})), + ) + .into_response(); } let invite_code_required = std::env::var("INVITE_CODE_REQUIRED") .map(|v| v == "true" || v == "1") diff --git a/src/api/repo/blob.rs b/src/api/repo/blob.rs index 095a8f3..ab45c1b 100644 --- a/src/api/repo/blob.rs +++ b/src/api/repo/blob.rs @@ -222,6 +222,7 @@ pub struct RecordBlob { #[derive(Serialize)] pub struct ListMissingBlobsOutput { + #[serde(skip_serializing_if = "Option::is_none")] pub cursor: Option, pub blobs: Vec, } diff --git a/src/api/repo/record/read.rs b/src/api/repo/record/read.rs index ac9b62a..f79080c 100644 --- a/src/api/repo/record/read.rs +++ b/src/api/repo/record/read.rs @@ -197,6 +197,7 @@ pub struct ListRecordsInput { } #[derive(Serialize)] pub struct ListRecordsOutput { + #[serde(skip_serializing_if = "Option::is_none")] pub cursor: Option, pub records: Vec, } diff --git a/src/sync/blob.rs b/src/sync/blob.rs index e93c001..4adeb1e 100644 --- a/src/sync/blob.rs +++ b/src/sync/blob.rs @@ -110,6 +110,7 @@ pub struct ListBlobsParams { #[derive(Serialize)] pub struct ListBlobsOutput { + #[serde(skip_serializing_if = "Option::is_none")] pub cursor: Option, pub cids: Vec, } diff --git a/src/sync/commit.rs b/src/sync/commit.rs index fea8cb4..9322d39 100644 --- a/src/sync/commit.rs +++ b/src/sync/commit.rs @@ -101,6 +101,7 @@ pub struct RepoInfo { #[derive(Serialize)] pub struct ListReposOutput { + #[serde(skip_serializing_if = "Option::is_none")] pub cursor: Option, pub repos: Vec, }