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,
}