+2
frontend/src/lib/api.ts
+2
frontend/src/lib/api.ts
+30
frontend/src/routes/Dashboard.svelte
+30
frontend/src/routes/Dashboard.svelte
···
84
84
{/if}
85
85
</div>
86
86
</header>
87
+
{#if auth.session.status === 'deactivated' || auth.session.active === false}
88
+
<div class="deactivated-banner">
89
+
<strong>Account Deactivated</strong>
90
+
<p>Your account is currently deactivated. This typically happens during account migration. Some features may be limited until your account is reactivated.</p>
91
+
</div>
92
+
{/if}
87
93
<section class="account-overview">
88
94
<h2>Account Overview</h2>
89
95
<dl>
···
92
98
@{auth.session.handle}
93
99
{#if auth.session.isAdmin}
94
100
<span class="badge admin">Admin</span>
101
+
{/if}
102
+
{#if auth.session.status === 'deactivated' || auth.session.active === false}
103
+
<span class="badge deactivated">Deactivated</span>
95
104
{/if}
96
105
</dd>
97
106
<dt>DID</dt>
···
301
310
background: var(--accent);
302
311
color: white;
303
312
}
313
+
.badge.deactivated {
314
+
background: var(--warning-bg);
315
+
color: var(--warning-text);
316
+
border: 1px solid #d4a03c;
317
+
}
304
318
.nav-grid {
305
319
display: grid;
306
320
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
···
340
354
text-align: center;
341
355
padding: 4rem;
342
356
color: var(--text-secondary);
357
+
}
358
+
.deactivated-banner {
359
+
background: var(--warning-bg);
360
+
border: 1px solid #d4a03c;
361
+
border-radius: 8px;
362
+
padding: 1rem 1.5rem;
363
+
margin-bottom: 2rem;
364
+
}
365
+
.deactivated-banner strong {
366
+
color: var(--warning-text);
367
+
font-size: 1rem;
368
+
}
369
+
.deactivated-banner p {
370
+
margin: 0.5rem 0 0 0;
371
+
color: var(--warning-text);
372
+
font-size: 0.875rem;
343
373
}
344
374
</style>
+1
src/api/admin/invite.rs
+1
src/api/admin/invite.rs
+146
-18
src/api/identity/account.rs
+146
-18
src/api/identity/account.rs
···
364
364
.into_response();
365
365
}
366
366
};
367
-
let exists_query = sqlx::query!("SELECT 1 as one FROM users WHERE handle = $1", short_handle)
368
-
.fetch_optional(&mut *tx)
369
-
.await;
370
-
match exists_query {
371
-
Ok(Some(_)) => {
372
-
return (
373
-
StatusCode::BAD_REQUEST,
374
-
Json(json!({"error": "HandleTaken", "message": "Handle already taken"})),
367
+
if is_migration {
368
+
let existing_account: Option<(uuid::Uuid, String, Option<chrono::DateTime<chrono::Utc>>)> =
369
+
sqlx::query_as(
370
+
"SELECT id, handle, deactivated_at FROM users WHERE did = $1 FOR UPDATE",
375
371
)
376
-
.into_response();
377
-
}
378
-
Err(e) => {
379
-
error!("Error checking handle: {:?}", e);
380
-
return (
381
-
StatusCode::INTERNAL_SERVER_ERROR,
382
-
Json(json!({"error": "InternalError"})),
383
-
)
384
-
.into_response();
372
+
.bind(&did)
373
+
.fetch_optional(&mut *tx)
374
+
.await
375
+
.unwrap_or(None);
376
+
if let Some((account_id, old_handle, deactivated_at)) = existing_account {
377
+
if deactivated_at.is_some() {
378
+
info!(did = %did, old_handle = %old_handle, new_handle = %short_handle, "Preparing existing account for inbound migration");
379
+
let update_result: Result<_, sqlx::Error> = sqlx::query(
380
+
"UPDATE users SET handle = $1 WHERE id = $2",
381
+
)
382
+
.bind(short_handle)
383
+
.bind(account_id)
384
+
.execute(&mut *tx)
385
+
.await;
386
+
if let Err(e) = update_result {
387
+
if let Some(db_err) = e.as_database_error() {
388
+
if db_err.constraint().map(|c| c.contains("handle")).unwrap_or(false) {
389
+
return (
390
+
StatusCode::BAD_REQUEST,
391
+
Json(json!({"error": "HandleTaken", "message": "Handle already taken by another account"})),
392
+
)
393
+
.into_response();
394
+
}
395
+
}
396
+
error!("Error reactivating account: {:?}", e);
397
+
return (
398
+
StatusCode::INTERNAL_SERVER_ERROR,
399
+
Json(json!({"error": "InternalError"})),
400
+
)
401
+
.into_response();
402
+
}
403
+
if let Err(e) = tx.commit().await {
404
+
error!("Error committing reactivation: {:?}", e);
405
+
return (
406
+
StatusCode::INTERNAL_SERVER_ERROR,
407
+
Json(json!({"error": "InternalError"})),
408
+
)
409
+
.into_response();
410
+
}
411
+
let key_row: Option<(Vec<u8>, i32)> = sqlx::query_as(
412
+
"SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1",
413
+
)
414
+
.bind(account_id)
415
+
.fetch_optional(&state.db)
416
+
.await
417
+
.unwrap_or(None);
418
+
let secret_key_bytes = match key_row {
419
+
Some((key_bytes, encryption_version)) => {
420
+
match crate::config::decrypt_key(&key_bytes, Some(encryption_version)) {
421
+
Ok(k) => k,
422
+
Err(e) => {
423
+
error!("Error decrypting key for reactivated account: {:?}", e);
424
+
return (
425
+
StatusCode::INTERNAL_SERVER_ERROR,
426
+
Json(json!({"error": "InternalError"})),
427
+
)
428
+
.into_response();
429
+
}
430
+
}
431
+
}
432
+
None => {
433
+
error!("No signing key found for reactivated account");
434
+
return (
435
+
StatusCode::INTERNAL_SERVER_ERROR,
436
+
Json(json!({"error": "InternalError", "message": "Account signing key not found"})),
437
+
)
438
+
.into_response();
439
+
}
440
+
};
441
+
let access_meta = match crate::auth::create_access_token_with_metadata(&did, &secret_key_bytes) {
442
+
Ok(m) => m,
443
+
Err(e) => {
444
+
error!("Error creating access token: {:?}", e);
445
+
return (
446
+
StatusCode::INTERNAL_SERVER_ERROR,
447
+
Json(json!({"error": "InternalError"})),
448
+
)
449
+
.into_response();
450
+
}
451
+
};
452
+
let refresh_meta = match crate::auth::create_refresh_token_with_metadata(&did, &secret_key_bytes) {
453
+
Ok(m) => m,
454
+
Err(e) => {
455
+
error!("Error creating refresh token: {:?}", e);
456
+
return (
457
+
StatusCode::INTERNAL_SERVER_ERROR,
458
+
Json(json!({"error": "InternalError"})),
459
+
)
460
+
.into_response();
461
+
}
462
+
};
463
+
let session_result: Result<_, sqlx::Error> = sqlx::query(
464
+
"INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at) VALUES ($1, $2, $3, $4, $5)",
465
+
)
466
+
.bind(&did)
467
+
.bind(&access_meta.jti)
468
+
.bind(&refresh_meta.jti)
469
+
.bind(access_meta.expires_at)
470
+
.bind(refresh_meta.expires_at)
471
+
.execute(&state.db)
472
+
.await;
473
+
if let Err(e) = session_result {
474
+
error!("Error creating session: {:?}", e);
475
+
return (
476
+
StatusCode::INTERNAL_SERVER_ERROR,
477
+
Json(json!({"error": "InternalError"})),
478
+
)
479
+
.into_response();
480
+
}
481
+
return (
482
+
StatusCode::OK,
483
+
Json(CreateAccountOutput {
484
+
handle: full_handle.clone(),
485
+
did,
486
+
access_jwt: Some(access_meta.token),
487
+
refresh_jwt: Some(refresh_meta.token),
488
+
verification_required: false,
489
+
verification_channel: "email".to_string(),
490
+
}),
491
+
)
492
+
.into_response();
493
+
} else {
494
+
return (
495
+
StatusCode::BAD_REQUEST,
496
+
Json(json!({"error": "AccountAlreadyExists", "message": "An active account with this DID already exists"})),
497
+
)
498
+
.into_response();
499
+
}
385
500
}
386
-
Ok(None) => {}
501
+
}
502
+
let exists_result: Option<(i32,)> = sqlx::query_as(
503
+
"SELECT 1 FROM users WHERE handle = $1 AND deactivated_at IS NULL",
504
+
)
505
+
.bind(short_handle)
506
+
.fetch_optional(&mut *tx)
507
+
.await
508
+
.unwrap_or(None);
509
+
if exists_result.is_some() {
510
+
return (
511
+
StatusCode::BAD_REQUEST,
512
+
Json(json!({"error": "HandleTaken", "message": "Handle already taken"})),
513
+
)
514
+
.into_response();
387
515
}
388
516
let invite_code_required = std::env::var("INVITE_CODE_REQUIRED")
389
517
.map(|v| v == "true" || v == "1")
+1
src/api/repo/blob.rs
+1
src/api/repo/blob.rs
+1
src/api/repo/record/read.rs
+1
src/api/repo/record/read.rs
+1
src/sync/blob.rs
+1
src/sync/blob.rs