mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-02-08 21:30:08 +00:00
Outbound migration perfected
This commit is contained in:
@@ -48,6 +48,8 @@ export interface Session {
|
||||
preferredChannel?: string
|
||||
preferredChannelVerified?: boolean
|
||||
isAdmin?: boolean
|
||||
active?: boolean
|
||||
status?: 'active' | 'deactivated'
|
||||
accessJwt: string
|
||||
refreshJwt: string
|
||||
}
|
||||
|
||||
@@ -84,6 +84,12 @@
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
{#if auth.session.status === 'deactivated' || auth.session.active === false}
|
||||
<div class="deactivated-banner">
|
||||
<strong>Account Deactivated</strong>
|
||||
<p>Your account is currently deactivated. This typically happens during account migration. Some features may be limited until your account is reactivated.</p>
|
||||
</div>
|
||||
{/if}
|
||||
<section class="account-overview">
|
||||
<h2>Account Overview</h2>
|
||||
<dl>
|
||||
@@ -93,6 +99,9 @@
|
||||
{#if auth.session.isAdmin}
|
||||
<span class="badge admin">Admin</span>
|
||||
{/if}
|
||||
{#if auth.session.status === 'deactivated' || auth.session.active === false}
|
||||
<span class="badge deactivated">Deactivated</span>
|
||||
{/if}
|
||||
</dd>
|
||||
<dt>DID</dt>
|
||||
<dd class="mono">{auth.session.did}</dd>
|
||||
@@ -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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -78,6 +78,7 @@ pub struct InviteCodeUseInfo {
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct GetInviteCodesOutput {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cursor: Option<String>,
|
||||
pub codes: Vec<InviteCodeInfo>,
|
||||
}
|
||||
|
||||
@@ -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<chrono::DateTime<chrono::Utc>>)> =
|
||||
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<u8>, 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")
|
||||
|
||||
@@ -222,6 +222,7 @@ pub struct RecordBlob {
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ListMissingBlobsOutput {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cursor: Option<String>,
|
||||
pub blobs: Vec<RecordBlob>,
|
||||
}
|
||||
|
||||
@@ -197,6 +197,7 @@ pub struct ListRecordsInput {
|
||||
}
|
||||
#[derive(Serialize)]
|
||||
pub struct ListRecordsOutput {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cursor: Option<String>,
|
||||
pub records: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
@@ -110,6 +110,7 @@ pub struct ListBlobsParams {
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ListBlobsOutput {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cursor: Option<String>,
|
||||
pub cids: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@ pub struct RepoInfo {
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ListReposOutput {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cursor: Option<String>,
|
||||
pub repos: Vec<RepoInfo>,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user