Tranquil PDS! Moved to https://tangled.org/tranquil.farm/tranquil-pds

Fix replying to other users not in the same PDS, add PAR for oauth and fix minor bugs #10

closed opened by scanash.com targeting main from [deleted fork]: main
Labels

None yet.

assignee

None yet.

Participants 3
AT URI
at://did:plc:3i6uzuatdyk7rwfkrybynf5j/sh.tangled.repo.pull/3mbekljkj5d22
+2688 -7652
Interdiff #0 โ†’ #1
Dockerfile

This file has not been changed.

+82 -56
frontend/src/lib/migration/atproto-client.ts
··· 372 372 ); 373 373 } 374 374 375 - async deactivateAccount(): Promise<void> { 375 + async deactivateAccount(migratingTo?: string): Promise<void> { 376 376 apiLog( 377 377 "POST", 378 378 `${this.baseUrl}/xrpc/com.atproto.server.deactivateAccount`, 379 + { 380 + migratingTo, 381 + }, 379 382 ); 380 383 const start = Date.now(); 381 384 try { 385 + const body: { migratingTo?: string } = {}; 386 + if (migratingTo) { 387 + body.migratingTo = migratingTo; 388 + } 382 389 await this.xrpc("com.atproto.server.deactivateAccount", { 383 390 httpMethod: "POST", 391 + body, 384 392 }); 385 393 apiLog( 386 394 "POST", ··· 388 396 { 389 397 durationMs: Date.now() - start, 390 398 success: true, 399 + migratingTo, 391 400 }, 392 401 ); 393 402 } catch (e) { ··· 400 409 error: err.message, 401 410 errorCode: err.error, 402 411 status: err.status, 412 + migratingTo, 403 413 }, 404 414 ); 405 415 throw e; ··· 410 420 return this.xrpc("com.atproto.server.checkAccountStatus"); 411 421 } 412 422 423 + async getMigrationStatus(): Promise<{ 424 + did: string; 425 + didType: string; 426 + migrated: boolean; 427 + migratedToPds?: string; 428 + migratedAt?: string; 429 + }> { 430 + return this.xrpc("com.tranquil.account.getMigrationStatus"); 431 + } 432 + 433 + async updateMigrationForwarding(pdsUrl: string): Promise<{ 434 + success: boolean; 435 + migratedToPds: string; 436 + migratedAt: string; 437 + }> { 438 + return this.xrpc("com.tranquil.account.updateMigrationForwarding", { 439 + httpMethod: "POST", 440 + body: { pdsUrl }, 441 + }); 442 + } 443 + 444 + async clearMigrationForwarding(): Promise<{ success: boolean }> { 445 + return this.xrpc("com.tranquil.account.clearMigrationForwarding", { 446 + httpMethod: "POST", 447 + }); 448 + } 449 + 413 450 async resolveHandle(handle: string): Promise<{ did: string }> { 414 451 return this.xrpc("com.atproto.identity.resolveHandle", { 415 452 params: { handle }, ··· 431 468 return session; 432 469 } 433 470 434 - async checkEmailVerified(identifier: string): Promise<boolean> { 435 - const result = await this.xrpc<{ verified: boolean }>( 436 - "_checkEmailVerified", 437 - { 438 - httpMethod: "POST", 439 - body: { identifier }, 440 - }, 441 - ); 442 - return result.verified; 443 - } 444 - 445 471 async verifyToken( 446 472 token: string, 447 473 identifier: string, 448 474 ): Promise< 449 475 { success: boolean; did: string; purpose: string; channel: string } 450 476 > { 451 - return this.xrpc("_account.verifyToken", { 477 + return this.xrpc("com.tranquil.account.verifyToken", { 452 478 httpMethod: "POST", 453 479 body: { token, identifier }, 454 480 }); ··· 472 498 } 473 499 474 500 const res = await fetch( 475 - `${this.baseUrl}/xrpc/_account.createPasskeyAccount`, 501 + `${this.baseUrl}/xrpc/com.tranquil.account.createPasskeyAccount`, 476 502 { 477 503 method: "POST", 478 504 headers, ··· 504 530 setupToken: string, 505 531 friendlyName?: string, 506 532 ): Promise<StartPasskeyRegistrationResponse> { 507 - return this.xrpc("_account.startPasskeyRegistrationForSetup", { 533 + return this.xrpc("com.tranquil.account.startPasskeyRegistrationForSetup", { 508 534 httpMethod: "POST", 509 535 body: { did, setupToken, friendlyName }, 510 536 }); ··· 516 542 passkeyCredential: unknown, 517 543 passkeyFriendlyName?: string, 518 544 ): Promise<CompletePasskeySetupResponse> { 519 - return this.xrpc("_account.completePasskeySetup", { 545 + return this.xrpc("com.tranquil.account.completePasskeySetup", { 520 546 httpMethod: "POST", 521 547 body: { did, setupToken, passkeyCredential, passkeyFriendlyName }, 522 548 }); ··· 546 572 return null; 547 573 } 548 574 549 - const authServerUrl = `${ 550 - authServers[0] 551 - }/.well-known/oauth-authorization-server`; 575 + const authServerUrl = `${authServers[0] 576 + }/.well-known/oauth-authorization-server`; 552 577 const authServerRes = await fetch(authServerUrl); 553 578 if (!authServerRes.ok) { 554 579 return null; ··· 572 597 return null; 573 598 } 574 599 575 - const authServerUrl = `${authServers[0] 576 - }/.well-known/oauth-authorization-server`; 600 + const authServerUrl = `${ 601 + authServers[0] 602 + }/.well-known/oauth-authorization-server`; 577 603 const authServerRes = await fetch(authServerUrl); 578 604 if (!authServerRes.ok) { 579 605 return null; ··· 615 641 ...cred, 616 642 id: base64UrlDecode(cred.id as string), 617 643 }), 618 - ), 619 - } as PublicKeyCredentialCreationOptions; 644 + ) as unknown, 645 + } as unknown as PublicKeyCredentialCreationOptions; 620 646 } 621 647 622 648 async function computeAccessTokenHash(accessToken: string): Promise<string> { ··· 641 667 ...cred, 642 668 id: base64UrlDecode(cred.id as string), 643 669 }), 644 - ) as unknown, 645 - } as unknown as PublicKeyCredentialCreationOptions; 670 + ), 671 + } as PublicKeyCredentialCreationOptions; 646 672 } 647 673 648 674 async function computeAccessTokenHash(accessToken: string): Promise<string> { ··· 661 687 return url.toString(); 662 688 } 663 689 664 - export async function exchangeOAuthCode( 665 - metadata: OAuthServerMetadata, 666 - params: { 667 - 668 - 669 - 670 - 671 - 672 - 673 - 674 - 675 - 676 - 677 - 678 - 679 - 680 - 681 - 682 - 683 - 684 - 685 - 686 - 687 - return url.toString(); 688 - } 689 - 690 690 export function buildParAuthorizationUrl( 691 691 metadata: OAuthServerMetadata, 692 692 clientId: string, ··· 806 806 807 807 808 808 809 + return url.toString(); 810 + } 809 811 812 + export async function exchangeOAuthCode( 813 + metadata: OAuthServerMetadata, 814 + params: { 810 815 811 816 812 817 813 818 814 819 815 820 821 + 822 + 823 + 824 + 825 + 826 + 827 + 828 + 829 + 830 + 831 + 832 + 833 + 834 + 835 + 836 + 837 + 838 + 839 + 840 + 841 + 816 842 })); 817 843 throw new Error( 818 844 retryErr.error_description || retryErr.error || 819 - "Token exchange failed", 845 + "Token exchange failed", 820 846 ); 821 847 } 822 848 return res.json(); ··· 842 868 })); 843 869 throw new Error( 844 870 retryErr.error_description || retryErr.error || 845 - "Token exchange failed", 871 + "Token exchange failed", 846 872 ); 847 873 } 848 874 return res.json(); ··· 868 894 869 895 if (handle.endsWith(".bsky.social")) { 870 896 const res = await fetch( 871 - `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${ 872 - encodeURIComponent(handle) 897 + `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle) 873 898 }`, 874 899 ); 875 900 if (!res.ok) { ··· 894 919 895 920 if (handle.endsWith(".bsky.social")) { 896 921 const res = await fetch( 897 - `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle) 922 + `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${ 923 + encodeURIComponent(handle) 898 924 }`, 899 925 ); 900 926 if (!res.ok) {
frontend/src/lib/migration/flow.svelte.ts

This patch was likely rebased, as context lines do not match.

+23 -37
frontend/src/lib/migration/types.ts
··· 13 13 | "success" 14 14 | "error"; 15 15 16 - export type OfflineInboundStep = 16 + export type AuthMethod = "password" | "passkey"; 17 + 18 + export type OutboundStep = 17 19 | "welcome" 18 - | "provide-did" 19 - | "upload-car" 20 - | "provide-rotation-key" 21 - | "choose-handle" 20 + | "target-pds" 21 + | "new-account" 22 22 | "review" 23 - | "creating" 24 - | "importing" 25 - | "migrating-blobs" 26 - | "plc-signing" 27 - | "email-verify" 28 - | "passkey-setup" 29 - | "app-password" 23 + | "migrating" 24 + | "plc-token" 30 25 | "finalizing" 31 26 | "success" 32 27 | "error"; 33 28 34 - export type AuthMethod = "password" | "passkey"; 29 + export type MigrationDirection = "inbound" | "outbound"; 35 30 36 - export type MigrationDirection = "inbound"; 37 - 38 31 export interface MigrationProgress { 39 32 repoExported: boolean; 40 33 ··· 75 68 resumeToStep?: InboundStep; 76 69 } 77 70 78 - export interface OfflineInboundMigrationState { 79 - direction: "offline-inbound"; 80 - step: OfflineInboundStep; 81 - userDid: string; 82 - carFile: Uint8Array | null; 83 - carFileName: string; 84 - carSizeBytes: number; 85 - carNeedsReupload: boolean; 86 - rotationKey: string; 87 - rotationKeyDidKey: string; 88 - oldPdsUrl: string | null; 71 + export interface OutboundMigrationState { 72 + direction: "outbound"; 73 + step: OutboundStep; 74 + localDid: string; 75 + localHandle: string; 76 + targetPdsUrl: string; 77 + targetPdsDid: string; 89 78 targetHandle: string; 90 79 targetEmail: string; 91 80 targetPassword: string; 92 81 inviteCode: string; 93 - authMethod: AuthMethod; 94 - localAccessToken: string | null; 95 - localRefreshToken: string | null; 96 - passkeySetupToken: string | null; 97 - generatedAppPassword: string | null; 98 - generatedAppPasswordName: string | null; 99 - emailVerifyToken: string; 82 + targetAccessToken: string | null; 83 + targetRefreshToken: string | null; 84 + serviceAuthToken: string | null; 85 + plcToken: string; 100 86 progress: MigrationProgress; 101 87 error: string | null; 102 - plcUpdatedTemporarily: boolean; 88 + targetServerInfo: ServerDescription | null; 103 89 } 104 90 105 - export type MigrationState = InboundMigrationState; 91 + export type MigrationState = InboundMigrationState | OutboundMigrationState; 106 92 107 93 export interface StoredMigrationState { 108 94 version: 1; ··· 240 226 issuer: string; 241 227 authorization_endpoint: string; 242 228 token_endpoint: string; 243 - pushed_authorization_request_endpoint?: string; 244 - require_pushed_authorization_requests?: boolean; 245 229 scopes_supported?: string[]; 246 230 response_types_supported?: string[]; 247 231 grant_types_supported?: string[]; ··· 256 240 issuer: string; 257 241 authorization_endpoint: string; 258 242 token_endpoint: string; 243 + pushed_authorization_request_endpoint?: string; 244 + require_pushed_authorization_requests?: boolean; 259 245 scopes_supported?: string[]; 260 246 response_types_supported?: string[]; 261 247 grant_types_supported?: string[];
+37 -9
src/api/repo/record/read.rs
··· 206 206 let record_cid_str: String = match record_row { 207 207 Ok(Some(row)) => row.record_cid, 208 208 _ => { 209 - let appview_endpoint = std::env::var("BSKY_APPVIEW_ENDPOINT") 210 - .unwrap_or_else(|_| "https://api.bsky.app".to_string()); 209 + let did = if input.repo.starts_with("did:") { 210 + input.repo.clone() 211 + } else { 212 + info!("Resolving handle {} to DID for P2P proxy", input.repo); 213 + match crate::handle::resolve_handle(&input.repo).await { 214 + Ok(d) => d, 215 + Err(e) => { 216 + error!("Failed to resolve handle {}: {}", input.repo, e); 217 + return ( 218 + StatusCode::NOT_FOUND, 219 + Json(json!({"error": "RepoNotFound", "message": "Could not resolve handle"})), 220 + ) 221 + .into_response(); 222 + } 223 + } 224 + }; 225 + 226 + let resolved_service = match state.did_resolver.resolve_did(&did).await { 227 + Some(s) => s, 228 + None => { 229 + error!("Failed to resolve PDS for DID {}", did); 230 + return ( 231 + StatusCode::NOT_FOUND, 232 + Json(json!({"error": "RepoNotFound", "message": "Could not resolve DID service endpoint"})), 233 + ) 234 + .into_response(); 235 + } 236 + }; 237 + 211 238 let mut url = format!( 212 239 "{}/xrpc/com.atproto.repo.getRecord?repo={}&collection={}&rkey={}", 213 - appview_endpoint.trim_end_matches('/'), 240 + resolved_service.url.trim_end_matches('/'), 214 241 urlencoding::encode(&input.repo), 215 242 urlencoding::encode(&input.collection), 216 243 urlencoding::encode(&input.rkey) ··· 219 246 url.push_str(&format!("&cid={}", urlencoding::encode(cid))); 220 247 } 221 248 info!( 222 - "Record not found locally (user exists). Proxying getRecord for {} to AppView: {}", 223 - input.repo, url 249 + "Record not found locally. Proxying getRecord for {} (DID: {}) to PDS: {}", 250 + input.repo, did, url 224 251 ); 252 + 225 253 match proxy_client().get(&url).send().await { 226 254 Ok(resp) => { 227 255 let status = resp.status(); 228 256 let body = match resp.bytes().await { 229 257 Ok(b) => b, 230 258 Err(e) => { 231 - error!("Error reading AppView proxy response: {:?}", e); 259 + error!("Error reading upstream PDS response: {:?}", e); 232 260 return ( 233 261 StatusCode::BAD_GATEWAY, 234 262 Json(json!({ 235 263 "error": "UpstreamFailure", 236 - "message": "Error reading upstream response from AppView" 264 + "message": "Error reading upstream response" 237 265 })), 238 266 ) 239 267 .into_response(); ··· 248 276 }); 249 277 } 250 278 Err(e) => { 251 - error!("Error proxying request to AppView: {:?}", e); 279 + error!("Error proxying request to upstream PDS: {:?}", e); 252 280 return ( 253 281 StatusCode::BAD_GATEWAY, 254 282 Json(json!({ 255 283 "error": "UpstreamFailure", 256 - "message": "Failed to reach AppView" 284 + "message": "Failed to reach upstream PDS" 257 285 })), 258 286 ) 259 287 .into_response();
-28
.sqlx/query-017b04caf42b30f2c8f9468acf61a83244b7c2fa5cacfaee41a946a6af5ef68e.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT id, backup_enabled FROM users WHERE did = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "id", 9 - "type_info": "Uuid" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "backup_enabled", 14 - "type_info": "Bool" 15 - } 16 - ], 17 - "parameters": { 18 - "Left": [ 19 - "Text" 20 - ] 21 - }, 22 - "nullable": [ 23 - false, 24 - false 25 - ] 26 - }, 27 - "hash": "017b04caf42b30f2c8f9468acf61a83244b7c2fa5cacfaee41a946a6af5ef68e" 28 - }
+16
.sqlx/query-0d6565c792bb9c2845d03ac1cb984658d77a26f90df511686e47b358c79a8ebe.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE users SET deactivated_at = NOW(), delete_after = $2, migrated_to_pds = $3, migrated_at = NOW() WHERE did = $1", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Timestamptz", 10 + "Text" 11 + ] 12 + }, 13 + "nullable": [] 14 + }, 15 + "hash": "0d6565c792bb9c2845d03ac1cb984658d77a26f90df511686e47b358c79a8ebe" 16 + }
-52
.sqlx/query-2728a7c672f95349b0406acfca24addfbc039379331142e3a7d78597f622382c.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n SELECT u.id, u.did, u.backup_enabled, u.deactivated_at, r.repo_root_cid, r.repo_rev\n FROM users u\n JOIN repos r ON r.user_id = u.id\n WHERE u.did = $1\n ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "id", 9 - "type_info": "Uuid" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "did", 14 - "type_info": "Text" 15 - }, 16 - { 17 - "ordinal": 2, 18 - "name": "backup_enabled", 19 - "type_info": "Bool" 20 - }, 21 - { 22 - "ordinal": 3, 23 - "name": "deactivated_at", 24 - "type_info": "Timestamptz" 25 - }, 26 - { 27 - "ordinal": 4, 28 - "name": "repo_root_cid", 29 - "type_info": "Text" 30 - }, 31 - { 32 - "ordinal": 5, 33 - "name": "repo_rev", 34 - "type_info": "Text" 35 - } 36 - ], 37 - "parameters": { 38 - "Left": [ 39 - "Text" 40 - ] 41 - }, 42 - "nullable": [ 43 - false, 44 - false, 45 - false, 46 - true, 47 - false, 48 - true 49 - ] 50 - }, 51 - "hash": "2728a7c672f95349b0406acfca24addfbc039379331142e3a7d78597f622382c" 52 - }
+14
.sqlx/query-603711611d2100957c67bb18485f03eecf54a5f2865fe2e40b251ab6d6c64cd1.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE users SET migrated_to_pds = NULL, migrated_at = NULL WHERE did = $1", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "603711611d2100957c67bb18485f03eecf54a5f2865fe2e40b251ab6d6c64cd1" 14 + }
-22
.sqlx/query-6258398accee69e0c5f455a3c0ecc273b3da6ef5bb4d8660adafe63d8e3cd2d4.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT email_verified FROM users WHERE email = $1 OR handle = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "email_verified", 9 - "type_info": "Bool" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Text" 15 - ] 16 - }, 17 - "nullable": [ 18 - false 19 - ] 20 - }, 21 - "hash": "6258398accee69e0c5f455a3c0ecc273b3da6ef5bb4d8660adafe63d8e3cd2d4" 22 - }
+34
.sqlx/query-63cfbd8c2fda2c01cb9a97fc2768b60cafecaa4fa3006c2db9848e852d867073.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT id, migrated_to_pds, handle FROM users WHERE did = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "migrated_to_pds", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "handle", 19 + "type_info": "Text" 20 + } 21 + ], 22 + "parameters": { 23 + "Left": [ 24 + "Text" 25 + ] 26 + }, 27 + "nullable": [ 28 + false, 29 + true, 30 + false 31 + ] 32 + }, 33 + "hash": "63cfbd8c2fda2c01cb9a97fc2768b60cafecaa4fa3006c2db9848e852d867073" 34 + }
+30
.sqlx/query-6f88c5e63c1beb47733daed5295492d59c649a35ef78414c62dcdf4d0b2a3115.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT rb.blob_cid, rb.record_uri\n FROM record_blobs rb\n LEFT JOIN blobs b ON rb.blob_cid = b.cid AND b.created_by_user = rb.repo_id\n WHERE rb.repo_id = $1 AND b.cid IS NULL AND rb.blob_cid > $2\n ORDER BY rb.blob_cid\n LIMIT $3\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "blob_cid", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "record_uri", 14 + "type_info": "Text" 15 + } 16 + ], 17 + "parameters": { 18 + "Left": [ 19 + "Uuid", 20 + "Text", 21 + "Int8" 22 + ] 23 + }, 24 + "nullable": [ 25 + false, 26 + false 27 + ] 28 + }, 29 + "hash": "6f88c5e63c1beb47733daed5295492d59c649a35ef78414c62dcdf4d0b2a3115" 30 + }
-35
.sqlx/query-72a5e8d9f678caf2e6c03e43d78203941645529a4d0ccf18f1abf477cde6ed8d.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n SELECT ab.id, ab.storage_key, u.deactivated_at\n FROM account_backups ab\n JOIN users u ON u.id = ab.user_id\n WHERE ab.id = $1 AND u.did = $2\n ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "id", 9 - "type_info": "Uuid" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "storage_key", 14 - "type_info": "Text" 15 - }, 16 - { 17 - "ordinal": 2, 18 - "name": "deactivated_at", 19 - "type_info": "Timestamptz" 20 - } 21 - ], 22 - "parameters": { 23 - "Left": [ 24 - "Uuid", 25 - "Text" 26 - ] 27 - }, 28 - "nullable": [ 29 - false, 30 - false, 31 - true 32 - ] 33 - }, 34 - "hash": "72a5e8d9f678caf2e6c03e43d78203941645529a4d0ccf18f1abf477cde6ed8d" 35 - }
+34
.sqlx/query-791d0d6ea6fbedc3e51fb8b1fda7bfe756f2631daf688e6646705725342ad67e.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT did, migrated_to_pds, migrated_at FROM users WHERE did = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "did", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "migrated_to_pds", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "migrated_at", 19 + "type_info": "Timestamptz" 20 + } 21 + ], 22 + "parameters": { 23 + "Left": [ 24 + "Text" 25 + ] 26 + }, 27 + "nullable": [ 28 + false, 29 + true, 30 + true 31 + ] 32 + }, 33 + "hash": "791d0d6ea6fbedc3e51fb8b1fda7bfe756f2631daf688e6646705725342ad67e" 34 + }
-19
.sqlx/query-7a05733a51eb9989d2aba807ab1806d67e3fbf8219d06edec7840fda89bf222c.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n INSERT INTO account_backups (user_id, storage_key, repo_root_cid, repo_rev, block_count, size_bytes)\n VALUES ($1, $2, $3, $4, $5, $6)\n ", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Uuid", 9 - "Text", 10 - "Text", 11 - "Text", 12 - "Int4", 13 - "Int8" 14 - ] 15 - }, 16 - "nullable": [] 17 - }, 18 - "hash": "7a05733a51eb9989d2aba807ab1806d67e3fbf8219d06edec7840fda89bf222c" 19 - }
-29
.sqlx/query-95d38301fed0592dc309b0d7d08559deab0c25965b41025eec6a2bced5dd5f0f.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n SELECT ab.storage_key, ab.repo_rev\n FROM account_backups ab\n JOIN users u ON u.id = ab.user_id\n WHERE ab.id = $1 AND u.did = $2\n ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "storage_key", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "repo_rev", 14 - "type_info": "Text" 15 - } 16 - ], 17 - "parameters": { 18 - "Left": [ 19 - "Uuid", 20 - "Text" 21 - ] 22 - }, 23 - "nullable": [ 24 - false, 25 - false 26 - ] 27 - }, 28 - "hash": "95d38301fed0592dc309b0d7d08559deab0c25965b41025eec6a2bced5dd5f0f" 29 - }
-29
.sqlx/query-a36a237358f5dc502bb09258074139a5aef77adb0f6d58ffc5e998acbc00f144.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n SELECT id, storage_key\n FROM account_backups\n WHERE user_id = $1\n ORDER BY created_at DESC\n OFFSET $2\n ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "id", 9 - "type_info": "Uuid" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "storage_key", 14 - "type_info": "Text" 15 - } 16 - ], 17 - "parameters": { 18 - "Left": [ 19 - "Uuid", 20 - "Int8" 21 - ] 22 - }, 23 - "nullable": [ 24 - false, 25 - false 26 - ] 27 - }, 28 - "hash": "a36a237358f5dc502bb09258074139a5aef77adb0f6d58ffc5e998acbc00f144" 29 - }
-52
.sqlx/query-b4fb4ae0fb94168ee7144ea249e75bedc6d4fb54f09b3df2ce10903d4f04dfc4.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n SELECT id, repo_rev, repo_root_cid, block_count, size_bytes, created_at\n FROM account_backups\n WHERE user_id = $1\n ORDER BY created_at DESC\n ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "id", 9 - "type_info": "Uuid" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "repo_rev", 14 - "type_info": "Text" 15 - }, 16 - { 17 - "ordinal": 2, 18 - "name": "repo_root_cid", 19 - "type_info": "Text" 20 - }, 21 - { 22 - "ordinal": 3, 23 - "name": "block_count", 24 - "type_info": "Int4" 25 - }, 26 - { 27 - "ordinal": 4, 28 - "name": "size_bytes", 29 - "type_info": "Int8" 30 - }, 31 - { 32 - "ordinal": 5, 33 - "name": "created_at", 34 - "type_info": "Timestamptz" 35 - } 36 - ], 37 - "parameters": { 38 - "Left": [ 39 - "Uuid" 40 - ] 41 - }, 42 - "nullable": [ 43 - false, 44 - false, 45 - false, 46 - false, 47 - false, 48 - false 49 - ] 50 - }, 51 - "hash": "b4fb4ae0fb94168ee7144ea249e75bedc6d4fb54f09b3df2ce10903d4f04dfc4" 52 - }
-40
.sqlx/query-d6d533b728887666b2a9ad2d2f9e6b173131842bb9b5f9068175397fd30a50ab.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n SELECT u.id as user_id, u.did, r.repo_root_cid, r.repo_rev\n FROM users u\n JOIN repos r ON r.user_id = u.id\n WHERE u.backup_enabled = true\n AND u.deactivated_at IS NULL\n AND (\n NOT EXISTS (\n SELECT 1 FROM account_backups ab WHERE ab.user_id = u.id\n )\n OR (\n SELECT MAX(ab.created_at) FROM account_backups ab WHERE ab.user_id = u.id\n ) < NOW() - make_interval(secs => $1)\n )\n LIMIT 50\n ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "user_id", 9 - "type_info": "Uuid" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "did", 14 - "type_info": "Text" 15 - }, 16 - { 17 - "ordinal": 2, 18 - "name": "repo_root_cid", 19 - "type_info": "Text" 20 - }, 21 - { 22 - "ordinal": 3, 23 - "name": "repo_rev", 24 - "type_info": "Text" 25 - } 26 - ], 27 - "parameters": { 28 - "Left": [ 29 - "Float8" 30 - ] 31 - }, 32 - "nullable": [ 33 - false, 34 - false, 35 - false, 36 - true 37 - ] 38 - }, 39 - "hash": "d6d533b728887666b2a9ad2d2f9e6b173131842bb9b5f9068175397fd30a50ab" 40 - }
-34
.sqlx/query-e60550cc972a5b0dd7cbdbc20d6ae6439eae3811d488166dca1b41bcc11f81f7.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT id, handle, deactivated_at FROM users WHERE did = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "id", 9 - "type_info": "Uuid" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "handle", 14 - "type_info": "Text" 15 - }, 16 - { 17 - "ordinal": 2, 18 - "name": "deactivated_at", 19 - "type_info": "Timestamptz" 20 - } 21 - ], 22 - "parameters": { 23 - "Left": [ 24 - "Text" 25 - ] 26 - }, 27 - "nullable": [ 28 - false, 29 - false, 30 - true 31 - ] 32 - }, 33 - "hash": "e60550cc972a5b0dd7cbdbc20d6ae6439eae3811d488166dca1b41bcc11f81f7" 34 - }
-30
.sqlx/query-ec51d224b9fcd73fd04eebaf2215423d7b1d528b5aba87a0d2f5fe4636af0adf.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n SELECT rb.blob_cid, rb.record_uri\n FROM record_blobs rb\n LEFT JOIN blobs b ON rb.blob_cid = b.cid\n WHERE rb.repo_id = $1 AND b.cid IS NULL AND rb.blob_cid > $2\n ORDER BY rb.blob_cid\n LIMIT $3\n ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "blob_cid", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "record_uri", 14 - "type_info": "Text" 15 - } 16 - ], 17 - "parameters": { 18 - "Left": [ 19 - "Uuid", 20 - "Text", 21 - "Int8" 22 - ] 23 - }, 24 - "nullable": [ 25 - false, 26 - false 27 - ] 28 - }, 29 - "hash": "ec51d224b9fcd73fd04eebaf2215423d7b1d528b5aba87a0d2f5fe4636af0adf" 30 - }
-34
.sqlx/query-f405fc944c383ab9f50b805da3e4bf302e40698beac5b06d3d19abd185de21c1.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n SELECT DISTINCT b.cid, b.storage_key, b.mime_type\n FROM blobs b\n JOIN record_blobs rb ON rb.blob_cid = b.cid\n WHERE rb.repo_id = $1\n ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "cid", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "storage_key", 14 - "type_info": "Text" 15 - }, 16 - { 17 - "ordinal": 2, 18 - "name": "mime_type", 19 - "type_info": "Text" 20 - } 21 - ], 22 - "parameters": { 23 - "Left": [ 24 - "Uuid" 25 - ] 26 - }, 27 - "nullable": [ 28 - false, 29 - false, 30 - false 31 - ] 32 - }, 33 - "hash": "f405fc944c383ab9f50b805da3e4bf302e40698beac5b06d3d19abd185de21c1" 34 - }
-27
.sqlx/query-f6a7ab9916e50ee74e5ff41af4d7cc1b24f3ed740dc61b21d485ab6535037183.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n INSERT INTO account_backups (user_id, storage_key, repo_root_cid, repo_rev, block_count, size_bytes)\n VALUES ($1, $2, $3, $4, $5, $6)\n RETURNING id\n ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "id", 9 - "type_info": "Uuid" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Uuid", 15 - "Text", 16 - "Text", 17 - "Text", 18 - "Int4", 19 - "Int8" 20 - ] 21 - }, 22 - "nullable": [ 23 - false 24 - ] 25 - }, 26 - "hash": "f6a7ab9916e50ee74e5ff41af4d7cc1b24f3ed740dc61b21d485ab6535037183" 27 - }
-15
.sqlx/query-f71428b1ce982504cd531937131d49196ec092b4d13e9ae7dcdaedfe98de5a70.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "UPDATE users SET backup_enabled = $1 WHERE did = $2", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Bool", 9 - "Text" 10 - ] 11 - }, 12 - "nullable": [] 13 - }, 14 - "hash": "f71428b1ce982504cd531937131d49196ec092b4d13e9ae7dcdaedfe98de5a70" 15 - }
-14
.sqlx/query-f85f8d49bbd2d5e048bd8c29081aef5b8097e2384793e85df72eeeb858b7c532.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "DELETE FROM account_backups WHERE id = $1", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Uuid" 9 - ] 10 - }, 11 - "nullable": [] 12 - }, 13 - "hash": "f85f8d49bbd2d5e048bd8c29081aef5b8097e2384793e85df72eeeb858b7c532" 14 - }
-64
Cargo.lock
··· 111 111 checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" 112 112 113 113 [[package]] 114 - name = "arbitrary" 115 - version = "1.4.2" 116 - source = "registry+https://github.com/rust-lang/crates.io-index" 117 - checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" 118 - dependencies = [ 119 - "derive_arbitrary", 120 - ] 121 - 122 - [[package]] 123 114 name = "arc-swap" 124 115 version = "1.7.1" 125 116 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1630 1621 ] 1631 1622 1632 1623 [[package]] 1633 - name = "derive_arbitrary" 1634 - version = "1.4.2" 1635 - source = "registry+https://github.com/rust-lang/crates.io-index" 1636 - checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" 1637 - dependencies = [ 1638 - "proc-macro2", 1639 - "quote", 1640 - "syn 2.0.111", 1641 - ] 1642 - 1643 - [[package]] 1644 1624 name = "derive_more" 1645 1625 version = "1.0.0" 1646 1626 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1993 1973 checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" 1994 1974 dependencies = [ 1995 1975 "crc32fast", 1996 - "libz-rs-sys", 1997 1976 "miniz_oxide", 1998 1977 ] 1999 1978 ··· 3478 3457 dependencies = [ 3479 3458 "pkg-config", 3480 3459 "vcpkg", 3481 - ] 3482 - 3483 - [[package]] 3484 - name = "libz-rs-sys" 3485 - version = "0.5.5" 3486 - source = "registry+https://github.com/rust-lang/crates.io-index" 3487 - checksum = "c10501e7805cee23da17c7790e59df2870c0d4043ec6d03f67d31e2b53e77415" 3488 - dependencies = [ 3489 - "zlib-rs", 3490 3460 ] 3491 3461 3492 3462 [[package]] ··· 6316 6286 "ed25519-dalek", 6317 6287 "futures", 6318 6288 "governor", 6319 - "hex", 6320 6289 "hickory-resolver", 6321 6290 "hkdf", 6322 6291 "hmac", ··· 6360 6329 "webauthn-rs", 6361 6330 "webauthn-rs-proto", 6362 6331 "wiremock", 6363 - "zip", 6364 6332 ] 6365 6333 6366 6334 [[package]] ··· 7321 7289 "proc-macro2", 7322 7290 "quote", 7323 7291 "syn 2.0.111", 7324 - ] 7325 - 7326 - [[package]] 7327 - name = "zip" 7328 - version = "7.0.0" 7329 - source = "registry+https://github.com/rust-lang/crates.io-index" 7330 - checksum = "bdd8a47718a4ee5fe78e07667cd36f3de80e7c2bfe727c7074245ffc7303c037" 7331 - dependencies = [ 7332 - "arbitrary", 7333 - "crc32fast", 7334 - "flate2", 7335 - "indexmap 2.12.1", 7336 - "memchr", 7337 - "zopfli", 7338 - ] 7339 - 7340 - [[package]] 7341 - name = "zlib-rs" 7342 - version = "0.5.5" 7343 - source = "registry+https://github.com/rust-lang/crates.io-index" 7344 - checksum = "40990edd51aae2c2b6907af74ffb635029d5788228222c4bb811e9351c0caad3" 7345 - 7346 - [[package]] 7347 - name = "zopfli" 7348 - version = "0.8.3" 7349 - source = "registry+https://github.com/rust-lang/crates.io-index" 7350 - checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" 7351 - dependencies = [ 7352 - "bumpalo", 7353 - "crc32fast", 7354 - "log", 7355 - "simd-adler32", 7356 7292 ] 7357 7293 7358 7294 [[package]]
-2
Cargo.toml
··· 19 19 dotenvy = "0.15.7" 20 20 futures = "0.3.30" 21 21 governor = "0.10" 22 - hex = "0.4" 23 22 hkdf = "0.12" 24 23 hmac = "0.12" 25 24 aes-gcm = "0.10" ··· 63 62 totp-rs = { version = "5", features = ["qr"] } 64 63 webauthn-rs = { version = "0.5.4", features = ["danger-allow-state-serialisation", "danger-user-presence-only-security-keys"] } 65 64 webauthn-rs-proto = "0.5.4" 66 - zip = { version = "7.0.0", default-features = false, features = ["deflate"] } 67 65 [features] 68 66 external-infra = [] 69 67 [dev-dependencies]
+1 -1
README.md
··· 14 14 15 15 This software isn't an afterthought by a company with limited resources. 16 16 17 - It is a superset of the reference PDS, including: passkeys and 2FA (WebAuthn/FIDO2, TOTP, backup codes, trusted devices), did:web support (PDS-hosted subdomains or bring-your-own), multi-channel communication (email, discord, telegram, signal) for verification and alerts, granular OAuth scopes with a consent UI showing human-readable descriptions, app passwords with granular permissions (read-only, post-only, or custom scopes), account delegation (letting others manage an account with configurable permission levels), automatic backups to s3-compatible object storage (configurable retention and frequency, one-click restore), and a built-in web UI for account management, OAuth consent, repo browsing, and admin. 17 + It is a superset of the reference PDS, including: passkeys and 2FA (WebAuthn/FIDO2, TOTP, backup codes, trusted devices), did:web support (PDS-hosted subdomains or bring-your-own), multi-channel communication (email, discord, telegram, signal) for verification and alerts, granular OAuth scopes with a consent UI showing human-readable descriptions, app passwords with granular permissions (read-only, post-only, or custom scopes), account delegation (letting others manage an account with configurable permission levels), and a built-in web UI for account management, OAuth consent, repo browsing, and admin. 18 18 19 19 The PDS itself is a single small binary with no node/npm runtime. It does require postgres, valkey, and s3-compatible storage, which makes setup heavier than the reference PDS's sqlite. The tradeoff is that these are battle-tested pieces of infra that we already know how to scale, back up, and monitor. 20 20
+15 -2
TODO.md
··· 2 2 3 3 ## Active development 4 4 5 + ### Migration tool 6 + Seamless account migration built into the UI, inspired by pdsmoover. Users shouldn't need external tools or brain surgery on half-done account states. 7 + 8 + - [x] Inbound UI wizard: login to old PDS -> choose handle -> import -> PLC token flow 9 + - [x] Support `createAccount` with existing DID + service auth token 10 + - [x] Progress tracking with resume capability 11 + - [ ] Scheduled automatic backups (CAR export) 12 + - [ ] One-click restore from backup 13 + 14 + Outbound migration wizard exists but is disabled. Rethinking the approach: instead of a managed flow with `migratingTo` state, pds-hosted did:web users should just have direct control over their DID document. They can independently update serviceEndpoint, add/remove keys, export their repo, deactivate their account. 15 + 16 + - [ ] Remove `migratingTo` field and related state machine 17 + - [ ] Let did:web users edit their DID doc fields (serviceEndpoint, keys) whenever 18 + - [ ] Repo export as standalone feature, not tied to migration wizard 19 + 5 20 ### Plugin system 6 21 Extensible architecture allowing third-party plugins to add functionality. Going with wasm-based rather than scripting language. 7 22 ··· 54 69 App password scopes: Granular permissions for app passwords using the same scope system as OAuth. Preset buttons for common use cases (full access, read-only, post-only), scope stored in session and preserved across token refresh, explicit RPC/repo/blob scope enforcement for restricted passwords. 55 70 56 71 Account Delegation: Delegated accounts controlled by other accounts instead of passwords. OAuth delegation flow (authenticate as controller), scope-based permissions (owner/admin/editor/viewer presets), scope intersection (tokens limited to granted permissions), `act` claim for delegation tracking, creating delegated account flow, controller management UI, "act as" account switcher, comprehensive audit logging with actor/controller tracking, delegation-aware OAuth consent with permission limitation notices. 57 - 58 - Migration: OAuth-based inbound migration wizard with PLC token flow, offline restore from CAR file + rotation key for disaster recovery, scheduled automatic backups, standalone repo/blob export, did:web DID document editor for self-service identity management.
-94
frontend/deno.lock
··· 1 1 { 2 2 "version": "5", 3 3 "specifiers": { 4 - "npm:@atcute/cbor@^2.2.8": "2.2.8", 5 - "npm:@atcute/crypto@^2.3.0": "2.3.0", 6 - "npm:@atcute/did-plc@~0.3.1": "0.3.1", 7 - "npm:@atcute/multibase@^1.1.6": "1.1.6", 8 4 "npm:@noble/secp256k1@^2.1.0": "2.3.0", 9 5 "npm:@sveltejs/vite-plugin-svelte@5": "5.1.1_svelte@5.45.10__acorn@8.15.0_vite@6.4.1__picomatch@4.0.3", 10 6 "npm:@testing-library/jest-dom@^6.6.3": "6.9.1", ··· 34 30 "lru-cache" 35 31 ] 36 32 }, 37 - "@atcute/cbor@2.2.8": { 38 - "integrity": "sha512-UzOAN9BuN6JCXgn0ryV8qZuRJUDrNqrbLd6EFM8jc6RYssjRyGRxNy6RZ1NU/07Hd8Tq/0pz8+nQiMu5Zai5uw==", 39 - "dependencies": [ 40 - "@atcute/cid", 41 - "@atcute/multibase", 42 - "@atcute/uint8array" 43 - ] 44 - }, 45 - "@atcute/cid@2.3.0": { 46 - "integrity": "sha512-1SRdkTuMs/l5arQ+7Ag0F7JAueZqtzYE0d2gmbkuzi8EPweNU1kYlQs0CE4dSd81YF8PMDTOQty0K2ATq9CW9g==", 47 - "dependencies": [ 48 - "@atcute/multibase", 49 - "@atcute/uint8array" 50 - ] 51 - }, 52 - "@atcute/crypto@2.3.0": { 53 - "integrity": "sha512-w5pkJKCjbNMQu+F4JRHbR3ROQyhi1wbn+GSC6WDQamcYHkZmEZk1/eoI354bIQOOfkEM6aFLv718iskrkon4GQ==", 54 - "dependencies": [ 55 - "@atcute/multibase", 56 - "@atcute/uint8array", 57 - "@noble/secp256k1@3.0.0" 58 - ] 59 - }, 60 - "@atcute/did-plc@0.3.1": { 61 - "integrity": "sha512-KsuVdRtaaIPMmlcCDcxZzLg6OWm7rajczquhIHfA3s57+c34PFQbdY4Lsc2BvDwZ0fUjmbwzvQI3Zio2VcZa7w==", 62 - "dependencies": [ 63 - "@atcute/cbor", 64 - "@atcute/cid", 65 - "@atcute/crypto", 66 - "@atcute/identity", 67 - "@atcute/lexicons", 68 - "@atcute/multibase", 69 - "@atcute/uint8array", 70 - "@atcute/util-fetch", 71 - "@badrap/valita" 72 - ] 73 - }, 74 - "@atcute/identity@1.1.3": { 75 - "integrity": "sha512-oIqPoI8TwWeQxvcLmFEZLdN2XdWcaLVtlm8pNk0E72As9HNzzD9pwKPrLr3rmTLRIoULPPFmq9iFNsTeCIU9ng==", 76 - "dependencies": [ 77 - "@atcute/lexicons", 78 - "@badrap/valita" 79 - ] 80 - }, 81 - "@atcute/lexicons@1.2.6": { 82 - "integrity": "sha512-s76UQd8D+XmHIzrjD9CJ9SOOeeLPHc+sMmcj7UFakAW/dDFXc579fcRdRfuUKvXBL5v1Gs2VgDdlh/IvvQZAwA==", 83 - "dependencies": [ 84 - "@atcute/uint8array", 85 - "@atcute/util-text", 86 - "@standard-schema/spec", 87 - "esm-env" 88 - ] 89 - }, 90 - "@atcute/multibase@1.1.6": { 91 - "integrity": "sha512-HBxuCgYLKPPxETV0Rot4VP9e24vKl8JdzGCZOVsDaOXJgbRZoRIF67Lp0H/OgnJeH/Xpva8Z5ReoTNJE5dn3kg==", 92 - "dependencies": [ 93 - "@atcute/uint8array" 94 - ] 95 - }, 96 - "@atcute/uint8array@1.0.6": { 97 - "integrity": "sha512-ucfRBQc7BFT8n9eCyGOzDHEMKF/nZwhS2pPao4Xtab1ML3HdFYcX2DM1tadCzas85QTGxHe5urnUAAcNKGRi9A==" 98 - }, 99 - "@atcute/util-fetch@1.0.4": { 100 - "integrity": "sha512-sIU9Qk0dE8PLEXSfhy+gIJV+HpiiknMytCI2SqLlqd0vgZUtEKI/EQfP+23LHWvP+CLCzVDOa6cpH045OlmNBg==", 101 - "dependencies": [ 102 - "@badrap/valita" 103 - ] 104 - }, 105 - "@atcute/util-text@0.0.1": { 106 - "integrity": "sha512-t1KZqvn0AYy+h2KcJyHnKF9aEqfRfMUmyY8j1ELtAEIgqN9CxINAjxnoRCJIFUlvWzb+oY3uElQL/Vyk3yss0g==", 107 - "dependencies": [ 108 - "unicode-segmenter" 109 - ] 110 - }, 111 33 "@babel/code-frame@7.27.1": { 112 34 "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", 113 35 "dependencies": [ ··· 121 43 }, 122 44 "@babel/runtime@7.28.4": { 123 45 "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==" 124 - }, 125 - "@badrap/valita@0.4.6": { 126 - "integrity": "sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg==" 127 46 }, 128 47 "@csstools/color-helpers@5.1.0": { 129 48 "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==" ··· 579 498 "@noble/secp256k1@2.3.0": { 580 499 "integrity": "sha512-0TQed2gcBbIrh7Ccyw+y/uZQvbJwm7Ao4scBUxqpBCcsOlZG0O4KGfjtNAy/li4W8n1xt3dxrwJ0beZ2h2G6Kw==" 581 500 }, 582 - "@noble/secp256k1@3.0.0": { 583 - "integrity": "sha512-NJBaR352KyIvj3t6sgT/+7xrNyF9Xk9QlLSIqUGVUYlsnDTAUqY8LOmwpcgEx4AMJXRITQ5XEVHD+mMaPfr3mg==" 584 - }, 585 501 "@rollup/rollup-android-arm-eabi@4.53.3": { 586 502 "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", 587 503 "os": ["android"], ··· 691 607 "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", 692 608 "os": ["win32"], 693 609 "cpu": ["x64"] 694 - }, 695 - "@standard-schema/spec@1.1.0": { 696 - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==" 697 610 }, 698 611 "@sveltejs/acorn-typescript@1.0.8_acorn@8.15.0": { 699 612 "integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==", ··· 1632 1545 "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", 1633 1546 "bin": true 1634 1547 }, 1635 - "unicode-segmenter@0.14.4": { 1636 - "integrity": "sha512-pR5VCiCrLrKOL6FRW61jnk9+wyMtKKowq+jyFY9oc6uHbWKhDL4yVRiI4YZPksGMK72Pahh8m0cn/0JvbDDyJg==" 1637 - }, 1638 1548 "vite-node@2.1.9": { 1639 1549 "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", 1640 1550 "dependencies": [ ··· 1761 1671 "workspace": { 1762 1672 "packageJson": { 1763 1673 "dependencies": [ 1764 - "npm:@atcute/cbor@^2.2.8", 1765 - "npm:@atcute/crypto@^2.3.0", 1766 - "npm:@atcute/did-plc@~0.3.1", 1767 - "npm:@atcute/multibase@^1.1.6", 1768 1674 "npm:@noble/secp256k1@^2.1.0", 1769 1675 "npm:@sveltejs/vite-plugin-svelte@5", 1770 1676 "npm:@testing-library/jest-dom@^6.6.3",
-4
frontend/package.json
··· 12 12 "test:coverage": "vitest run --coverage" 13 13 }, 14 14 "dependencies": { 15 - "@atcute/cbor": "^2.2.8", 16 - "@atcute/crypto": "^2.3.0", 17 - "@atcute/did-plc": "^0.3.1", 18 - "@atcute/multibase": "^1.1.6", 19 15 "@noble/secp256k1": "^2.1.0", 20 16 "multiformats": "^13.3.1", 21 17 "svelte-i18n": "^4.0.1"
+2 -2
frontend/src/components/ReauthModal.svelte
··· 228 228 /> 229 229 </div> 230 230 <button type="submit" class="btn-primary" disabled={loading || !password}> 231 - {loading ? $_('common.verifying') : $_('common.verify')} 231 + {loading ? $_('reauth.verifying') : $_('reauth.verify')} 232 232 </button> 233 233 </form> 234 234 {:else if activeMethod === 'totp'} ··· 247 247 /> 248 248 </div> 249 249 <button type="submit" class="btn-primary" disabled={loading || !totpCode}> 250 - {loading ? $_('common.verifying') : $_('common.verify')} 250 + {loading ? $_('reauth.verifying') : $_('reauth.verify')} 251 251 </button> 252 252 </form> 253 253 {:else if activeMethod === 'passkey'}
-86
frontend/src/components/migration/AppPasswordStep.svelte
··· 1 - <script lang="ts"> 2 - import { _ } from '../../lib/i18n' 3 - 4 - interface Props { 5 - appPassword: string 6 - appPasswordName: string 7 - loading: boolean 8 - onContinue: () => void 9 - } 10 - 11 - let { 12 - appPassword, 13 - appPasswordName, 14 - loading, 15 - onContinue, 16 - }: Props = $props() 17 - 18 - let copied = $state(false) 19 - let acknowledged = $state(false) 20 - 21 - function copyPassword() { 22 - navigator.clipboard.writeText(appPassword) 23 - copied = true 24 - } 25 - </script> 26 - 27 - <div class="step-content"> 28 - <h2>{$_('migration.inbound.appPassword.title')}</h2> 29 - <p>{$_('migration.inbound.appPassword.desc')}</p> 30 - 31 - <div class="warning-box"> 32 - <strong>{$_('migration.inbound.appPassword.warning')}</strong> 33 - </div> 34 - 35 - <div class="app-password-display"> 36 - <div class="app-password-label"> 37 - {$_('migration.inbound.appPassword.label')}: <strong>{appPasswordName}</strong> 38 - </div> 39 - <code class="app-password-code">{appPassword}</code> 40 - <button type="button" class="copy-btn" onclick={copyPassword}> 41 - {copied ? $_('common.copied') : $_('common.copyToClipboard')} 42 - </button> 43 - </div> 44 - 45 - <label class="checkbox-label"> 46 - <input type="checkbox" bind:checked={acknowledged} /> 47 - <span>{$_('migration.inbound.appPassword.saved')}</span> 48 - </label> 49 - 50 - <div class="button-row"> 51 - <button onclick={onContinue} disabled={!acknowledged || loading}> 52 - {loading ? $_('migration.inbound.common.continue') : $_('migration.inbound.appPassword.continue')} 53 - </button> 54 - </div> 55 - </div> 56 - 57 - <style> 58 - .app-password-display { 59 - background: var(--bg-card); 60 - border: 2px solid var(--accent); 61 - border-radius: var(--radius-xl); 62 - padding: var(--space-6); 63 - text-align: center; 64 - margin: var(--space-4) 0; 65 - } 66 - .app-password-label { 67 - font-size: var(--text-sm); 68 - color: var(--text-secondary); 69 - margin-bottom: var(--space-4); 70 - } 71 - .app-password-code { 72 - display: block; 73 - font-size: var(--text-xl); 74 - font-family: ui-monospace, monospace; 75 - letter-spacing: 0.1em; 76 - padding: var(--space-5); 77 - background: var(--bg-input); 78 - border-radius: var(--radius-md); 79 - margin-bottom: var(--space-4); 80 - user-select: all; 81 - } 82 - .copy-btn { 83 - padding: var(--space-3) var(--space-5); 84 - font-size: var(--text-sm); 85 - } 86 - </style>
-185
frontend/src/components/migration/ChooseHandleStep.svelte
··· 1 - <script lang="ts"> 2 - import type { AuthMethod, ServerDescription } from '../../lib/migration/types' 3 - import { _ } from '../../lib/i18n' 4 - 5 - interface Props { 6 - handleInput: string 7 - selectedDomain: string 8 - handleAvailable: boolean | null 9 - checkingHandle: boolean 10 - email: string 11 - password: string 12 - authMethod: AuthMethod 13 - inviteCode: string 14 - serverInfo: ServerDescription | null 15 - migratingFromLabel: string 16 - migratingFromValue: string 17 - loading?: boolean 18 - onHandleChange: (handle: string) => void 19 - onDomainChange: (domain: string) => void 20 - onCheckHandle: () => void 21 - onEmailChange: (email: string) => void 22 - onPasswordChange: (password: string) => void 23 - onAuthMethodChange: (method: AuthMethod) => void 24 - onInviteCodeChange: (code: string) => void 25 - onBack: () => void 26 - onContinue: () => void 27 - } 28 - 29 - let { 30 - handleInput, 31 - selectedDomain, 32 - handleAvailable, 33 - checkingHandle, 34 - email, 35 - password, 36 - authMethod, 37 - inviteCode, 38 - serverInfo, 39 - migratingFromLabel, 40 - migratingFromValue, 41 - loading = false, 42 - onHandleChange, 43 - onDomainChange, 44 - onCheckHandle, 45 - onEmailChange, 46 - onPasswordChange, 47 - onAuthMethodChange, 48 - onInviteCodeChange, 49 - onBack, 50 - onContinue, 51 - }: Props = $props() 52 - 53 - const canContinue = $derived( 54 - handleInput.trim() && 55 - email && 56 - (authMethod === 'passkey' || password) && 57 - handleAvailable !== false 58 - ) 59 - </script> 60 - 61 - <div class="step-content"> 62 - <h2>{$_('migration.inbound.chooseHandle.title')}</h2> 63 - <p>{$_('migration.inbound.chooseHandle.desc')}</p> 64 - 65 - <div class="current-info"> 66 - <span class="label">{migratingFromLabel}:</span> 67 - <span class="value">{migratingFromValue}</span> 68 - </div> 69 - 70 - <div class="field"> 71 - <label for="new-handle">{$_('migration.inbound.chooseHandle.newHandle')}</label> 72 - <div class="handle-input-group"> 73 - <input 74 - id="new-handle" 75 - type="text" 76 - placeholder="username" 77 - value={handleInput} 78 - oninput={(e) => onHandleChange((e.target as HTMLInputElement).value)} 79 - onblur={onCheckHandle} 80 - /> 81 - {#if serverInfo && serverInfo.availableUserDomains.length > 0 && !handleInput.includes('.')} 82 - <select value={selectedDomain} onchange={(e) => onDomainChange((e.target as HTMLSelectElement).value)}> 83 - {#each serverInfo.availableUserDomains as domain} 84 - <option value={domain}>.{domain}</option> 85 - {/each} 86 - </select> 87 - {/if} 88 - </div> 89 - 90 - {#if checkingHandle} 91 - <p class="hint">{$_('migration.inbound.chooseHandle.checkingAvailability')}</p> 92 - {:else if handleAvailable === true} 93 - <p class="hint" style="color: var(--success-text)">{$_('migration.inbound.chooseHandle.handleAvailable')}</p> 94 - {:else if handleAvailable === false} 95 - <p class="hint error">{$_('migration.inbound.chooseHandle.handleTaken')}</p> 96 - {:else} 97 - <p class="hint">{$_('migration.inbound.chooseHandle.handleHint')}</p> 98 - {/if} 99 - </div> 100 - 101 - <div class="field"> 102 - <label for="email">{$_('migration.inbound.chooseHandle.email')}</label> 103 - <input 104 - id="email" 105 - type="email" 106 - placeholder="you@example.com" 107 - value={email} 108 - oninput={(e) => onEmailChange((e.target as HTMLInputElement).value)} 109 - required 110 - /> 111 - </div> 112 - 113 - <div class="field"> 114 - <label>{$_('migration.inbound.chooseHandle.authMethod')}</label> 115 - <div class="auth-method-options"> 116 - <label class="auth-option" class:selected={authMethod === 'password'}> 117 - <input 118 - type="radio" 119 - name="auth-method" 120 - value="password" 121 - checked={authMethod === 'password'} 122 - onchange={() => onAuthMethodChange('password')} 123 - /> 124 - <div class="auth-option-content"> 125 - <strong>{$_('migration.inbound.chooseHandle.authPassword')}</strong> 126 - <span>{$_('migration.inbound.chooseHandle.authPasswordDesc')}</span> 127 - </div> 128 - </label> 129 - <label class="auth-option" class:selected={authMethod === 'passkey'}> 130 - <input 131 - type="radio" 132 - name="auth-method" 133 - value="passkey" 134 - checked={authMethod === 'passkey'} 135 - onchange={() => onAuthMethodChange('passkey')} 136 - /> 137 - <div class="auth-option-content"> 138 - <strong>{$_('migration.inbound.chooseHandle.authPasskey')}</strong> 139 - <span>{$_('migration.inbound.chooseHandle.authPasskeyDesc')}</span> 140 - </div> 141 - </label> 142 - </div> 143 - </div> 144 - 145 - {#if authMethod === 'password'} 146 - <div class="field"> 147 - <label for="new-password">{$_('migration.inbound.chooseHandle.password')}</label> 148 - <input 149 - id="new-password" 150 - type="password" 151 - placeholder="Password for your new account" 152 - value={password} 153 - oninput={(e) => onPasswordChange((e.target as HTMLInputElement).value)} 154 - required 155 - minlength={8} 156 - /> 157 - <p class="hint">{$_('migration.inbound.chooseHandle.passwordHint')}</p> 158 - </div> 159 - {:else} 160 - <div class="info-box"> 161 - <p>{$_('migration.inbound.chooseHandle.passkeyInfo')}</p> 162 - </div> 163 - {/if} 164 - 165 - {#if serverInfo?.inviteCodeRequired} 166 - <div class="field"> 167 - <label for="invite">{$_('migration.inbound.chooseHandle.inviteCode')}</label> 168 - <input 169 - id="invite" 170 - type="text" 171 - placeholder="Enter invite code" 172 - value={inviteCode} 173 - oninput={(e) => onInviteCodeChange((e.target as HTMLInputElement).value)} 174 - required 175 - /> 176 - </div> 177 - {/if} 178 - 179 - <div class="button-row"> 180 - <button class="ghost" onclick={onBack} disabled={loading}>{$_('migration.inbound.common.back')}</button> 181 - <button disabled={!canContinue || loading} onclick={onContinue}> 182 - {$_('migration.inbound.common.continue')} 183 - </button> 184 - </div> 185 - </div>
-64
frontend/src/components/migration/EmailVerifyStep.svelte
··· 1 - <script lang="ts"> 2 - import { _ } from '../../lib/i18n' 3 - 4 - interface Props { 5 - email: string 6 - token: string 7 - loading: boolean 8 - error: string | null 9 - onTokenChange: (token: string) => void 10 - onSubmit: (e: Event) => void 11 - onResend: () => void 12 - } 13 - 14 - let { 15 - email, 16 - token, 17 - loading, 18 - error, 19 - onTokenChange, 20 - onSubmit, 21 - onResend, 22 - }: Props = $props() 23 - </script> 24 - 25 - <div class="step-content"> 26 - <h2>{$_('migration.inbound.emailVerify.title')}</h2> 27 - <p>{@html $_('migration.inbound.emailVerify.desc', { values: { email: `<strong>${email}</strong>` } })}</p> 28 - 29 - <div class="info-box"> 30 - <p> 31 - {$_('migration.inbound.emailVerify.hint')} 32 - </p> 33 - </div> 34 - 35 - {#if error} 36 - <div class="message error"> 37 - {error} 38 - </div> 39 - {/if} 40 - 41 - <form onsubmit={onSubmit}> 42 - <div class="field"> 43 - <label for="email-verify-token">{$_('migration.inbound.emailVerify.tokenLabel')}</label> 44 - <input 45 - id="email-verify-token" 46 - type="text" 47 - placeholder={$_('migration.inbound.emailVerify.tokenPlaceholder')} 48 - value={token} 49 - oninput={(e) => onTokenChange((e.target as HTMLInputElement).value)} 50 - disabled={loading} 51 - required 52 - /> 53 - </div> 54 - 55 - <div class="button-row"> 56 - <button type="button" class="ghost" onclick={onResend} disabled={loading}> 57 - {$_('migration.inbound.emailVerify.resend')} 58 - </button> 59 - <button type="submit" disabled={loading || !token}> 60 - {loading ? $_('common.verifying') : $_('common.verify')} 61 - </button> 62 - </div> 63 - </form> 64 - </div>
-23
frontend/src/components/migration/ErrorStep.svelte
··· 1 - <script lang="ts"> 2 - import { _ } from '../../lib/i18n' 3 - 4 - interface Props { 5 - error: string | null 6 - onStartOver: () => void 7 - } 8 - 9 - let { error, onStartOver }: Props = $props() 10 - </script> 11 - 12 - <div class="step-content"> 13 - <h2>{$_('migration.inbound.error.title')}</h2> 14 - <p>{$_('migration.inbound.error.desc')}</p> 15 - 16 - <div class="message error"> 17 - {error || $_('migration.inbound.error.unknown')} 18 - </div> 19 - 20 - <div class="button-row"> 21 - <button class="ghost" onclick={onStartOver}>{$_('migration.inbound.error.startOver')}</button> 22 - </div> 23 - </div>
+306 -64
frontend/src/components/migration/InboundWizard.svelte
··· 5 5 import { base64UrlEncode, prepareWebAuthnCreationOptions } from '../../lib/migration/atproto-client' 6 6 import { _ } from '../../lib/i18n' 7 7 import '../../styles/migration.css' 8 - import ErrorStep from './ErrorStep.svelte' 9 - import SuccessStep from './SuccessStep.svelte' 10 - import ChooseHandleStep from './ChooseHandleStep.svelte' 11 - import EmailVerifyStep from './EmailVerifyStep.svelte' 12 - import PasskeySetupStep from './PasskeySetupStep.svelte' 13 - import AppPasswordStep from './AppPasswordStep.svelte' 14 8 15 9 interface ResumeInfo { 16 - direction: 'inbound' 10 + direction: 'inbound' | 'outbound' 17 11 sourceHandle: string 18 12 targetHandle: string 19 13 sourcePdsUrl: string ··· 43 37 let checkingHandle = $state(false) 44 38 let selectedAuthMethod = $state<AuthMethod>('password') 45 39 let passkeyName = $state('') 40 + let appPasswordCopied = $state(false) 41 + let appPasswordAcknowledged = $state(false) 46 42 47 43 const isResuming = $derived(flow.state.needsReauth === true) 48 44 const isDidWeb = $derived(flow.state.sourceDid.startsWith("did:web:")) ··· 238 234 } 239 235 } 240 236 237 + function copyAppPassword() { 238 + if (flow.state.generatedAppPassword) { 239 + navigator.clipboard.writeText(flow.state.generatedAppPassword) 240 + appPasswordCopied = true 241 + } 242 + } 243 + 241 244 async function handleProceedFromAppPassword() { 242 245 loading = true 243 246 try { ··· 349 352 </label> 350 353 351 354 <div class="button-row"> 352 - <button type="button" class="ghost" onclick={onBack}>{$_('migration.inbound.common.cancel')}</button> 353 - <button type="button" disabled={!understood} onclick={() => flow.setStep('source-handle')}> 355 + <button class="ghost" onclick={onBack}>{$_('migration.inbound.common.cancel')}</button> 356 + <button disabled={!understood} onclick={() => flow.setStep('source-handle')}> 354 357 {$_('migration.inbound.common.continue')} 355 358 </button> 356 359 </div> ··· 406 409 </div> 407 410 408 411 {:else if flow.state.step === 'choose-handle'} 409 - <ChooseHandleStep 410 - {handleInput} 411 - {selectedDomain} 412 - {handleAvailable} 413 - {checkingHandle} 414 - email={flow.state.targetEmail} 415 - password={flow.state.targetPassword} 416 - authMethod={selectedAuthMethod} 417 - inviteCode={flow.state.inviteCode} 418 - {serverInfo} 419 - migratingFromLabel={$_('migration.inbound.chooseHandle.migratingFrom')} 420 - migratingFromValue={flow.state.sourceHandle} 421 - {loading} 422 - onHandleChange={(h) => handleInput = h} 423 - onDomainChange={(d) => selectedDomain = d} 424 - onCheckHandle={checkHandle} 425 - onEmailChange={(e) => flow.updateField('targetEmail', e)} 426 - onPasswordChange={(p) => flow.updateField('targetPassword', p)} 427 - onAuthMethodChange={(m) => selectedAuthMethod = m} 428 - onInviteCodeChange={(c) => flow.updateField('inviteCode', c)} 429 - onBack={() => flow.setStep('source-handle')} 430 - onContinue={proceedToReviewWithAuth} 431 - /> 412 + <div class="step-content"> 413 + <h2>{$_('migration.inbound.chooseHandle.title')}</h2> 414 + <p>{$_('migration.inbound.chooseHandle.desc')}</p> 415 + 416 + <div class="current-info"> 417 + <span class="label">{$_('migration.inbound.chooseHandle.migratingFrom')}:</span> 418 + <span class="value">{flow.state.sourceHandle}</span> 419 + </div> 420 + 421 + <div class="field"> 422 + <label for="new-handle">{$_('migration.inbound.chooseHandle.newHandle')}</label> 423 + <div class="handle-input-group"> 424 + <input 425 + id="new-handle" 426 + type="text" 427 + placeholder="username" 428 + bind:value={handleInput} 429 + onblur={checkHandle} 430 + /> 431 + {#if serverInfo && serverInfo.availableUserDomains.length > 0 && !handleInput.includes('.')} 432 + <select bind:value={selectedDomain}> 433 + {#each serverInfo.availableUserDomains as domain} 434 + <option value={domain}>.{domain}</option> 435 + {/each} 436 + </select> 437 + {/if} 438 + </div> 439 + 440 + {#if checkingHandle} 441 + <p class="hint">{$_('migration.inbound.chooseHandle.checkingAvailability')}</p> 442 + {:else if handleAvailable === true} 443 + <p class="hint" style="color: var(--success-text)">{$_('migration.inbound.chooseHandle.handleAvailable')}</p> 444 + {:else if handleAvailable === false} 445 + <p class="hint error">{$_('migration.inbound.chooseHandle.handleTaken')}</p> 446 + {:else} 447 + <p class="hint">{$_('migration.inbound.chooseHandle.handleHint')}</p> 448 + {/if} 449 + </div> 450 + 451 + <div class="field"> 452 + <label for="email">{$_('migration.inbound.chooseHandle.email')}</label> 453 + <input 454 + id="email" 455 + type="email" 456 + placeholder="you@example.com" 457 + bind:value={flow.state.targetEmail} 458 + oninput={(e) => flow.updateField('targetEmail', (e.target as HTMLInputElement).value)} 459 + required 460 + /> 461 + </div> 462 + 463 + <div class="field"> 464 + <label>{$_('migration.inbound.chooseHandle.authMethod')}</label> 465 + <div class="auth-method-options"> 466 + <label class="auth-option" class:selected={selectedAuthMethod === 'password'}> 467 + <input 468 + type="radio" 469 + name="auth-method" 470 + value="password" 471 + bind:group={selectedAuthMethod} 472 + /> 473 + <div class="auth-option-content"> 474 + <strong>{$_('migration.inbound.chooseHandle.authPassword')}</strong> 475 + <span>{$_('migration.inbound.chooseHandle.authPasswordDesc')}</span> 476 + </div> 477 + </label> 478 + <label class="auth-option" class:selected={selectedAuthMethod === 'passkey'}> 479 + <input 480 + type="radio" 481 + name="auth-method" 482 + value="passkey" 483 + bind:group={selectedAuthMethod} 484 + /> 485 + <div class="auth-option-content"> 486 + <strong>{$_('migration.inbound.chooseHandle.authPasskey')}</strong> 487 + <span>{$_('migration.inbound.chooseHandle.authPasskeyDesc')}</span> 488 + </div> 489 + </label> 490 + </div> 491 + </div> 492 + 493 + {#if selectedAuthMethod === 'password'} 494 + <div class="field"> 495 + <label for="new-password">{$_('migration.inbound.chooseHandle.password')}</label> 496 + <input 497 + id="new-password" 498 + type="password" 499 + placeholder="Password for your new account" 500 + bind:value={flow.state.targetPassword} 501 + oninput={(e) => flow.updateField('targetPassword', (e.target as HTMLInputElement).value)} 502 + required 503 + minlength="8" 504 + /> 505 + <p class="hint">{$_('migration.inbound.chooseHandle.passwordHint')}</p> 506 + </div> 507 + {:else} 508 + <div class="info-box"> 509 + <p>{$_('migration.inbound.chooseHandle.passkeyInfo')}</p> 510 + </div> 511 + {/if} 512 + 513 + {#if serverInfo?.inviteCodeRequired} 514 + <div class="field"> 515 + <label for="invite">{$_('migration.inbound.chooseHandle.inviteCode')}</label> 516 + <input 517 + id="invite" 518 + type="text" 519 + placeholder="Enter invite code" 520 + bind:value={flow.state.inviteCode} 521 + oninput={(e) => flow.updateField('inviteCode', (e.target as HTMLInputElement).value)} 522 + required 523 + /> 524 + </div> 525 + {/if} 526 + 527 + <div class="button-row"> 528 + <button class="ghost" onclick={() => flow.setStep('source-handle')}>{$_('migration.inbound.common.back')}</button> 529 + <button 530 + disabled={!handleInput.trim() || !flow.state.targetEmail || (selectedAuthMethod === 'password' && !flow.state.targetPassword) || handleAvailable === false} 531 + onclick={proceedToReviewWithAuth} 532 + > 533 + {$_('migration.inbound.common.continue')} 534 + </button> 535 + </div> 536 + </div> 432 537 433 538 {:else if flow.state.step === 'review'} 434 539 <div class="step-content"> ··· 515 620 </div> 516 621 517 622 {:else if flow.state.step === 'passkey-setup'} 518 - <PasskeySetupStep 519 - {passkeyName} 520 - {loading} 521 - error={flow.state.error} 522 - onPasskeyNameChange={(n) => passkeyName = n} 523 - onRegister={registerPasskey} 524 - /> 623 + <div class="step-content"> 624 + <h2>{$_('migration.inbound.passkeySetup.title')}</h2> 625 + <p>{$_('migration.inbound.passkeySetup.desc')}</p> 626 + 627 + {#if flow.state.error} 628 + <div class="message error"> 629 + {flow.state.error} 630 + </div> 631 + {/if} 632 + 633 + <div class="field"> 634 + <label for="passkey-name">{$_('migration.inbound.passkeySetup.nameLabel')}</label> 635 + <input 636 + id="passkey-name" 637 + type="text" 638 + placeholder={$_('migration.inbound.passkeySetup.namePlaceholder')} 639 + bind:value={passkeyName} 640 + disabled={loading} 641 + /> 642 + <p class="hint">{$_('migration.inbound.passkeySetup.nameHint')}</p> 643 + </div> 644 + 645 + <div class="passkey-section"> 646 + <p>{$_('migration.inbound.passkeySetup.instructions')}</p> 647 + <button class="primary" onclick={registerPasskey} disabled={loading}> 648 + {loading ? $_('migration.inbound.passkeySetup.registering') : $_('migration.inbound.passkeySetup.register')} 649 + </button> 650 + </div> 651 + </div> 525 652 526 653 {:else if flow.state.step === 'app-password'} 527 - <AppPasswordStep 528 - appPassword={flow.state.generatedAppPassword || ''} 529 - appPasswordName={flow.state.generatedAppPasswordName || ''} 530 - {loading} 531 - onContinue={handleProceedFromAppPassword} 532 - /> 654 + <div class="step-content"> 655 + <h2>{$_('migration.inbound.appPassword.title')}</h2> 656 + <p>{$_('migration.inbound.appPassword.desc')}</p> 657 + 658 + <div class="warning-box"> 659 + <strong>{$_('migration.inbound.appPassword.warning')}</strong> 660 + </div> 661 + 662 + <div class="app-password-display"> 663 + <div class="app-password-label"> 664 + {$_('migration.inbound.appPassword.label')}: <strong>{flow.state.generatedAppPasswordName}</strong> 665 + </div> 666 + <code class="app-password-code">{flow.state.generatedAppPassword}</code> 667 + <button type="button" class="copy-btn" onclick={copyAppPassword}> 668 + {appPasswordCopied ? $_('common.copied') : $_('common.copyToClipboard')} 669 + </button> 670 + </div> 671 + 672 + <label class="checkbox-label"> 673 + <input type="checkbox" bind:checked={appPasswordAcknowledged} /> 674 + <span>{$_('migration.inbound.appPassword.saved')}</span> 675 + </label> 676 + 677 + <div class="button-row"> 678 + <button onclick={handleProceedFromAppPassword} disabled={!appPasswordAcknowledged || loading}> 679 + {loading ? $_('migration.inbound.common.continue') : $_('migration.inbound.appPassword.continue')} 680 + </button> 681 + </div> 682 + </div> 533 683 534 684 {:else if flow.state.step === 'email-verify'} 535 - <EmailVerifyStep 536 - email={flow.state.targetEmail} 537 - token={flow.state.emailVerifyToken} 538 - {loading} 539 - error={flow.state.error} 540 - onTokenChange={(t) => flow.updateField('emailVerifyToken', t)} 541 - onSubmit={submitEmailVerify} 542 - onResend={resendEmailVerify} 543 - /> 685 + <div class="step-content"> 686 + <h2>{$_('migration.inbound.emailVerify.title')}</h2> 687 + <p>{@html $_('migration.inbound.emailVerify.desc', { values: { email: `<strong>${flow.state.targetEmail}</strong>` } })}</p> 688 + 689 + <div class="info-box"> 690 + <p> 691 + {$_('migration.inbound.emailVerify.hint')} 692 + </p> 693 + </div> 694 + 695 + {#if flow.state.error} 696 + <div class="message error"> 697 + {flow.state.error} 698 + </div> 699 + {/if} 700 + 701 + <form onsubmit={submitEmailVerify}> 702 + <div class="field"> 703 + <label for="email-verify-token">{$_('migration.inbound.emailVerify.tokenLabel')}</label> 704 + <input 705 + id="email-verify-token" 706 + type="text" 707 + placeholder={$_('migration.inbound.emailVerify.tokenPlaceholder')} 708 + bind:value={flow.state.emailVerifyToken} 709 + oninput={(e) => flow.updateField('emailVerifyToken', (e.target as HTMLInputElement).value)} 710 + disabled={loading} 711 + required 712 + /> 713 + </div> 714 + 715 + <div class="button-row"> 716 + <button type="button" class="ghost" onclick={resendEmailVerify} disabled={loading}> 717 + {$_('migration.inbound.emailVerify.resend')} 718 + </button> 719 + <button type="submit" disabled={loading || !flow.state.emailVerifyToken}> 720 + {loading ? $_('migration.inbound.emailVerify.verifying') : $_('migration.inbound.emailVerify.verify')} 721 + </button> 722 + </div> 723 + </form> 724 + </div> 544 725 545 726 {:else if flow.state.step === 'plc-token'} 546 727 <div class="step-content"> ··· 656 837 </div> 657 838 658 839 {:else if flow.state.step === 'success'} 659 - <SuccessStep handle={flow.state.targetHandle} did={flow.state.sourceDid}> 660 - {#snippet extraContent()} 661 - {#if flow.state.progress.blobsFailed.length > 0} 662 - <div class="message warning"> 663 - {$_('migration.inbound.success.blobsWarning', { values: { count: flow.state.progress.blobsFailed.length } })} 664 - </div> 665 - {/if} 666 - {/snippet} 667 - </SuccessStep> 840 + <div class="step-content success-content"> 841 + <div class="success-icon">โœ“</div> 842 + <h2>{$_('migration.inbound.success.title')}</h2> 843 + <p>{$_('migration.inbound.success.desc')}</p> 844 + 845 + <div class="success-details"> 846 + <div class="detail-row"> 847 + <span class="label">{$_('migration.inbound.success.yourNewHandle')}:</span> 848 + <span class="value">{flow.state.targetHandle}</span> 849 + </div> 850 + <div class="detail-row"> 851 + <span class="label">{$_('migration.inbound.success.did')}:</span> 852 + <span class="value mono">{flow.state.sourceDid}</span> 853 + </div> 854 + </div> 855 + 856 + {#if flow.state.progress.blobsFailed.length > 0} 857 + <div class="message warning"> 858 + {$_('migration.inbound.success.blobsWarning', { values: { count: flow.state.progress.blobsFailed.length } })} 859 + </div> 860 + {/if} 861 + 862 + <p class="redirect-text">{$_('migration.inbound.success.redirecting')}</p> 863 + </div> 668 864 669 865 {:else if flow.state.step === 'error'} 670 - <ErrorStep error={flow.state.error} onStartOver={onBack} /> 866 + <div class="step-content"> 867 + <h2>{$_('migration.inbound.error.title')}</h2> 868 + <p>{$_('migration.inbound.error.desc')}</p> 869 + 870 + <div class="message error"> 871 + {flow.state.error || 'An unknown error occurred. Please check the browser console for details.'} 872 + </div> 873 + 874 + <div class="button-row"> 875 + <button class="ghost" onclick={onBack}>{$_('migration.inbound.error.startOver')}</button> 876 + </div> 877 + </div> 671 878 {/if} 672 879 </div> 673 880 674 881 <style> 882 + .passkey-section { 883 + margin-top: 16px; 884 + } 885 + .passkey-section button { 886 + width: 100%; 887 + margin-top: 12px; 888 + } 889 + .app-password-display { 890 + background: var(--bg-card); 891 + border: 2px solid var(--accent); 892 + border-radius: var(--radius-xl); 893 + padding: var(--space-6); 894 + text-align: center; 895 + margin: var(--space-4) 0; 896 + } 897 + .app-password-label { 898 + font-size: var(--text-sm); 899 + color: var(--text-secondary); 900 + margin-bottom: var(--space-4); 901 + } 902 + .app-password-code { 903 + display: block; 904 + font-size: var(--text-xl); 905 + font-family: ui-monospace, monospace; 906 + letter-spacing: 0.1em; 907 + padding: var(--space-5); 908 + background: var(--bg-input); 909 + border-radius: var(--radius-md); 910 + margin-bottom: var(--space-4); 911 + user-select: all; 912 + } 913 + .copy-btn { 914 + padding: var(--space-3) var(--space-5); 915 + font-size: var(--text-sm); 916 + } 675 917 .resume-info { 676 918 margin-bottom: var(--space-5); 677 919 }
-591
frontend/src/components/migration/OfflineInboundWizard.svelte
··· 1 - <script lang="ts"> 2 - import type { OfflineInboundMigrationFlow } from '../../lib/migration' 3 - import type { AuthMethod, ServerDescription } from '../../lib/migration/types' 4 - import { getErrorMessage } from '../../lib/migration/types' 5 - import { base64UrlEncode, prepareWebAuthnCreationOptions } from '../../lib/migration/atproto-client' 6 - import { _ } from '../../lib/i18n' 7 - import '../../styles/migration.css' 8 - import ErrorStep from './ErrorStep.svelte' 9 - import SuccessStep from './SuccessStep.svelte' 10 - import ChooseHandleStep from './ChooseHandleStep.svelte' 11 - import EmailVerifyStep from './EmailVerifyStep.svelte' 12 - import PasskeySetupStep from './PasskeySetupStep.svelte' 13 - import AppPasswordStep from './AppPasswordStep.svelte' 14 - 15 - interface Props { 16 - flow: OfflineInboundMigrationFlow 17 - onBack: () => void 18 - onComplete: () => void 19 - } 20 - 21 - let { flow, onBack, onComplete }: Props = $props() 22 - 23 - let serverInfo = $state<ServerDescription | null>(null) 24 - let loading = $state(false) 25 - let understood = $state(false) 26 - let handleInput = $state('') 27 - let selectedDomain = $state('') 28 - let handleAvailable = $state<boolean | null>(null) 29 - let checkingHandle = $state(false) 30 - let validatingKey = $state(false) 31 - let keyValid = $state<boolean | null>(null) 32 - let fileInputRef = $state<HTMLInputElement | null>(null) 33 - let selectedAuthMethod = $state<AuthMethod>('password') 34 - let passkeyName = $state('') 35 - 36 - let redirectTriggered = $state(false) 37 - 38 - $effect(() => { 39 - if (flow.state.step === 'welcome' || flow.state.step === 'choose-handle') { 40 - loadServerInfo() 41 - } 42 - if (flow.state.step === 'choose-handle') { 43 - handleInput = '' 44 - handleAvailable = null 45 - } 46 - }) 47 - 48 - $effect(() => { 49 - if (flow.state.step === 'success' && !redirectTriggered) { 50 - redirectTriggered = true 51 - setTimeout(() => { 52 - onComplete() 53 - }, 2000) 54 - } 55 - }) 56 - 57 - $effect(() => { 58 - if (flow.state.step === 'email-verify') { 59 - const interval = setInterval(async () => { 60 - if (flow.state.emailVerifyToken.trim()) return 61 - await flow.checkEmailVerifiedAndProceed() 62 - }, 3000) 63 - return () => clearInterval(interval) 64 - } 65 - }) 66 - 67 - async function loadServerInfo() { 68 - if (!serverInfo) { 69 - serverInfo = await flow.loadLocalServerInfo() 70 - if (serverInfo.availableUserDomains.length > 0) { 71 - selectedDomain = serverInfo.availableUserDomains[0] 72 - } 73 - } 74 - } 75 - 76 - function handleFileSelect(e: Event) { 77 - const input = e.target as HTMLInputElement 78 - const file = input.files?.[0] 79 - if (!file) return 80 - 81 - const reader = new FileReader() 82 - reader.onload = () => { 83 - const arrayBuffer = reader.result as ArrayBuffer 84 - flow.setCarFile(new Uint8Array(arrayBuffer), file.name) 85 - } 86 - reader.readAsArrayBuffer(file) 87 - } 88 - 89 - async function validateRotationKey() { 90 - if (!flow.state.rotationKey || !flow.state.userDid) return 91 - 92 - validatingKey = true 93 - keyValid = null 94 - 95 - try { 96 - const isValid = await flow.validateRotationKey() 97 - keyValid = isValid 98 - if (isValid) { 99 - flow.setStep('choose-handle') 100 - } 101 - } catch (err) { 102 - flow.setError(getErrorMessage(err)) 103 - keyValid = false 104 - } finally { 105 - validatingKey = false 106 - } 107 - } 108 - 109 - async function startMigration() { 110 - loading = true 111 - try { 112 - await flow.runMigration() 113 - } catch (err) { 114 - flow.setError(getErrorMessage(err)) 115 - } finally { 116 - loading = false 117 - } 118 - } 119 - 120 - const steps = $derived( 121 - flow.state.authMethod === 'passkey' 122 - ? ['Enter DID', 'Upload CAR', 'Rotation Key', 'Handle', 'Review', 'Import', 'Blobs', 'Verify Email', 'Passkey', 'App Password', 'Complete'] 123 - : ['Enter DID', 'Upload CAR', 'Rotation Key', 'Handle', 'Review', 'Import', 'Blobs', 'Verify Email', 'Complete'] 124 - ) 125 - 126 - function getCurrentStepIndex(): number { 127 - const isPasskey = flow.state.authMethod === 'passkey' 128 - switch (flow.state.step) { 129 - case 'welcome': return 0 130 - case 'provide-did': return 0 131 - case 'upload-car': return 1 132 - case 'provide-rotation-key': return 2 133 - case 'choose-handle': return 3 134 - case 'review': return 4 135 - case 'creating': 136 - case 'importing': return 5 137 - case 'migrating-blobs': return 6 138 - case 'email-verify': return 7 139 - case 'passkey-setup': return isPasskey ? 8 : 7 140 - case 'app-password': return 9 141 - case 'plc-signing': 142 - case 'finalizing': return isPasskey ? 10 : 8 143 - case 'success': return isPasskey ? 10 : 8 144 - default: return 0 145 - } 146 - } 147 - 148 - async function checkHandle() { 149 - if (!handleInput.trim()) return 150 - 151 - const fullHandle = handleInput.includes('.') 152 - ? handleInput 153 - : `${handleInput}.${selectedDomain}` 154 - 155 - checkingHandle = true 156 - handleAvailable = null 157 - 158 - try { 159 - handleAvailable = await flow.checkHandleAvailability(fullHandle) 160 - } catch { 161 - handleAvailable = true 162 - } finally { 163 - checkingHandle = false 164 - } 165 - } 166 - 167 - function proceedToReview() { 168 - const fullHandle = handleInput.includes('.') 169 - ? handleInput 170 - : `${handleInput}.${selectedDomain}` 171 - 172 - flow.setTargetHandle(fullHandle) 173 - flow.setAuthMethod(selectedAuthMethod) 174 - flow.setStep('review') 175 - } 176 - 177 - async function submitEmailVerify(e: Event) { 178 - e.preventDefault() 179 - loading = true 180 - try { 181 - await flow.submitEmailVerifyToken(flow.state.emailVerifyToken) 182 - } catch (err) { 183 - flow.setError(getErrorMessage(err)) 184 - } finally { 185 - loading = false 186 - } 187 - } 188 - 189 - async function resendEmailVerify() { 190 - loading = true 191 - try { 192 - await flow.resendEmailVerification() 193 - flow.setError(null) 194 - } catch (err) { 195 - flow.setError(getErrorMessage(err)) 196 - } finally { 197 - loading = false 198 - } 199 - } 200 - 201 - async function registerPasskey() { 202 - loading = true 203 - flow.setError(null) 204 - 205 - try { 206 - if (!window.PublicKeyCredential) { 207 - throw new Error('Passkeys are not supported in this browser. Please use a modern browser with WebAuthn support.') 208 - } 209 - 210 - await flow.registerPasskey(passkeyName || undefined) 211 - } catch (err) { 212 - const message = getErrorMessage(err) 213 - if (message.includes('cancelled') || message.includes('AbortError')) { 214 - flow.setError('Passkey registration was cancelled. Please try again.') 215 - } else { 216 - flow.setError(message) 217 - } 218 - } finally { 219 - loading = false 220 - } 221 - } 222 - 223 - async function handleProceedFromAppPassword() { 224 - loading = true 225 - try { 226 - await flow.proceedFromAppPassword() 227 - } catch (err) { 228 - flow.setError(getErrorMessage(err)) 229 - } finally { 230 - loading = false 231 - } 232 - } 233 - </script> 234 - 235 - <div class="migration-wizard"> 236 - <div class="step-indicator"> 237 - {#each steps as _, i} 238 - <div class="step" class:active={i === getCurrentStepIndex()} class:completed={i < getCurrentStepIndex()}> 239 - <div class="step-dot">{i < getCurrentStepIndex() ? 'โœ“' : i + 1}</div> 240 - </div> 241 - {#if i < steps.length - 1} 242 - <div class="step-line" class:completed={i < getCurrentStepIndex()}></div> 243 - {/if} 244 - {/each} 245 - </div> 246 - <div class="current-step-label"> 247 - <strong>{steps[getCurrentStepIndex()]}</strong> ยท Step {getCurrentStepIndex() + 1} of {steps.length} 248 - </div> 249 - 250 - {#if flow.state.error} 251 - <div class="message error">{flow.state.error}</div> 252 - {/if} 253 - 254 - {#if flow.state.step === 'welcome'} 255 - <div class="step-content"> 256 - <h2>{$_('migration.offline.welcome.title')}</h2> 257 - <p>{$_('migration.offline.welcome.desc')}</p> 258 - 259 - <div class="warning-box"> 260 - <strong>{$_('migration.offline.welcome.warningTitle')}</strong> 261 - <p>{$_('migration.offline.welcome.warningDesc')}</p> 262 - </div> 263 - 264 - <div class="info-box"> 265 - <h3>{$_('migration.offline.welcome.requirementsTitle')}</h3> 266 - <ul> 267 - <li>{$_('migration.offline.welcome.requirement1')}</li> 268 - <li>{$_('migration.offline.welcome.requirement2')}</li> 269 - <li>{$_('migration.offline.welcome.requirement3')}</li> 270 - </ul> 271 - </div> 272 - 273 - <label class="checkbox-label"> 274 - <input type="checkbox" bind:checked={understood} /> 275 - <span>{$_('migration.offline.welcome.understand')}</span> 276 - </label> 277 - 278 - <div class="button-row"> 279 - <button class="ghost" onclick={onBack}>{$_('migration.inbound.common.cancel')}</button> 280 - <button disabled={!understood} onclick={() => flow.setStep('provide-did')}> 281 - {$_('migration.inbound.common.continue')} 282 - </button> 283 - </div> 284 - </div> 285 - 286 - {:else if flow.state.step === 'provide-did'} 287 - <div class="step-content"> 288 - <h2>{$_('migration.offline.provideDid.title')}</h2> 289 - <p>{$_('migration.offline.provideDid.desc')}</p> 290 - 291 - <div class="field"> 292 - <label for="user-did">{$_('migration.offline.provideDid.label')}</label> 293 - <input 294 - id="user-did" 295 - type="text" 296 - placeholder="did:plc:abc123..." 297 - value={flow.state.userDid} 298 - oninput={(e) => flow.setUserDid((e.target as HTMLInputElement).value)} 299 - /> 300 - <p class="hint">{$_('migration.offline.provideDid.hint')}</p> 301 - </div> 302 - 303 - <div class="button-row"> 304 - <button class="ghost" onclick={() => flow.setStep('welcome')}>{$_('migration.inbound.common.back')}</button> 305 - <button disabled={!flow.state.userDid.startsWith('did:')} onclick={() => flow.setStep('upload-car')}> 306 - {$_('migration.inbound.common.continue')} 307 - </button> 308 - </div> 309 - </div> 310 - 311 - {:else if flow.state.step === 'upload-car'} 312 - <div class="step-content"> 313 - <h2>{$_('migration.offline.uploadCar.title')}</h2> 314 - <p>{$_('migration.offline.uploadCar.desc')}</p> 315 - 316 - {#if flow.state.carNeedsReupload} 317 - <div class="warning-box"> 318 - <strong>{$_('migration.offline.uploadCar.reuploadWarningTitle')}</strong> 319 - <p>{$_('migration.offline.uploadCar.reuploadWarning')}</p> 320 - {#if flow.state.carFileName} 321 - <p><strong>Previous file:</strong> {flow.state.carFileName} ({(flow.state.carSizeBytes / 1024 / 1024).toFixed(2)} MB)</p> 322 - {/if} 323 - </div> 324 - {/if} 325 - 326 - <div class="field"> 327 - <label for="car-file">{$_('migration.offline.uploadCar.label')}</label> 328 - <div class="file-input-container"> 329 - <input 330 - id="car-file" 331 - type="file" 332 - accept=".car" 333 - onchange={handleFileSelect} 334 - bind:this={fileInputRef} 335 - /> 336 - {#if flow.state.carFile && flow.state.carFileName} 337 - <div class="file-info"> 338 - <span class="file-name">{flow.state.carFileName}</span> 339 - <span class="file-size">({(flow.state.carSizeBytes / 1024 / 1024).toFixed(2)} MB)</span> 340 - </div> 341 - {/if} 342 - </div> 343 - <p class="hint">{$_('migration.offline.uploadCar.hint')}</p> 344 - </div> 345 - 346 - <div class="button-row"> 347 - <button class="ghost" onclick={() => flow.setStep('provide-did')}>{$_('migration.inbound.common.back')}</button> 348 - <button disabled={!flow.state.carFile} onclick={() => flow.setStep('provide-rotation-key')}> 349 - {$_('migration.inbound.common.continue')} 350 - </button> 351 - </div> 352 - </div> 353 - 354 - {:else if flow.state.step === 'provide-rotation-key'} 355 - <div class="step-content"> 356 - <h2>{$_('migration.offline.rotationKey.title')}</h2> 357 - <p>{$_('migration.offline.rotationKey.desc')}</p> 358 - 359 - <div class="warning-box"> 360 - <strong>{$_('migration.offline.rotationKey.securityWarningTitle')}</strong> 361 - <ul> 362 - <li>{$_('migration.offline.rotationKey.securityWarning1')}</li> 363 - <li>{$_('migration.offline.rotationKey.securityWarning2')}</li> 364 - <li>{$_('migration.offline.rotationKey.securityWarning3')}</li> 365 - </ul> 366 - </div> 367 - 368 - <div class="field"> 369 - <label for="rotation-key">{$_('migration.offline.rotationKey.label')}</label> 370 - <textarea 371 - id="rotation-key" 372 - rows={4} 373 - placeholder={$_('migration.offline.rotationKey.placeholder')} 374 - value={flow.state.rotationKey} 375 - oninput={(e) => { 376 - flow.setRotationKey((e.target as HTMLTextAreaElement).value) 377 - keyValid = null 378 - }} 379 - ></textarea> 380 - <p class="hint">{$_('migration.offline.rotationKey.hint')}</p> 381 - </div> 382 - 383 - {#if keyValid === true} 384 - <div class="message success">{$_('migration.offline.rotationKey.valid')}</div> 385 - {:else if keyValid === false} 386 - <div class="message error">{$_('migration.offline.rotationKey.invalid')}</div> 387 - {/if} 388 - 389 - <div class="button-row"> 390 - <button class="ghost" onclick={() => flow.setStep('upload-car')}>{$_('migration.inbound.common.back')}</button> 391 - <button 392 - disabled={!flow.state.rotationKey || validatingKey} 393 - onclick={validateRotationKey} 394 - > 395 - {validatingKey ? $_('migration.offline.rotationKey.validating') : $_('migration.offline.rotationKey.validate')} 396 - </button> 397 - </div> 398 - </div> 399 - 400 - {:else if flow.state.step === 'choose-handle'} 401 - <ChooseHandleStep 402 - {handleInput} 403 - {selectedDomain} 404 - {handleAvailable} 405 - {checkingHandle} 406 - email={flow.state.targetEmail} 407 - password={flow.state.targetPassword} 408 - authMethod={selectedAuthMethod} 409 - inviteCode={flow.state.inviteCode} 410 - {serverInfo} 411 - migratingFromLabel={$_('migration.offline.chooseHandle.migratingDid')} 412 - migratingFromValue={flow.state.userDid} 413 - {loading} 414 - onHandleChange={(h) => handleInput = h} 415 - onDomainChange={(d) => selectedDomain = d} 416 - onCheckHandle={checkHandle} 417 - onEmailChange={(e) => flow.setTargetEmail(e)} 418 - onPasswordChange={(p) => flow.setTargetPassword(p)} 419 - onAuthMethodChange={(m) => selectedAuthMethod = m} 420 - onInviteCodeChange={(c) => flow.setInviteCode(c)} 421 - onBack={() => flow.setStep('provide-rotation-key')} 422 - onContinue={proceedToReview} 423 - /> 424 - 425 - {:else if flow.state.step === 'review'} 426 - <div class="step-content"> 427 - <h2>{$_('migration.inbound.review.title')}</h2> 428 - <p>{$_('migration.offline.review.desc')}</p> 429 - 430 - <div class="review-card"> 431 - <div class="review-row"> 432 - <span class="label">{$_('migration.inbound.review.did')}:</span> 433 - <span class="value mono">{flow.state.userDid}</span> 434 - </div> 435 - <div class="review-row"> 436 - <span class="label">{$_('migration.inbound.review.newHandle')}:</span> 437 - <span class="value">{flow.state.targetHandle}</span> 438 - </div> 439 - <div class="review-row"> 440 - <span class="label">{$_('migration.offline.review.carFile')}:</span> 441 - <span class="value">{flow.state.carFileName} ({(flow.state.carSizeBytes / 1024 / 1024).toFixed(2)} MB)</span> 442 - </div> 443 - <div class="review-row"> 444 - <span class="label">{$_('migration.offline.review.rotationKey')}:</span> 445 - <span class="value mono">{flow.state.rotationKeyDidKey}</span> 446 - </div> 447 - <div class="review-row"> 448 - <span class="label">{$_('migration.inbound.review.targetPds')}:</span> 449 - <span class="value">{window.location.origin}</span> 450 - </div> 451 - <div class="review-row"> 452 - <span class="label">{$_('migration.inbound.review.email')}:</span> 453 - <span class="value">{flow.state.targetEmail}</span> 454 - </div> 455 - <div class="review-row"> 456 - <span class="label">{$_('migration.inbound.review.authentication')}:</span> 457 - <span class="value">{flow.state.authMethod === 'passkey' ? $_('migration.inbound.review.authPasskey') : $_('migration.inbound.review.authPassword')}</span> 458 - </div> 459 - </div> 460 - 461 - <div class="warning-box"> 462 - <strong>{$_('migration.offline.review.plcWarningTitle')}</strong> 463 - <p>{$_('migration.offline.review.plcWarning')}</p> 464 - </div> 465 - 466 - <div class="button-row"> 467 - <button class="ghost" onclick={() => flow.setStep('choose-handle')} disabled={loading}>{$_('migration.inbound.common.back')}</button> 468 - <button onclick={startMigration} disabled={loading}> 469 - {loading ? $_('migration.inbound.review.starting') : $_('migration.inbound.review.startMigration')} 470 - </button> 471 - </div> 472 - </div> 473 - 474 - {:else if flow.state.step === 'creating' || flow.state.step === 'importing'} 475 - <div class="step-content"> 476 - <h2>{$_('migration.offline.migrating.title')}</h2> 477 - <p>{$_('migration.offline.migrating.desc')}</p> 478 - 479 - <div class="progress-section"> 480 - <div class="progress-item" class:completed={flow.state.step !== 'creating'} class:active={flow.state.step === 'creating'}> 481 - <span class="icon">{flow.state.step !== 'creating' ? 'โœ“' : 'โ—‹'}</span> 482 - <span>{$_('migration.offline.migrating.creating')}</span> 483 - </div> 484 - <div class="progress-item" class:active={flow.state.step === 'importing'}> 485 - <span class="icon">โ—‹</span> 486 - <span>{$_('migration.offline.migrating.importing')}</span> 487 - </div> 488 - </div> 489 - 490 - <p class="status-text">{flow.state.progress.currentOperation}</p> 491 - </div> 492 - 493 - {:else if flow.state.step === 'migrating-blobs'} 494 - <div class="step-content"> 495 - <h2>{$_('migration.offline.blobs.title')}</h2> 496 - <p>{$_('migration.offline.blobs.desc')}</p> 497 - 498 - <div class="progress-section"> 499 - <div class="progress-item completed"> 500 - <span class="icon">โœ“</span> 501 - <span>{$_('migration.offline.migrating.importing')}</span> 502 - </div> 503 - <div class="progress-item active"> 504 - <span class="icon">โ—‹</span> 505 - <span>{$_('migration.offline.blobs.migrating')}</span> 506 - </div> 507 - </div> 508 - 509 - {#if flow.state.progress.blobsTotal > 0} 510 - <div class="blob-progress"> 511 - <div class="blob-progress-bar"> 512 - <div 513 - class="blob-progress-fill" 514 - style="width: {(flow.state.progress.blobsMigrated / flow.state.progress.blobsTotal) * 100}%" 515 - ></div> 516 - </div> 517 - <p class="blob-progress-text"> 518 - {flow.state.progress.blobsMigrated} / {flow.state.progress.blobsTotal} blobs 519 - </p> 520 - </div> 521 - {/if} 522 - 523 - <p class="status-text">{flow.state.progress.currentOperation}</p> 524 - 525 - {#if flow.state.progress.blobsFailed.length > 0} 526 - <div class="warning-box"> 527 - <strong>{$_('migration.offline.blobs.failedTitle')}</strong> 528 - <p>{$_('migration.offline.blobs.failedDesc', { values: { count: flow.state.progress.blobsFailed.length } })}</p> 529 - </div> 530 - {/if} 531 - </div> 532 - 533 - {:else if flow.state.step === 'email-verify'} 534 - <EmailVerifyStep 535 - email={flow.state.targetEmail} 536 - token={flow.state.emailVerifyToken} 537 - {loading} 538 - error={flow.state.error} 539 - onTokenChange={(t) => flow.updateField('emailVerifyToken', t)} 540 - onSubmit={submitEmailVerify} 541 - onResend={resendEmailVerify} 542 - /> 543 - 544 - {:else if flow.state.step === 'passkey-setup'} 545 - <PasskeySetupStep 546 - {passkeyName} 547 - {loading} 548 - error={flow.state.error} 549 - onPasskeyNameChange={(n) => passkeyName = n} 550 - onRegister={registerPasskey} 551 - /> 552 - 553 - {:else if flow.state.step === 'app-password'} 554 - <AppPasswordStep 555 - appPassword={flow.state.generatedAppPassword || ''} 556 - appPasswordName={flow.state.generatedAppPasswordName || ''} 557 - {loading} 558 - onContinue={handleProceedFromAppPassword} 559 - /> 560 - 561 - {:else if flow.state.step === 'plc-signing' || flow.state.step === 'finalizing'} 562 - <div class="step-content"> 563 - <h2>{$_('migration.inbound.finalizing.title')}</h2> 564 - <p>{$_('migration.inbound.finalizing.desc')}</p> 565 - 566 - <div class="progress-section"> 567 - <div class="progress-item" class:completed={flow.state.progress.plcSigned}> 568 - <span class="icon">{flow.state.progress.plcSigned ? 'โœ“' : 'โ—‹'}</span> 569 - <span>{$_('migration.inbound.finalizing.signingPlc')}</span> 570 - </div> 571 - <div class="progress-item" class:completed={flow.state.progress.activated}> 572 - <span class="icon">{flow.state.progress.activated ? 'โœ“' : 'โ—‹'}</span> 573 - <span>{$_('migration.inbound.finalizing.activating')}</span> 574 - </div> 575 - </div> 576 - 577 - <p class="status-text">{flow.state.progress.currentOperation}</p> 578 - </div> 579 - 580 - {:else if flow.state.step === 'success'} 581 - <SuccessStep 582 - handle={flow.state.targetHandle} 583 - did={flow.state.userDid} 584 - description={$_('migration.offline.success.desc')} 585 - /> 586 - 587 - {:else if flow.state.step === 'error'} 588 - <ErrorStep error={flow.state.error} onStartOver={onBack} /> 589 - {/if} 590 - </div> 591 -
+546
frontend/src/components/migration/OutboundWizard.svelte
··· 1 + <script lang="ts"> 2 + import type { OutboundMigrationFlow } from '../../lib/migration' 3 + import type { ServerDescription } from '../../lib/migration/types' 4 + import { getAuthState, logout } from '../../lib/auth.svelte' 5 + import '../../styles/migration.css' 6 + 7 + interface Props { 8 + flow: OutboundMigrationFlow 9 + onBack: () => void 10 + onComplete: () => void 11 + } 12 + 13 + let { flow, onBack, onComplete }: Props = $props() 14 + 15 + const auth = getAuthState() 16 + 17 + let loading = $state(false) 18 + let understood = $state(false) 19 + let pdsUrlInput = $state('') 20 + let handleInput = $state('') 21 + let selectedDomain = $state('') 22 + let confirmFinal = $state(false) 23 + 24 + $effect(() => { 25 + if (flow.state.step === 'success') { 26 + setTimeout(async () => { 27 + await logout() 28 + onComplete() 29 + }, 3000) 30 + } 31 + }) 32 + 33 + $effect(() => { 34 + if (flow.state.targetServerInfo?.availableUserDomains?.length) { 35 + selectedDomain = flow.state.targetServerInfo.availableUserDomains[0] 36 + } 37 + }) 38 + 39 + async function validatePds(e: Event) { 40 + e.preventDefault() 41 + loading = true 42 + flow.updateField('error', null) 43 + 44 + try { 45 + let url = pdsUrlInput.trim() 46 + if (!url.startsWith('http://') && !url.startsWith('https://')) { 47 + url = `https://${url}` 48 + } 49 + await flow.validateTargetPds(url) 50 + flow.setStep('new-account') 51 + } catch (err) { 52 + flow.setError((err as Error).message) 53 + } finally { 54 + loading = false 55 + } 56 + } 57 + 58 + function proceedToReview() { 59 + const fullHandle = handleInput.includes('.') 60 + ? handleInput 61 + : `${handleInput}.${selectedDomain}` 62 + 63 + flow.updateField('targetHandle', fullHandle) 64 + flow.setStep('review') 65 + } 66 + 67 + async function startMigration() { 68 + if (!auth.session) return 69 + loading = true 70 + try { 71 + await flow.startMigration(auth.session.did) 72 + } catch (err) { 73 + flow.setError((err as Error).message) 74 + } finally { 75 + loading = false 76 + } 77 + } 78 + 79 + async function submitPlcToken(e: Event) { 80 + e.preventDefault() 81 + loading = true 82 + try { 83 + await flow.submitPlcToken(flow.state.plcToken) 84 + } catch (err) { 85 + flow.setError((err as Error).message) 86 + } finally { 87 + loading = false 88 + } 89 + } 90 + 91 + async function resendToken() { 92 + loading = true 93 + try { 94 + await flow.resendPlcToken() 95 + flow.setError(null) 96 + } catch (err) { 97 + flow.setError((err as Error).message) 98 + } finally { 99 + loading = false 100 + } 101 + } 102 + 103 + function isDidWeb(): boolean { 104 + return auth.session?.did?.startsWith('did:web:') ?? false 105 + } 106 + 107 + const steps = ['Target', 'Setup', 'Review', 'Transfer', 'Verify', 'Complete'] 108 + function getCurrentStepIndex(): number { 109 + switch (flow.state.step) { 110 + case 'welcome': return -1 111 + case 'target-pds': return 0 112 + case 'new-account': return 1 113 + case 'review': return 2 114 + case 'migrating': return 3 115 + case 'plc-token': 116 + case 'finalizing': return 4 117 + case 'success': return 5 118 + default: return 0 119 + } 120 + } 121 + </script> 122 + 123 + <div class="migration-wizard"> 124 + {#if flow.state.step !== 'welcome'} 125 + <div class="step-indicator"> 126 + {#each steps as stepName, i} 127 + <div class="step" class:active={i === getCurrentStepIndex()} class:completed={i < getCurrentStepIndex()}> 128 + <div class="step-dot">{i < getCurrentStepIndex() ? 'โœ“' : i + 1}</div> 129 + <span class="step-label">{stepName}</span> 130 + </div> 131 + {#if i < steps.length - 1} 132 + <div class="step-line" class:completed={i < getCurrentStepIndex()}></div> 133 + {/if} 134 + {/each} 135 + </div> 136 + {/if} 137 + 138 + {#if flow.state.error} 139 + <div class="migration-message error">{flow.state.error}</div> 140 + {/if} 141 + 142 + {#if flow.state.step === 'welcome'} 143 + <div class="step-content"> 144 + <h2>Migrate Your Account Away</h2> 145 + <p>This wizard will help you move your AT Protocol account from this PDS to another one.</p> 146 + 147 + <div class="current-account"> 148 + <span class="label">Current account:</span> 149 + <span class="value">@{auth.session?.handle}</span> 150 + </div> 151 + 152 + {#if isDidWeb()} 153 + <div class="migration-warning-box"> 154 + <strong>did:web Migration Notice</strong> 155 + <p> 156 + Your account uses a did:web identifier ({auth.session?.did}). After migrating, this PDS will 157 + continue serving your DID document with an updated service endpoint pointing to your new PDS. 158 + </p> 159 + <p> 160 + You can return here anytime to update the forwarding if you migrate again in the future. 161 + </p> 162 + </div> 163 + {/if} 164 + 165 + <div class="migration-info-box"> 166 + <h3>What will happen:</h3> 167 + <ol> 168 + <li>Choose your new PDS</li> 169 + <li>Set up your account on the new server</li> 170 + <li>Your repository and blobs will be transferred</li> 171 + <li>Verify the migration via email</li> 172 + <li>Your identity will be updated to point to the new PDS</li> 173 + <li>Your account here will be deactivated</li> 174 + </ol> 175 + </div> 176 + 177 + <div class="migration-warning-box"> 178 + <strong>Before you proceed:</strong> 179 + <ul> 180 + <li>You need access to the email registered with this account</li> 181 + <li>You will lose access to this account on this PDS</li> 182 + <li>Make sure you trust the destination PDS</li> 183 + <li>Large accounts may take several minutes to transfer</li> 184 + </ul> 185 + </div> 186 + 187 + <label class="checkbox-label"> 188 + <input type="checkbox" bind:checked={understood} /> 189 + <span>I understand that my account will be moved and deactivated here</span> 190 + </label> 191 + 192 + <div class="button-row"> 193 + <button class="ghost" onclick={onBack}>Cancel</button> 194 + <button disabled={!understood} onclick={() => flow.setStep('target-pds')}> 195 + Continue 196 + </button> 197 + </div> 198 + </div> 199 + 200 + {:else if flow.state.step === 'target-pds'} 201 + <div class="step-content"> 202 + <h2>Choose Your New PDS</h2> 203 + <p>Enter the URL of the PDS you want to migrate to.</p> 204 + 205 + <form onsubmit={validatePds}> 206 + <div class="migration-field"> 207 + <label for="pds-url">PDS URL</label> 208 + <input 209 + id="pds-url" 210 + type="text" 211 + placeholder="pds.example.com" 212 + bind:value={pdsUrlInput} 213 + disabled={loading} 214 + required 215 + /> 216 + <p class="migration-hint">The server address of your new PDS (e.g., bsky.social, pds.example.com)</p> 217 + </div> 218 + 219 + <div class="button-row"> 220 + <button type="button" class="ghost" onclick={() => flow.setStep('welcome')} disabled={loading}>Back</button> 221 + <button type="submit" disabled={loading || !pdsUrlInput.trim()}> 222 + {loading ? 'Checking...' : 'Connect'} 223 + </button> 224 + </div> 225 + </form> 226 + 227 + {#if flow.state.targetServerInfo} 228 + <div class="server-info"> 229 + <h3>Connected to PDS</h3> 230 + <div class="info-row"> 231 + <span class="label">Server:</span> 232 + <span class="value">{flow.state.targetPdsUrl}</span> 233 + </div> 234 + {#if flow.state.targetServerInfo.availableUserDomains.length > 0} 235 + <div class="info-row"> 236 + <span class="label">Available domains:</span> 237 + <span class="value">{flow.state.targetServerInfo.availableUserDomains.join(', ')}</span> 238 + </div> 239 + {/if} 240 + <div class="info-row"> 241 + <span class="label">Invite required:</span> 242 + <span class="value">{flow.state.targetServerInfo.inviteCodeRequired ? 'Yes' : 'No'}</span> 243 + </div> 244 + {#if flow.state.targetServerInfo.links?.termsOfService} 245 + <a href={flow.state.targetServerInfo.links.termsOfService} target="_blank" rel="noopener"> 246 + Terms of Service 247 + </a> 248 + {/if} 249 + {#if flow.state.targetServerInfo.links?.privacyPolicy} 250 + <a href={flow.state.targetServerInfo.links.privacyPolicy} target="_blank" rel="noopener"> 251 + Privacy Policy 252 + </a> 253 + {/if} 254 + </div> 255 + {/if} 256 + </div> 257 + 258 + {:else if flow.state.step === 'new-account'} 259 + <div class="step-content"> 260 + <h2>Set Up Your New Account</h2> 261 + <p>Configure your account details on the new PDS.</p> 262 + 263 + <div class="current-info"> 264 + <span class="label">Migrating to:</span> 265 + <span class="value">{flow.state.targetPdsUrl}</span> 266 + </div> 267 + 268 + <div class="migration-field"> 269 + <label for="new-handle">New Handle</label> 270 + <div class="handle-input-group"> 271 + <input 272 + id="new-handle" 273 + type="text" 274 + placeholder="username" 275 + bind:value={handleInput} 276 + /> 277 + {#if flow.state.targetServerInfo && flow.state.targetServerInfo.availableUserDomains.length > 0 && !handleInput.includes('.')} 278 + <select bind:value={selectedDomain}> 279 + {#each flow.state.targetServerInfo.availableUserDomains as domain} 280 + <option value={domain}>.{domain}</option> 281 + {/each} 282 + </select> 283 + {/if} 284 + </div> 285 + <p class="migration-hint">You can also use your own domain by entering the full handle (e.g., alice.mydomain.com)</p> 286 + </div> 287 + 288 + <div class="migration-field"> 289 + <label for="email">Email Address</label> 290 + <input 291 + id="email" 292 + type="email" 293 + placeholder="you@example.com" 294 + bind:value={flow.state.targetEmail} 295 + oninput={(e) => flow.updateField('targetEmail', (e.target as HTMLInputElement).value)} 296 + required 297 + /> 298 + </div> 299 + 300 + <div class="migration-field"> 301 + <label for="new-password">Password</label> 302 + <input 303 + id="new-password" 304 + type="password" 305 + placeholder="Password for your new account" 306 + bind:value={flow.state.targetPassword} 307 + oninput={(e) => flow.updateField('targetPassword', (e.target as HTMLInputElement).value)} 308 + required 309 + minlength="8" 310 + /> 311 + <p class="migration-hint">At least 8 characters. This will be your password on the new PDS.</p> 312 + </div> 313 + 314 + {#if flow.state.targetServerInfo?.inviteCodeRequired} 315 + <div class="migration-field"> 316 + <label for="invite">Invite Code</label> 317 + <input 318 + id="invite" 319 + type="text" 320 + placeholder="Enter invite code" 321 + bind:value={flow.state.inviteCode} 322 + oninput={(e) => flow.updateField('inviteCode', (e.target as HTMLInputElement).value)} 323 + required 324 + /> 325 + <p class="migration-hint">Required by this PDS to create an account</p> 326 + </div> 327 + {/if} 328 + 329 + <div class="button-row"> 330 + <button class="ghost" onclick={() => flow.setStep('target-pds')}>Back</button> 331 + <button 332 + disabled={!handleInput.trim() || !flow.state.targetEmail || !flow.state.targetPassword} 333 + onclick={proceedToReview} 334 + > 335 + Continue 336 + </button> 337 + </div> 338 + </div> 339 + 340 + {:else if flow.state.step === 'review'} 341 + <div class="step-content"> 342 + <h2>Review Migration</h2> 343 + <p>Please confirm the details of your migration.</p> 344 + 345 + <div class="review-card"> 346 + <div class="review-row"> 347 + <span class="label">Current Handle:</span> 348 + <span class="value">@{auth.session?.handle}</span> 349 + </div> 350 + <div class="review-row"> 351 + <span class="label">New Handle:</span> 352 + <span class="value">@{flow.state.targetHandle}</span> 353 + </div> 354 + <div class="review-row"> 355 + <span class="label">DID:</span> 356 + <span class="value mono">{auth.session?.did}</span> 357 + </div> 358 + <div class="review-row"> 359 + <span class="label">From PDS:</span> 360 + <span class="value">{window.location.origin}</span> 361 + </div> 362 + <div class="review-row"> 363 + <span class="label">To PDS:</span> 364 + <span class="value">{flow.state.targetPdsUrl}</span> 365 + </div> 366 + <div class="review-row"> 367 + <span class="label">New Email:</span> 368 + <span class="value">{flow.state.targetEmail}</span> 369 + </div> 370 + </div> 371 + 372 + <div class="migration-warning-box final-warning"> 373 + <strong>This action cannot be easily undone!</strong> 374 + <p> 375 + After migration completes, your account on this PDS will be deactivated. 376 + To return, you would need to migrate back from the new PDS. 377 + </p> 378 + </div> 379 + 380 + <label class="checkbox-label"> 381 + <input type="checkbox" bind:checked={confirmFinal} /> 382 + <span>I confirm I want to migrate my account to {flow.state.targetPdsUrl}</span> 383 + </label> 384 + 385 + <div class="button-row"> 386 + <button class="ghost" onclick={() => flow.setStep('new-account')} disabled={loading}>Back</button> 387 + <button class="danger" onclick={startMigration} disabled={loading || !confirmFinal}> 388 + {loading ? 'Starting...' : 'Start Migration'} 389 + </button> 390 + </div> 391 + </div> 392 + 393 + {:else if flow.state.step === 'migrating'} 394 + <div class="step-content"> 395 + <h2>Migration in Progress</h2> 396 + <p>Please wait while your account is being transferred...</p> 397 + 398 + <div class="progress-section"> 399 + <div class="progress-item" class:completed={flow.state.progress.repoExported}> 400 + <span class="icon">{flow.state.progress.repoExported ? 'โœ“' : 'โ—‹'}</span> 401 + <span>Export repository</span> 402 + </div> 403 + <div class="progress-item" class:completed={flow.state.progress.repoImported}> 404 + <span class="icon">{flow.state.progress.repoImported ? 'โœ“' : 'โ—‹'}</span> 405 + <span>Import repository to new PDS</span> 406 + </div> 407 + <div class="progress-item" class:active={flow.state.progress.repoImported && !flow.state.progress.prefsMigrated}> 408 + <span class="icon">{flow.state.progress.blobsMigrated === flow.state.progress.blobsTotal && flow.state.progress.blobsTotal > 0 ? 'โœ“' : 'โ—‹'}</span> 409 + <span>Migrate blobs ({flow.state.progress.blobsMigrated}/{flow.state.progress.blobsTotal})</span> 410 + </div> 411 + <div class="progress-item" class:completed={flow.state.progress.prefsMigrated}> 412 + <span class="icon">{flow.state.progress.prefsMigrated ? 'โœ“' : 'โ—‹'}</span> 413 + <span>Migrate preferences</span> 414 + </div> 415 + </div> 416 + 417 + {#if flow.state.progress.blobsTotal > 0} 418 + <div class="progress-bar"> 419 + <div 420 + class="progress-fill" 421 + style="width: {(flow.state.progress.blobsMigrated / flow.state.progress.blobsTotal) * 100}%" 422 + ></div> 423 + </div> 424 + {/if} 425 + 426 + <p class="status-text">{flow.state.progress.currentOperation}</p> 427 + </div> 428 + 429 + {:else if flow.state.step === 'plc-token'} 430 + <div class="step-content"> 431 + <h2>Verify Migration</h2> 432 + <p>A verification code has been sent to your email ({auth.session?.email}).</p> 433 + 434 + <div class="migration-info-box"> 435 + <p> 436 + This code confirms you have access to the account and authorizes updating your identity 437 + to point to the new PDS. 438 + </p> 439 + </div> 440 + 441 + <form onsubmit={submitPlcToken}> 442 + <div class="migration-field"> 443 + <label for="plc-token">Verification Code</label> 444 + <input 445 + id="plc-token" 446 + type="text" 447 + placeholder="Enter code from email" 448 + bind:value={flow.state.plcToken} 449 + oninput={(e) => flow.updateField('plcToken', (e.target as HTMLInputElement).value)} 450 + disabled={loading} 451 + required 452 + /> 453 + </div> 454 + 455 + <div class="button-row"> 456 + <button type="button" class="ghost" onclick={resendToken} disabled={loading}> 457 + Resend Code 458 + </button> 459 + <button type="submit" disabled={loading || !flow.state.plcToken}> 460 + {loading ? 'Verifying...' : 'Complete Migration'} 461 + </button> 462 + </div> 463 + </form> 464 + </div> 465 + 466 + {:else if flow.state.step === 'finalizing'} 467 + <div class="step-content"> 468 + <h2>Finalizing Migration</h2> 469 + <p>Please wait while we complete the migration...</p> 470 + 471 + <div class="progress-section"> 472 + <div class="progress-item" class:completed={flow.state.progress.plcSigned}> 473 + <span class="icon">{flow.state.progress.plcSigned ? 'โœ“' : 'โ—‹'}</span> 474 + <span>Sign identity update</span> 475 + </div> 476 + <div class="progress-item" class:completed={flow.state.progress.activated}> 477 + <span class="icon">{flow.state.progress.activated ? 'โœ“' : 'โ—‹'}</span> 478 + <span>Activate account on new PDS</span> 479 + </div> 480 + <div class="progress-item" class:completed={flow.state.progress.deactivated}> 481 + <span class="icon">{flow.state.progress.deactivated ? 'โœ“' : 'โ—‹'}</span> 482 + <span>Deactivate account here</span> 483 + </div> 484 + </div> 485 + 486 + <p class="status-text">{flow.state.progress.currentOperation}</p> 487 + </div> 488 + 489 + {:else if flow.state.step === 'success'} 490 + <div class="step-content success-content"> 491 + <div class="success-icon">โœ“</div> 492 + <h2>Migration Complete!</h2> 493 + <p>Your account has been successfully migrated to your new PDS.</p> 494 + 495 + <div class="success-details"> 496 + <div class="detail-row"> 497 + <span class="label">Your new handle:</span> 498 + <span class="value">@{flow.state.targetHandle}</span> 499 + </div> 500 + <div class="detail-row"> 501 + <span class="label">New PDS:</span> 502 + <span class="value">{flow.state.targetPdsUrl}</span> 503 + </div> 504 + <div class="detail-row"> 505 + <span class="label">DID:</span> 506 + <span class="value mono">{auth.session?.did}</span> 507 + </div> 508 + </div> 509 + 510 + {#if flow.state.progress.blobsFailed.length > 0} 511 + <div class="migration-warning-box"> 512 + <strong>Note:</strong> {flow.state.progress.blobsFailed.length} blobs could not be migrated. 513 + These may be images or other media that are no longer available. 514 + </div> 515 + {/if} 516 + 517 + <div class="next-steps"> 518 + <h3>Next Steps</h3> 519 + <ol> 520 + <li>Visit your new PDS at <a href={flow.state.targetPdsUrl} target="_blank" rel="noopener">{flow.state.targetPdsUrl}</a></li> 521 + <li>Log in with your new credentials</li> 522 + <li>Your followers and following will continue to work</li> 523 + </ol> 524 + </div> 525 + 526 + <p class="redirect-text">Logging out in a moment...</p> 527 + </div> 528 + 529 + {:else if flow.state.step === 'error'} 530 + <div class="step-content"> 531 + <h2>Migration Error</h2> 532 + <p>An error occurred during migration.</p> 533 + 534 + <div class="migration-error-box"> 535 + {flow.state.error} 536 + </div> 537 + 538 + <div class="button-row"> 539 + <button class="ghost" onclick={onBack}>Start Over</button> 540 + </div> 541 + </div> 542 + {/if} 543 + </div> 544 + 545 + <style> 546 + </style>
-60
frontend/src/components/migration/PasskeySetupStep.svelte
··· 1 - <script lang="ts"> 2 - import { _ } from '../../lib/i18n' 3 - 4 - interface Props { 5 - passkeyName: string 6 - loading: boolean 7 - error: string | null 8 - onPasskeyNameChange: (name: string) => void 9 - onRegister: () => void 10 - } 11 - 12 - let { 13 - passkeyName, 14 - loading, 15 - error, 16 - onPasskeyNameChange, 17 - onRegister, 18 - }: Props = $props() 19 - </script> 20 - 21 - <div class="step-content"> 22 - <h2>{$_('migration.inbound.passkeySetup.title')}</h2> 23 - <p>{$_('migration.inbound.passkeySetup.desc')}</p> 24 - 25 - {#if error} 26 - <div class="message error"> 27 - {error} 28 - </div> 29 - {/if} 30 - 31 - <div class="field"> 32 - <label for="passkey-name">{$_('migration.inbound.passkeySetup.nameLabel')}</label> 33 - <input 34 - id="passkey-name" 35 - type="text" 36 - placeholder={$_('migration.inbound.passkeySetup.namePlaceholder')} 37 - value={passkeyName} 38 - oninput={(e) => onPasskeyNameChange((e.target as HTMLInputElement).value)} 39 - disabled={loading} 40 - /> 41 - <p class="hint">{$_('migration.inbound.passkeySetup.nameHint')}</p> 42 - </div> 43 - 44 - <div class="passkey-section"> 45 - <p>{$_('migration.inbound.passkeySetup.instructions')}</p> 46 - <button class="primary" onclick={onRegister} disabled={loading}> 47 - {loading ? $_('migration.inbound.passkeySetup.registering') : $_('migration.inbound.passkeySetup.register')} 48 - </button> 49 - </div> 50 - </div> 51 - 52 - <style> 53 - .passkey-section { 54 - margin-top: 16px; 55 - } 56 - .passkey-section button { 57 - width: 100%; 58 - margin-top: 12px; 59 - } 60 - </style>
-36
frontend/src/components/migration/SuccessStep.svelte
··· 1 - <script lang="ts"> 2 - import type { Snippet } from 'svelte' 3 - import { _ } from '../../lib/i18n' 4 - 5 - interface Props { 6 - handle: string 7 - did: string 8 - description?: string 9 - extraContent?: Snippet 10 - } 11 - 12 - let { handle, did, description, extraContent }: Props = $props() 13 - </script> 14 - 15 - <div class="step-content success-content"> 16 - <div class="success-icon">โœ“</div> 17 - <h2>{$_('migration.inbound.success.title')}</h2> 18 - <p>{description || $_('migration.inbound.success.desc')}</p> 19 - 20 - <div class="success-details"> 21 - <div class="detail-row"> 22 - <span class="label">{$_('migration.inbound.success.yourNewHandle')}:</span> 23 - <span class="value">{handle}</span> 24 - </div> 25 - <div class="detail-row"> 26 - <span class="label">{$_('migration.inbound.success.did')}:</span> 27 - <span class="value mono">{did}</span> 28 - </div> 29 - </div> 30 - 31 - {#if extraContent} 32 - {@render extraContent()} 33 - {/if} 34 - 35 - <p class="redirect-text">{$_('migration.inbound.success.redirecting')}</p> 36 - </div>
+47 -155
frontend/src/lib/api.ts
··· 205 205 return data; 206 206 }, 207 207 208 - async createAccountWithServiceAuth( 209 - serviceAuthToken: string, 210 - params: { 211 - did: string; 212 - handle: string; 213 - email: string; 214 - password: string; 215 - inviteCode?: string; 216 - }, 217 - ): Promise<Session> { 218 - const url = `${API_BASE}/com.atproto.server.createAccount`; 219 - const response = await fetch(url, { 220 - method: "POST", 221 - headers: { 222 - "Content-Type": "application/json", 223 - "Authorization": `Bearer ${serviceAuthToken}`, 224 - }, 225 - body: JSON.stringify({ 226 - did: params.did, 227 - handle: params.handle, 228 - email: params.email, 229 - password: params.password, 230 - inviteCode: params.inviteCode, 231 - }), 232 - }); 233 - const data = await response.json(); 234 - if (!response.ok) { 235 - throw new ApiError(response.status, data.error, data.message); 236 - } 237 - return data; 238 - }, 239 - 240 208 async confirmSignup( 241 209 did: string, 242 210 verificationCode: string, ··· 258 226 return xrpc("com.atproto.server.createSession", { 259 227 method: "POST", 260 228 body: { identifier, password }, 261 - }); 262 - }, 263 - 264 - async checkEmailVerified(identifier: string): Promise<{ verified: boolean }> { 265 - return xrpc("_checkEmailVerified", { 266 - method: "POST", 267 - body: { identifier }, 268 229 }); 269 230 }, 270 231 ··· 418 379 signalNumber: string | null; 419 380 signalVerified: boolean; 420 381 }> { 421 - return xrpc("_account.getNotificationPrefs", { token }); 382 + return xrpc("com.tranquil.account.getNotificationPrefs", { token }); 422 383 }, 423 384 424 385 async updateNotificationPrefs(token: string, prefs: { ··· 427 388 telegramUsername?: string; 428 389 signalNumber?: string; 429 390 }): Promise<{ success: boolean }> { 430 - return xrpc("_account.updateNotificationPrefs", { 391 + return xrpc("com.tranquil.account.updateNotificationPrefs", { 431 392 method: "POST", 432 393 token, 433 394 body: prefs, ··· 440 401 identifier: string, 441 402 code: string, 442 403 ): Promise<{ success: boolean }> { 443 - return xrpc("_account.confirmChannelVerification", { 404 + return xrpc("com.tranquil.account.confirmChannelVerification", { 444 405 method: "POST", 445 406 token, 446 407 body: { channel, identifier, code }, ··· 457 418 body: string; 458 419 }>; 459 420 }> { 460 - return xrpc("_account.getNotificationHistory", { token }); 421 + return xrpc("com.tranquil.account.getNotificationHistory", { token }); 461 422 }, 462 423 463 424 async getServerStats(token: string): Promise<{ ··· 466 427 recordCount: number; 467 428 blobStorageBytes: number; 468 429 }> { 469 - return xrpc("_admin.getServerStats", { token }); 430 + return xrpc("com.tranquil.admin.getServerStats", { token }); 470 431 }, 471 432 472 433 async getServerConfig(): Promise<{ ··· 477 438 secondaryColorDark: string | null; 478 439 logoCid: string | null; 479 440 }> { 480 - return xrpc("_server.getConfig"); 441 + return xrpc("com.tranquil.server.getConfig"); 481 442 }, 482 443 483 444 async updateServerConfig( ··· 491 452 logoCid?: string; 492 453 }, 493 454 ): Promise<{ success: boolean }> { 494 - return xrpc("_admin.updateServerConfig", { 455 + return xrpc("com.tranquil.admin.updateServerConfig", { 495 456 method: "POST", 496 457 token, 497 458 body: config, ··· 534 495 currentPassword: string, 535 496 newPassword: string, 536 497 ): Promise<void> { 537 - await xrpc("_account.changePassword", { 498 + await xrpc("com.tranquil.account.changePassword", { 538 499 method: "POST", 539 500 token, 540 501 body: { currentPassword, newPassword }, ··· 542 503 }, 543 504 544 505 async removePassword(token: string): Promise<{ success: boolean }> { 545 - return xrpc("_account.removePassword", { 506 + return xrpc("com.tranquil.account.removePassword", { 546 507 method: "POST", 547 508 token, 548 509 }); 549 510 }, 550 511 551 512 async getPasswordStatus(token: string): Promise<{ hasPassword: boolean }> { 552 - return xrpc("_account.getPasswordStatus", { token }); 513 + return xrpc("com.tranquil.account.getPasswordStatus", { token }); 553 514 }, 554 515 555 516 async getLegacyLoginPreference( 556 517 token: string, 557 518 ): Promise<{ allowLegacyLogin: boolean; hasMfa: boolean }> { 558 - return xrpc("_account.getLegacyLoginPreference", { token }); 519 + return xrpc("com.tranquil.account.getLegacyLoginPreference", { token }); 559 520 }, 560 521 561 522 async updateLegacyLoginPreference( 562 523 token: string, 563 524 allowLegacyLogin: boolean, 564 525 ): Promise<{ allowLegacyLogin: boolean }> { 565 - return xrpc("_account.updateLegacyLoginPreference", { 526 + return xrpc("com.tranquil.account.updateLegacyLoginPreference", { 566 527 method: "POST", 567 528 token, 568 529 body: { allowLegacyLogin }, ··· 573 534 token: string, 574 535 preferredLocale: string, 575 536 ): Promise<{ preferredLocale: string }> { 576 - return xrpc("_account.updateLocale", { 537 + return xrpc("com.tranquil.account.updateLocale", { 577 538 method: "POST", 578 539 token, 579 540 body: { preferredLocale }, ··· 590 551 isCurrent: boolean; 591 552 }>; 592 553 }> { 593 - return xrpc("_account.listSessions", { token }); 554 + return xrpc("com.tranquil.account.listSessions", { token }); 594 555 }, 595 556 596 557 async revokeSession(token: string, sessionId: string): Promise<void> { 597 - await xrpc("_account.revokeSession", { 558 + await xrpc("com.tranquil.account.revokeSession", { 598 559 method: "POST", 599 560 token, 600 561 body: { sessionId }, ··· 602 563 }, 603 564 604 565 async revokeAllSessions(token: string): Promise<{ revokedCount: number }> { 605 - return xrpc("_account.revokeAllSessions", { 566 + return xrpc("com.tranquil.account.revokeAllSessions", { 606 567 method: "POST", 607 568 token, 608 569 }); ··· 907 868 lastSeenAt: string; 908 869 }>; 909 870 }> { 910 - return xrpc("_account.listTrustedDevices", { token }); 871 + return xrpc("com.tranquil.account.listTrustedDevices", { token }); 911 872 }, 912 873 913 874 async revokeTrustedDevice( 914 875 token: string, 915 876 deviceId: string, 916 877 ): Promise<{ success: boolean }> { 917 - return xrpc("_account.revokeTrustedDevice", { 878 + return xrpc("com.tranquil.account.revokeTrustedDevice", { 918 879 method: "POST", 919 880 token, 920 881 body: { deviceId }, ··· 926 887 deviceId: string, 927 888 friendlyName: string, 928 889 ): Promise<{ success: boolean }> { 929 - return xrpc("_account.updateTrustedDevice", { 890 + return xrpc("com.tranquil.account.updateTrustedDevice", { 930 891 method: "POST", 931 892 token, 932 893 body: { deviceId, friendlyName }, ··· 938 899 lastReauthAt: string | null; 939 900 availableMethods: string[]; 940 901 }> { 941 - return xrpc("_account.getReauthStatus", { token }); 902 + return xrpc("com.tranquil.account.getReauthStatus", { token }); 942 903 }, 943 904 944 905 async reauthPassword( 945 906 token: string, 946 907 password: string, 947 908 ): Promise<{ success: boolean; reauthAt: string }> { 948 - return xrpc("_account.reauthPassword", { 909 + return xrpc("com.tranquil.account.reauthPassword", { 949 910 method: "POST", 950 911 token, 951 912 body: { password }, ··· 956 917 token: string, 957 918 code: string, 958 919 ): Promise<{ success: boolean; reauthAt: string }> { 959 - return xrpc("_account.reauthTotp", { 920 + return xrpc("com.tranquil.account.reauthTotp", { 960 921 method: "POST", 961 922 token, 962 923 body: { code }, ··· 964 925 }, 965 926 966 927 async reauthPasskeyStart(token: string): Promise<{ options: unknown }> { 967 - return xrpc("_account.reauthPasskeyStart", { 928 + return xrpc("com.tranquil.account.reauthPasskeyStart", { 968 929 method: "POST", 969 930 token, 970 931 }); ··· 974 935 token: string, 975 936 credential: unknown, 976 937 ): Promise<{ success: boolean; reauthAt: string }> { 977 - return xrpc("_account.reauthPasskeyFinish", { 938 + return xrpc("com.tranquil.account.reauthPasskeyFinish", { 978 939 method: "POST", 979 940 token, 980 941 body: { credential }, ··· 1021 982 setupToken: string; 1022 983 setupExpiresAt: string; 1023 984 }> { 1024 - const url = `${API_BASE}/_account.createPasskeyAccount`; 985 + const url = `${API_BASE}/com.tranquil.account.createPasskeyAccount`; 1025 986 const headers: Record<string, string> = { 1026 987 "Content-Type": "application/json", 1027 988 }; ··· 1048 1009 setupToken: string, 1049 1010 friendlyName?: string, 1050 1011 ): Promise<{ options: unknown }> { 1051 - return xrpc("_account.startPasskeyRegistrationForSetup", { 1012 + return xrpc("com.tranquil.account.startPasskeyRegistrationForSetup", { 1052 1013 method: "POST", 1053 1014 body: { did, setupToken, friendlyName }, 1054 1015 }); ··· 1065 1026 appPassword: string; 1066 1027 appPasswordName: string; 1067 1028 }> { 1068 - return xrpc("_account.completePasskeySetup", { 1029 + return xrpc("com.tranquil.account.completePasskeySetup", { 1069 1030 method: "POST", 1070 1031 body: { did, setupToken, passkeyCredential, passkeyFriendlyName }, 1071 1032 }); 1072 1033 }, 1073 1034 1074 1035 async requestPasskeyRecovery(email: string): Promise<{ success: boolean }> { 1075 - return xrpc("_account.requestPasskeyRecovery", { 1036 + return xrpc("com.tranquil.account.requestPasskeyRecovery", { 1076 1037 method: "POST", 1077 1038 body: { email }, 1078 1039 }); ··· 1083 1044 recoveryToken: string, 1084 1045 newPassword: string, 1085 1046 ): Promise<{ success: boolean }> { 1086 - return xrpc("_account.recoverPasskeyAccount", { 1047 + return xrpc("com.tranquil.account.recoverPasskeyAccount", { 1087 1048 method: "POST", 1088 1049 body: { did, recoveryToken, newPassword }, 1089 1050 }); ··· 1116 1077 purpose: string; 1117 1078 channel: string; 1118 1079 }> { 1119 - return xrpc("_account.verifyToken", { 1080 + return xrpc("com.tranquil.account.verifyToken", { 1120 1081 method: "POST", 1121 1082 body: { token, identifier }, 1122 1083 token: accessToken, ··· 1124 1085 }, 1125 1086 1126 1087 async getDidDocument(token: string): Promise<DidDocument> { 1127 - return xrpc("_account.getDidDocument", { token }); 1088 + return xrpc("com.tranquil.account.getDidDocument", { token }); 1128 1089 }, 1129 1090 1130 1091 async updateDidDocument( ··· 1135 1096 serviceEndpoint?: string; 1136 1097 }, 1137 1098 ): Promise<{ success: boolean }> { 1138 - return xrpc("_account.updateDidDocument", { 1099 + return xrpc("com.tranquil.account.updateDidDocument", { 1139 1100 method: "POST", 1140 1101 token, 1141 1102 body: params, ··· 1145 1106 async deactivateAccount( 1146 1107 token: string, 1147 1108 deleteAfter?: string, 1109 + migratingTo?: string, 1148 1110 ): Promise<void> { 1149 1111 await xrpc("com.atproto.server.deactivateAccount", { 1150 1112 method: "POST", 1151 1113 token, 1152 - body: { deleteAfter }, 1153 - }); 1154 - }, 1155 - 1156 - async getRepo(token: string, did: string): Promise<ArrayBuffer> { 1157 - const url = `${API_BASE}/com.atproto.sync.getRepo?did=${ 1158 - encodeURIComponent(did) 1159 - }`; 1160 - const res = await fetch(url, { 1161 - headers: { Authorization: `Bearer ${token}` }, 1114 + body: { deleteAfter, migratingTo }, 1162 1115 }); 1163 - if (!res.ok) { 1164 - const err = await res.json().catch(() => ({ 1165 - error: "Unknown", 1166 - message: res.statusText, 1167 - })); 1168 - throw new ApiError(res.status, err.error, err.message); 1169 - } 1170 - return res.arrayBuffer(); 1171 1116 }, 1172 1117 1173 - async listBackups(token: string): Promise<{ 1174 - backups: Array<{ 1175 - id: string; 1176 - repoRev: string; 1177 - repoRootCid: string; 1178 - blockCount: number; 1179 - sizeBytes: number; 1180 - createdAt: string; 1181 - }>; 1182 - backupEnabled: boolean; 1118 + async getMigrationStatus(token: string): Promise<{ 1119 + migratedToPds?: string; 1120 + migratedAt?: string; 1121 + forwardingEnabled: boolean; 1183 1122 }> { 1184 - return xrpc("_backup.listBackups", { token }); 1123 + return xrpc("com.tranquil.account.getMigrationStatus", { token }); 1185 1124 }, 1186 1125 1187 - async getBackup(token: string, id: string): Promise<Blob> { 1188 - const url = `${API_BASE}/_backup.getBackup?id=${encodeURIComponent(id)}`; 1189 - const res = await fetch(url, { 1190 - headers: { Authorization: `Bearer ${token}` }, 1191 - }); 1192 - if (!res.ok) { 1193 - const err = await res.json().catch(() => ({ 1194 - error: "Unknown", 1195 - message: res.statusText, 1196 - })); 1197 - throw new ApiError(res.status, err.error, err.message); 1198 - } 1199 - return res.blob(); 1200 - }, 1201 - 1202 - async createBackup(token: string): Promise<{ 1203 - id: string; 1204 - repoRev: string; 1205 - sizeBytes: number; 1206 - blockCount: number; 1207 - }> { 1208 - return xrpc("_backup.createBackup", { 1126 + async updateMigrationForwarding( 1127 + token: string, 1128 + forwardingPds?: string, 1129 + ): Promise<{ success: boolean }> { 1130 + return xrpc("com.tranquil.account.updateMigrationForwarding", { 1209 1131 method: "POST", 1210 1132 token, 1211 - }); 1212 - }, 1213 - 1214 - async deleteBackup(token: string, id: string): Promise<void> { 1215 - await xrpc("_backup.deleteBackup", { 1216 - method: "POST", 1217 - token, 1218 - params: { id }, 1133 + body: { forwardingPds }, 1219 1134 }); 1220 1135 }, 1221 1136 1222 - async setBackupEnabled( 1223 - token: string, 1224 - enabled: boolean, 1225 - ): Promise<{ enabled: boolean }> { 1226 - return xrpc("_backup.setEnabled", { 1137 + async clearMigrationForwarding(token: string): Promise<{ success: boolean }> { 1138 + return xrpc("com.tranquil.account.clearMigrationForwarding", { 1227 1139 method: "POST", 1228 1140 token, 1229 - body: { enabled }, 1230 1141 }); 1231 - }, 1232 - 1233 - async importRepo(token: string, car: Uint8Array): Promise<void> { 1234 - const url = `${API_BASE}/com.atproto.repo.importRepo`; 1235 - const res = await fetch(url, { 1236 - method: "POST", 1237 - headers: { 1238 - Authorization: `Bearer ${token}`, 1239 - "Content-Type": "application/vnd.ipld.car", 1240 - }, 1241 - body: car, 1242 - }); 1243 - if (!res.ok) { 1244 - const err = await res.json().catch(() => ({ 1245 - error: "Unknown", 1246 - message: res.statusText, 1247 - })); 1248 - throw new ApiError(res.status, err.error, err.message); 1249 - } 1250 1142 }, 1251 1143 };
-156
frontend/src/lib/migration/blob-migration.ts
··· 1 - import type { AtprotoClient } from "./atproto-client"; 2 - import type { MigrationProgress } from "./types"; 3 - 4 - export interface BlobMigrationResult { 5 - migrated: number; 6 - failed: string[]; 7 - total: number; 8 - sourceUnreachable: boolean; 9 - } 10 - 11 - export async function migrateBlobs( 12 - localClient: AtprotoClient, 13 - sourceClient: AtprotoClient | null, 14 - userDid: string, 15 - onProgress: (update: Partial<MigrationProgress>) => void, 16 - ): Promise<BlobMigrationResult> { 17 - const missingBlobs: string[] = []; 18 - let cursor: string | undefined; 19 - 20 - console.log("[blob-migration] Starting blob migration for", userDid); 21 - console.log( 22 - "[blob-migration] Source client:", 23 - sourceClient ? "available" : "NOT AVAILABLE", 24 - ); 25 - 26 - onProgress({ currentOperation: "Checking for missing blobs..." }); 27 - 28 - do { 29 - const { blobs, cursor: nextCursor } = await localClient.listMissingBlobs( 30 - cursor, 31 - 100, 32 - ); 33 - console.log( 34 - "[blob-migration] listMissingBlobs returned", 35 - blobs.length, 36 - "blobs, cursor:", 37 - nextCursor, 38 - ); 39 - for (const blob of blobs) { 40 - missingBlobs.push(blob.cid); 41 - } 42 - cursor = nextCursor; 43 - } while (cursor); 44 - 45 - console.log("[blob-migration] Total missing blobs:", missingBlobs.length); 46 - onProgress({ blobsTotal: missingBlobs.length }); 47 - 48 - if (missingBlobs.length === 0) { 49 - console.log("[blob-migration] No blobs to migrate"); 50 - onProgress({ currentOperation: "No blobs to migrate" }); 51 - return { migrated: 0, failed: [], total: 0, sourceUnreachable: false }; 52 - } 53 - 54 - if (!sourceClient) { 55 - console.warn( 56 - "[blob-migration] No source client available, cannot fetch blobs", 57 - ); 58 - onProgress({ 59 - currentOperation: 60 - `${missingBlobs.length} media files missing. No source PDS URL available - your old server may have shut down. Posts will work, but some images/media may be unavailable.`, 61 - }); 62 - return { 63 - migrated: 0, 64 - failed: missingBlobs, 65 - total: missingBlobs.length, 66 - sourceUnreachable: true, 67 - }; 68 - } 69 - 70 - onProgress({ currentOperation: `Migrating ${missingBlobs.length} blobs...` }); 71 - 72 - let migrated = 0; 73 - const failed: string[] = []; 74 - let sourceUnreachable = false; 75 - 76 - for (const cid of missingBlobs) { 77 - if (sourceUnreachable) { 78 - failed.push(cid); 79 - continue; 80 - } 81 - 82 - try { 83 - onProgress({ 84 - currentOperation: `Migrating blob ${ 85 - migrated + 1 86 - }/${missingBlobs.length}...`, 87 - }); 88 - 89 - console.log("[blob-migration] Fetching blob", cid, "from source"); 90 - const blobData = await sourceClient.getBlob(userDid, cid); 91 - console.log( 92 - "[blob-migration] Got blob", 93 - cid, 94 - "size:", 95 - blobData.byteLength, 96 - ); 97 - await localClient.uploadBlob(blobData, "application/octet-stream"); 98 - console.log("[blob-migration] Uploaded blob", cid); 99 - migrated++; 100 - onProgress({ blobsMigrated: migrated }); 101 - } catch (e) { 102 - const errorMessage = (e as Error).message || String(e); 103 - console.error( 104 - "[blob-migration] Failed to migrate blob", 105 - cid, 106 - ":", 107 - errorMessage, 108 - ); 109 - 110 - const isNetworkError = 111 - errorMessage.includes("fetch") || 112 - errorMessage.includes("network") || 113 - errorMessage.includes("CORS") || 114 - errorMessage.includes("Failed to fetch") || 115 - errorMessage.includes("NetworkError") || 116 - errorMessage.includes("blocked by CORS"); 117 - 118 - if (isNetworkError) { 119 - sourceUnreachable = true; 120 - console.warn( 121 - "[blob-migration] Source appears unreachable (likely CORS or network issue), skipping remaining blobs", 122 - ); 123 - const remaining = missingBlobs.length - migrated - 1; 124 - if (migrated > 0) { 125 - onProgress({ 126 - currentOperation: 127 - `Source PDS unreachable (browser security restriction). ${migrated} media files migrated successfully. ${remaining + 1} could not be fetched - these may need to be re-uploaded.`, 128 - }); 129 - } else { 130 - onProgress({ 131 - currentOperation: 132 - `Cannot reach source PDS (browser security restriction). This commonly happens when the old server has shut down or doesn't allow cross-origin requests. Your posts will work, but ${missingBlobs.length} media files couldn't be recovered.`, 133 - }); 134 - } 135 - } 136 - failed.push(cid); 137 - } 138 - } 139 - 140 - if (migrated === missingBlobs.length) { 141 - onProgress({ 142 - currentOperation: `All ${migrated} blobs migrated successfully`, 143 - }); 144 - } else if (migrated > 0) { 145 - onProgress({ 146 - currentOperation: 147 - `${migrated}/${missingBlobs.length} blobs migrated. ${failed.length} failed.`, 148 - }); 149 - } else { 150 - onProgress({ 151 - currentOperation: `Could not migrate blobs (${failed.length} missing)`, 152 - }); 153 - } 154 - 155 - return { migrated, failed, total: missingBlobs.length, sourceUnreachable }; 156 - }
+2 -8
frontend/src/lib/migration/index.ts
··· 1 1 export * from "./types"; 2 2 export * from "./atproto-client"; 3 3 export * from "./storage"; 4 - export * from "./blob-migration"; 5 4 export { 6 5 createInboundMigrationFlow, 6 + createOutboundMigrationFlow, 7 7 type InboundMigrationFlow, 8 + type OutboundMigrationFlow, 8 9 } from "./flow.svelte"; 9 - export { 10 - clearOfflineState, 11 - createOfflineInboundMigrationFlow, 12 - getOfflineResumeInfo, 13 - hasPendingOfflineMigration, 14 - } from "./offline-flow.svelte"; 15 - export type { OfflineInboundMigrationFlow } from "./offline-flow.svelte";
-765
frontend/src/lib/migration/offline-flow.svelte.ts
··· 1 - import type { 2 - AuthMethod, 3 - MigrationProgress, 4 - OfflineInboundMigrationState, 5 - OfflineInboundStep, 6 - ServerDescription, 7 - } from "./types"; 8 - import { 9 - AtprotoClient, 10 - base64UrlEncode, 11 - createLocalClient, 12 - prepareWebAuthnCreationOptions, 13 - } from "./atproto-client"; 14 - import { api } from "../api"; 15 - import { type KeypairInfo, plcOps, type PrivateKey } from "./plc-ops"; 16 - import { migrateBlobs as migrateBlobsUtil } from "./blob-migration"; 17 - import { Secp256k1PrivateKeyExportable } from "@atcute/crypto"; 18 - 19 - const OFFLINE_STORAGE_KEY = "tranquil_offline_migration_state"; 20 - const MAX_AGE_MS = 24 * 60 * 60 * 1000; 21 - 22 - interface StoredOfflineMigrationState { 23 - version: number; 24 - step: OfflineInboundStep; 25 - startedAt: string; 26 - userDid: string; 27 - carFileName: string; 28 - carSizeBytes: number; 29 - rotationKeyDidKey: string; 30 - targetHandle: string; 31 - targetEmail: string; 32 - authMethod: AuthMethod; 33 - passkeySetupToken?: string; 34 - oldPdsUrl?: string; 35 - plcUpdatedTemporarily?: boolean; 36 - progress: { 37 - accountCreated: boolean; 38 - repoImported: boolean; 39 - plcSigned: boolean; 40 - activated: boolean; 41 - }; 42 - lastError?: string; 43 - } 44 - 45 - function saveOfflineState(state: OfflineInboundMigrationState): void { 46 - const stored: StoredOfflineMigrationState = { 47 - version: 1, 48 - step: state.step, 49 - startedAt: new Date().toISOString(), 50 - userDid: state.userDid, 51 - carFileName: state.carFileName, 52 - carSizeBytes: state.carSizeBytes, 53 - rotationKeyDidKey: state.rotationKeyDidKey, 54 - targetHandle: state.targetHandle, 55 - targetEmail: state.targetEmail, 56 - authMethod: state.authMethod, 57 - passkeySetupToken: state.passkeySetupToken ?? undefined, 58 - oldPdsUrl: state.oldPdsUrl ?? undefined, 59 - plcUpdatedTemporarily: state.plcUpdatedTemporarily || undefined, 60 - progress: { 61 - accountCreated: state.progress.repoExported, 62 - repoImported: state.progress.repoImported, 63 - plcSigned: state.progress.plcSigned, 64 - activated: state.progress.activated, 65 - }, 66 - lastError: state.error ?? undefined, 67 - }; 68 - try { 69 - localStorage.setItem(OFFLINE_STORAGE_KEY, JSON.stringify(stored)); 70 - } catch { /* ignore localStorage errors */ } 71 - } 72 - 73 - function loadOfflineState(): StoredOfflineMigrationState | null { 74 - try { 75 - const stored = localStorage.getItem(OFFLINE_STORAGE_KEY); 76 - if (!stored) return null; 77 - const state = JSON.parse(stored) as StoredOfflineMigrationState; 78 - if (state.version !== 1) { 79 - clearOfflineState(); 80 - return null; 81 - } 82 - const startedAt = new Date(state.startedAt).getTime(); 83 - if (Date.now() - startedAt > MAX_AGE_MS) { 84 - clearOfflineState(); 85 - return null; 86 - } 87 - return state; 88 - } catch { 89 - /* ignore parse errors */ 90 - clearOfflineState(); 91 - return null; 92 - } 93 - } 94 - 95 - function clearOfflineState(): void { 96 - try { 97 - localStorage.removeItem(OFFLINE_STORAGE_KEY); 98 - } catch { /* ignore localStorage errors */ } 99 - } 100 - 101 - export function hasPendingOfflineMigration(): boolean { 102 - return loadOfflineState() !== null; 103 - } 104 - 105 - export function getOfflineResumeInfo(): { 106 - step: OfflineInboundStep; 107 - userDid: string; 108 - targetHandle: string; 109 - } | null { 110 - const state = loadOfflineState(); 111 - if (!state) return null; 112 - return { 113 - step: state.step, 114 - userDid: state.userDid, 115 - targetHandle: state.targetHandle, 116 - }; 117 - } 118 - 119 - export { clearOfflineState }; 120 - 121 - function createInitialProgress(): MigrationProgress { 122 - return { 123 - repoExported: false, 124 - repoImported: false, 125 - blobsTotal: 0, 126 - blobsMigrated: 0, 127 - blobsFailed: [], 128 - prefsMigrated: false, 129 - plcSigned: false, 130 - activated: false, 131 - deactivated: false, 132 - currentOperation: "", 133 - }; 134 - } 135 - 136 - export type OfflineInboundMigrationFlow = ReturnType< 137 - typeof createOfflineInboundMigrationFlow 138 - >; 139 - 140 - export function createOfflineInboundMigrationFlow() { 141 - let state = $state<OfflineInboundMigrationState>({ 142 - direction: "offline-inbound", 143 - step: "welcome", 144 - userDid: "", 145 - carFile: null, 146 - carFileName: "", 147 - carSizeBytes: 0, 148 - carNeedsReupload: false, 149 - rotationKey: "", 150 - rotationKeyDidKey: "", 151 - oldPdsUrl: null, 152 - targetHandle: "", 153 - targetEmail: "", 154 - targetPassword: "", 155 - inviteCode: "", 156 - authMethod: "password", 157 - localAccessToken: null, 158 - localRefreshToken: null, 159 - passkeySetupToken: null, 160 - generatedAppPassword: null, 161 - generatedAppPasswordName: null, 162 - emailVerifyToken: "", 163 - progress: createInitialProgress(), 164 - error: null, 165 - plcUpdatedTemporarily: false, 166 - }); 167 - 168 - let localServerInfo: ServerDescription | null = null; 169 - let userRotationKeypair: KeypairInfo | null = null; 170 - let tempVerificationKeypair: Secp256k1PrivateKeyExportable | null = null; 171 - 172 - function setStep(step: OfflineInboundStep) { 173 - state.step = step; 174 - state.error = null; 175 - if (step !== "success") { 176 - saveOfflineState(state); 177 - } 178 - } 179 - 180 - function setError(error: string | null) { 181 - state.error = error; 182 - saveOfflineState(state); 183 - } 184 - 185 - function setProgress(updates: Partial<MigrationProgress>) { 186 - state.progress = { ...state.progress, ...updates }; 187 - saveOfflineState(state); 188 - } 189 - 190 - async function loadLocalServerInfo(): Promise<ServerDescription> { 191 - if (!localServerInfo) { 192 - const client = createLocalClient(); 193 - localServerInfo = await client.describeServer(); 194 - } 195 - return localServerInfo; 196 - } 197 - 198 - async function checkHandleAvailability(handle: string): Promise<boolean> { 199 - const client = createLocalClient(); 200 - try { 201 - await client.resolveHandle(handle); 202 - return false; 203 - } catch { 204 - return true; 205 - } 206 - } 207 - 208 - async function validateRotationKey(): Promise<boolean> { 209 - if (!state.userDid || !state.rotationKey) { 210 - throw new Error("DID and rotation key are required"); 211 - } 212 - 213 - try { 214 - userRotationKeypair = await plcOps.getKeyPair(state.rotationKey.trim()); 215 - const { lastOperation } = await plcOps.getLastPlcOpFromPlc(state.userDid); 216 - const currentRotationKeys = lastOperation.rotationKeys || []; 217 - 218 - if (!currentRotationKeys.includes(userRotationKeypair.didPublicKey)) { 219 - state.rotationKeyDidKey = ""; 220 - return false; 221 - } 222 - 223 - state.rotationKeyDidKey = userRotationKeypair.didPublicKey; 224 - 225 - const pdsService = lastOperation.services?.atproto_pds; 226 - if (pdsService?.endpoint) { 227 - state.oldPdsUrl = pdsService.endpoint; 228 - console.log( 229 - "[offline-migration] Captured old PDS URL:", 230 - state.oldPdsUrl, 231 - ); 232 - } else { 233 - console.warn( 234 - "[offline-migration] No PDS service endpoint found in PLC document", 235 - ); 236 - console.log( 237 - "[offline-migration] PLC services:", 238 - JSON.stringify(lastOperation.services), 239 - ); 240 - } 241 - 242 - saveOfflineState(state); 243 - return true; 244 - } catch (e) { 245 - throw new Error(`Failed to parse rotation key: ${(e as Error).message}`); 246 - } 247 - } 248 - 249 - async function prepareTempCredentials(): Promise<string> { 250 - if (!userRotationKeypair) { 251 - throw new Error("Rotation key not validated"); 252 - } 253 - 254 - setProgress({ currentOperation: "Preparing temporary credentials..." }); 255 - 256 - tempVerificationKeypair = await Secp256k1PrivateKeyExportable 257 - .createKeypair(); 258 - const tempVerificationPublicKey = await tempVerificationKeypair 259 - .exportPublicKey("did"); 260 - 261 - const { lastOperation, base } = await plcOps.getLastPlcOpFromPlc( 262 - state.userDid, 263 - ); 264 - const prevCid = base.cid; 265 - 266 - setProgress({ currentOperation: "Updating DID document temporarily..." }); 267 - 268 - const localPdsUrl = globalThis.location.origin; 269 - await plcOps.signAndPublishNewOp( 270 - state.userDid, 271 - userRotationKeypair.keypair, 272 - lastOperation.alsoKnownAs || [], 273 - [userRotationKeypair.didPublicKey], 274 - localPdsUrl, 275 - tempVerificationPublicKey, 276 - prevCid, 277 - ); 278 - 279 - state.plcUpdatedTemporarily = true; 280 - saveOfflineState(state); 281 - 282 - const serverInfo = await loadLocalServerInfo(); 283 - const serviceAuthToken = await plcOps.createServiceAuthToken( 284 - state.userDid, 285 - serverInfo.did, 286 - tempVerificationKeypair as unknown as PrivateKey, 287 - "com.atproto.server.createAccount", 288 - ); 289 - 290 - return serviceAuthToken; 291 - } 292 - 293 - async function createPasswordAccount( 294 - serviceAuthToken: string, 295 - ): Promise<void> { 296 - setProgress({ currentOperation: "Creating account on new PDS..." }); 297 - 298 - const serverInfo = await loadLocalServerInfo(); 299 - const fullHandle = state.targetHandle.includes(".") 300 - ? state.targetHandle 301 - : `${state.targetHandle}.${serverInfo.availableUserDomains[0]}`; 302 - 303 - const createResult = await api.createAccountWithServiceAuth( 304 - serviceAuthToken, 305 - { 306 - did: state.userDid, 307 - handle: fullHandle, 308 - email: state.targetEmail, 309 - password: state.targetPassword, 310 - inviteCode: state.inviteCode || undefined, 311 - }, 312 - ); 313 - 314 - state.targetHandle = fullHandle; 315 - state.localAccessToken = createResult.accessJwt; 316 - state.localRefreshToken = createResult.refreshJwt; 317 - setProgress({ repoExported: true }); 318 - } 319 - 320 - async function createPasskeyAccount(serviceAuthToken: string): Promise<void> { 321 - setProgress({ currentOperation: "Creating passkey account on new PDS..." }); 322 - 323 - const serverInfo = await loadLocalServerInfo(); 324 - const fullHandle = state.targetHandle.includes(".") 325 - ? state.targetHandle 326 - : `${state.targetHandle}.${serverInfo.availableUserDomains[0]}`; 327 - 328 - const createResult = await api.createPasskeyAccount({ 329 - did: state.userDid, 330 - handle: fullHandle, 331 - email: state.targetEmail, 332 - inviteCode: state.inviteCode || undefined, 333 - }, serviceAuthToken); 334 - 335 - state.targetHandle = fullHandle; 336 - state.passkeySetupToken = createResult.setupToken; 337 - setProgress({ repoExported: true }); 338 - saveOfflineState(state); 339 - } 340 - 341 - async function signFinalPlcOperation(): Promise<void> { 342 - if (!userRotationKeypair || !state.localAccessToken) { 343 - throw new Error("Prerequisites not met for PLC signing"); 344 - } 345 - 346 - setProgress({ currentOperation: "Finalizing DID document..." }); 347 - 348 - const { base } = await plcOps.getLastPlcOpFromPlc(state.userDid); 349 - const prevCid = base.cid; 350 - 351 - const credentials = await api.getRecommendedDidCredentials( 352 - state.localAccessToken, 353 - ); 354 - 355 - await plcOps.signPlcOperationWithCredentials( 356 - state.userDid, 357 - userRotationKeypair.keypair, 358 - { 359 - rotationKeys: credentials.rotationKeys, 360 - alsoKnownAs: credentials.alsoKnownAs, 361 - verificationMethods: credentials.verificationMethods, 362 - services: credentials.services, 363 - }, 364 - [userRotationKeypair.didPublicKey], 365 - prevCid, 366 - ); 367 - 368 - setProgress({ plcSigned: true }); 369 - } 370 - 371 - async function importRepository(): Promise<void> { 372 - if (!state.carFile || !state.localAccessToken) { 373 - throw new Error("CAR file and access token are required"); 374 - } 375 - 376 - setProgress({ currentOperation: "Importing repository..." }); 377 - await api.importRepo(state.localAccessToken, state.carFile); 378 - setProgress({ repoImported: true }); 379 - } 380 - 381 - async function migrateBlobs(): Promise<void> { 382 - if (!state.localAccessToken) { 383 - throw new Error("Access token required"); 384 - } 385 - 386 - const localClient = createLocalClient(); 387 - localClient.setAccessToken(state.localAccessToken); 388 - 389 - if (state.oldPdsUrl) { 390 - setProgress({ 391 - currentOperation: `Will fetch blobs from ${state.oldPdsUrl}`, 392 - }); 393 - } else { 394 - setProgress({ 395 - currentOperation: "No source PDS URL available for blob migration", 396 - }); 397 - } 398 - 399 - const sourceClient = state.oldPdsUrl 400 - ? new AtprotoClient(state.oldPdsUrl) 401 - : null; 402 - 403 - const result = await migrateBlobsUtil( 404 - localClient, 405 - sourceClient, 406 - state.userDid, 407 - setProgress, 408 - ); 409 - 410 - state.progress.blobsFailed = result.failed; 411 - state.progress.blobsTotal = result.total; 412 - state.progress.blobsMigrated = result.migrated; 413 - 414 - if (result.total === 0) { 415 - setProgress({ currentOperation: "No blobs to migrate" }); 416 - } else if (result.sourceUnreachable) { 417 - setProgress({ 418 - currentOperation: 419 - `Source PDS unreachable. ${result.failed.length} blobs could not be migrated.`, 420 - }); 421 - } else if (result.failed.length > 0) { 422 - setProgress({ 423 - currentOperation: 424 - `${result.migrated}/${result.total} blobs migrated. ${result.failed.length} failed.`, 425 - }); 426 - } else { 427 - setProgress({ 428 - currentOperation: `All ${result.migrated} blobs migrated successfully`, 429 - }); 430 - } 431 - } 432 - 433 - async function activateAccount(): Promise<void> { 434 - if (!state.localAccessToken) { 435 - throw new Error("Access token required"); 436 - } 437 - 438 - setProgress({ currentOperation: "Activating account..." }); 439 - await api.activateAccount(state.localAccessToken); 440 - setProgress({ activated: true }); 441 - } 442 - 443 - async function submitEmailVerifyToken(token: string): Promise<void> { 444 - state.emailVerifyToken = token; 445 - setError(null); 446 - 447 - try { 448 - await api.verifyMigrationEmail(token, state.targetEmail); 449 - 450 - if (state.authMethod === "passkey") { 451 - setStep("passkey-setup"); 452 - } else { 453 - const session = await api.createSession( 454 - state.targetEmail, 455 - state.targetPassword, 456 - ); 457 - state.localAccessToken = session.accessJwt; 458 - state.localRefreshToken = session.refreshJwt; 459 - saveOfflineState(state); 460 - 461 - setStep("plc-signing"); 462 - await signFinalPlcOperation(); 463 - 464 - setStep("finalizing"); 465 - await activateAccount(); 466 - 467 - cleanup(); 468 - setStep("success"); 469 - } 470 - } catch (e) { 471 - const err = e as Error & { error?: string }; 472 - setError(err.message || err.error || "Email verification failed"); 473 - } 474 - } 475 - 476 - async function resendEmailVerification(): Promise<void> { 477 - await api.resendMigrationVerification(state.targetEmail); 478 - } 479 - 480 - let checkingEmailVerification = false; 481 - 482 - async function checkEmailVerifiedAndProceed(): Promise<boolean> { 483 - if (checkingEmailVerification) return false; 484 - if (state.authMethod === "passkey") return false; 485 - 486 - checkingEmailVerification = true; 487 - try { 488 - const { verified } = await api.checkEmailVerified(state.targetEmail); 489 - if (!verified) return false; 490 - 491 - const session = await api.createSession( 492 - state.targetEmail, 493 - state.targetPassword, 494 - ); 495 - state.localAccessToken = session.accessJwt; 496 - state.localRefreshToken = session.refreshJwt; 497 - saveOfflineState(state); 498 - 499 - setStep("plc-signing"); 500 - await signFinalPlcOperation(); 501 - 502 - setStep("finalizing"); 503 - await activateAccount(); 504 - 505 - cleanup(); 506 - setStep("success"); 507 - return true; 508 - } catch { 509 - return false; 510 - } finally { 511 - checkingEmailVerification = false; 512 - } 513 - } 514 - 515 - async function startPasskeyRegistration(): Promise<{ options: unknown }> { 516 - if (!state.passkeySetupToken) { 517 - throw new Error("No passkey setup token"); 518 - } 519 - 520 - return api.startPasskeyRegistrationForSetup( 521 - state.userDid, 522 - state.passkeySetupToken, 523 - ); 524 - } 525 - 526 - async function registerPasskey(passkeyName?: string): Promise<void> { 527 - if (!state.passkeySetupToken) { 528 - throw new Error("No passkey setup token"); 529 - } 530 - 531 - if (!globalThis.PublicKeyCredential) { 532 - throw new Error("Passkeys are not supported in this browser"); 533 - } 534 - 535 - const { options } = await startPasskeyRegistration(); 536 - 537 - const publicKeyOptions = prepareWebAuthnCreationOptions( 538 - options as { publicKey: Record<string, unknown> }, 539 - ); 540 - const credential = await navigator.credentials.create({ 541 - publicKey: publicKeyOptions, 542 - }); 543 - 544 - if (!credential) { 545 - throw new Error("Passkey creation was cancelled"); 546 - } 547 - 548 - const publicKeyCredential = credential as PublicKeyCredential; 549 - const response = publicKeyCredential 550 - .response as AuthenticatorAttestationResponse; 551 - 552 - const credentialData = { 553 - id: publicKeyCredential.id, 554 - rawId: base64UrlEncode(publicKeyCredential.rawId), 555 - type: publicKeyCredential.type, 556 - response: { 557 - clientDataJSON: base64UrlEncode(response.clientDataJSON), 558 - attestationObject: base64UrlEncode(response.attestationObject), 559 - }, 560 - }; 561 - 562 - const result = await api.completePasskeySetup( 563 - state.userDid, 564 - state.passkeySetupToken, 565 - credentialData, 566 - passkeyName, 567 - ); 568 - 569 - state.generatedAppPassword = result.appPassword; 570 - state.generatedAppPasswordName = result.appPasswordName; 571 - 572 - const session = await api.createSession( 573 - state.targetEmail, 574 - result.appPassword, 575 - ); 576 - state.localAccessToken = session.accessJwt; 577 - state.localRefreshToken = session.refreshJwt; 578 - saveOfflineState(state); 579 - 580 - setStep("app-password"); 581 - } 582 - 583 - async function proceedFromAppPassword(): Promise<void> { 584 - setStep("plc-signing"); 585 - await signFinalPlcOperation(); 586 - 587 - setStep("finalizing"); 588 - await activateAccount(); 589 - 590 - cleanup(); 591 - setStep("success"); 592 - } 593 - 594 - function cleanup(): void { 595 - clearOfflineState(); 596 - userRotationKeypair = null; 597 - tempVerificationKeypair = null; 598 - state.rotationKey = ""; 599 - } 600 - 601 - async function runMigration(): Promise<void> { 602 - try { 603 - setStep("creating"); 604 - 605 - const serviceAuthToken = await prepareTempCredentials(); 606 - 607 - if (state.authMethod === "passkey") { 608 - await createPasskeyAccount(serviceAuthToken); 609 - } else { 610 - await createPasswordAccount(serviceAuthToken); 611 - } 612 - 613 - setStep("importing"); 614 - await importRepository(); 615 - 616 - setStep("migrating-blobs"); 617 - await migrateBlobs(); 618 - 619 - if ( 620 - state.progress.blobsTotal > 0 || state.progress.blobsFailed.length > 0 621 - ) { 622 - await new Promise((resolve) => setTimeout(resolve, 3000)); 623 - } 624 - 625 - setStep("email-verify"); 626 - } catch (e) { 627 - setError((e as Error).message); 628 - setStep("error"); 629 - } 630 - } 631 - 632 - function reset() { 633 - clearOfflineState(); 634 - userRotationKeypair = null; 635 - tempVerificationKeypair = null; 636 - state = { 637 - direction: "offline-inbound", 638 - step: "welcome", 639 - userDid: "", 640 - carFile: null, 641 - carFileName: "", 642 - carSizeBytes: 0, 643 - carNeedsReupload: false, 644 - rotationKey: "", 645 - rotationKeyDidKey: "", 646 - oldPdsUrl: null, 647 - targetHandle: "", 648 - targetEmail: "", 649 - targetPassword: "", 650 - inviteCode: "", 651 - authMethod: "password", 652 - localAccessToken: null, 653 - localRefreshToken: null, 654 - passkeySetupToken: null, 655 - generatedAppPassword: null, 656 - generatedAppPasswordName: null, 657 - emailVerifyToken: "", 658 - progress: createInitialProgress(), 659 - error: null, 660 - plcUpdatedTemporarily: false, 661 - }; 662 - localServerInfo = null; 663 - } 664 - 665 - function tryResume(): boolean { 666 - const stored = loadOfflineState(); 667 - if (!stored) return false; 668 - 669 - state.userDid = stored.userDid; 670 - state.carFileName = stored.carFileName; 671 - state.carSizeBytes = stored.carSizeBytes; 672 - state.rotationKeyDidKey = stored.rotationKeyDidKey; 673 - state.targetHandle = stored.targetHandle; 674 - state.targetEmail = stored.targetEmail; 675 - state.authMethod = stored.authMethod ?? "password"; 676 - state.passkeySetupToken = stored.passkeySetupToken ?? null; 677 - state.oldPdsUrl = stored.oldPdsUrl ?? null; 678 - state.plcUpdatedTemporarily = stored.plcUpdatedTemporarily ?? false; 679 - state.step = stored.step; 680 - state.progress.repoExported = stored.progress.accountCreated; 681 - state.progress.repoImported = stored.progress.repoImported; 682 - state.progress.plcSigned = stored.progress.plcSigned; 683 - state.progress.activated = stored.progress.activated; 684 - state.error = stored.lastError ?? null; 685 - 686 - if (stored.carFileName && stored.carSizeBytes > 0) { 687 - state.carNeedsReupload = true; 688 - } 689 - 690 - return true; 691 - } 692 - 693 - function getLocalSession(): 694 - | { accessJwt: string; did: string; handle: string } 695 - | null { 696 - if (!state.localAccessToken) return null; 697 - return { 698 - accessJwt: state.localAccessToken, 699 - did: state.userDid, 700 - handle: state.targetHandle, 701 - }; 702 - } 703 - 704 - return { 705 - get state() { 706 - return state; 707 - }, 708 - getLocalSession, 709 - setStep, 710 - setError, 711 - setProgress, 712 - loadLocalServerInfo, 713 - checkHandleAvailability, 714 - validateRotationKey, 715 - runMigration, 716 - submitEmailVerifyToken, 717 - resendEmailVerification, 718 - checkEmailVerifiedAndProceed, 719 - startPasskeyRegistration, 720 - registerPasskey, 721 - proceedFromAppPassword, 722 - reset, 723 - tryResume, 724 - clearOfflineState, 725 - setUserDid(did: string) { 726 - state.userDid = did; 727 - saveOfflineState(state); 728 - }, 729 - setCarFile(file: Uint8Array, fileName: string) { 730 - state.carFile = file; 731 - state.carFileName = fileName; 732 - state.carSizeBytes = file.length; 733 - state.carNeedsReupload = false; 734 - saveOfflineState(state); 735 - }, 736 - setRotationKey(key: string) { 737 - state.rotationKey = key; 738 - }, 739 - setTargetHandle(handle: string) { 740 - state.targetHandle = handle; 741 - saveOfflineState(state); 742 - }, 743 - setTargetEmail(email: string) { 744 - state.targetEmail = email; 745 - saveOfflineState(state); 746 - }, 747 - setTargetPassword(password: string) { 748 - state.targetPassword = password; 749 - }, 750 - setInviteCode(code: string) { 751 - state.inviteCode = code; 752 - }, 753 - setAuthMethod(method: AuthMethod) { 754 - state.authMethod = method; 755 - saveOfflineState(state); 756 - }, 757 - updateField<K extends keyof OfflineInboundMigrationState>( 758 - field: K, 759 - value: OfflineInboundMigrationState[K], 760 - ) { 761 - state[field] = value; 762 - saveOfflineState(state); 763 - }, 764 - }; 765 - }
-281
frontend/src/lib/migration/plc-ops.ts
··· 1 - import { 2 - defs, 3 - type IndexedEntry, 4 - normalizeOp, 5 - type Operation, 6 - } from "@atcute/did-plc"; 7 - import { 8 - P256PrivateKey, 9 - parsePrivateMultikey, 10 - Secp256k1PrivateKey, 11 - Secp256k1PrivateKeyExportable, 12 - } from "@atcute/crypto"; 13 - import * as CBOR from "@atcute/cbor"; 14 - import { fromBase16, toBase64Url } from "@atcute/multibase"; 15 - 16 - export type PrivateKey = P256PrivateKey | Secp256k1PrivateKey; 17 - 18 - export interface KeypairInfo { 19 - type: "private_key"; 20 - didPublicKey: `did:key:${string}`; 21 - keypair: PrivateKey; 22 - } 23 - 24 - export interface PlcService { 25 - type: string; 26 - endpoint: string; 27 - } 28 - 29 - export interface PlcOperationData { 30 - type: "plc_operation"; 31 - prev: string; 32 - alsoKnownAs: string[]; 33 - rotationKeys: string[]; 34 - services: Record<string, PlcService>; 35 - verificationMethods: Record<string, string>; 36 - sig?: string; 37 - } 38 - 39 - const jsonToB64Url = (obj: unknown): string => { 40 - const enc = new TextEncoder(); 41 - const json = JSON.stringify(obj); 42 - return toBase64Url(enc.encode(json)); 43 - }; 44 - 45 - export class PlcOps { 46 - private plcDirectoryUrl: string; 47 - 48 - constructor(plcDirectoryUrl = "https://plc.directory") { 49 - this.plcDirectoryUrl = plcDirectoryUrl; 50 - } 51 - 52 - async getPlcAuditLogs(did: string): Promise<IndexedEntry[]> { 53 - const response = await fetch(`${this.plcDirectoryUrl}/${did}/log/audit`); 54 - if (!response.ok) { 55 - throw new Error(`Failed to fetch PLC audit logs: ${response.status}`); 56 - } 57 - const json = await response.json(); 58 - return defs.indexedEntryLog.parse(json); 59 - } 60 - 61 - async getLastPlcOpFromPlc( 62 - did: string, 63 - ): Promise<{ lastOperation: Operation; base: IndexedEntry }> { 64 - const logs = await this.getPlcAuditLogs(did); 65 - const lastOp = logs.at(-1); 66 - if (!lastOp) { 67 - throw new Error("No PLC operations found for this DID"); 68 - } 69 - return { lastOperation: normalizeOp(lastOp.operation), base: lastOp }; 70 - } 71 - 72 - async getCurrentRotationKeysForUser(did: string): Promise<string[]> { 73 - const { lastOperation } = await this.getLastPlcOpFromPlc(did); 74 - return lastOperation.rotationKeys || []; 75 - } 76 - 77 - async createNewSecp256k1Keypair(): Promise< 78 - { privateKey: string; publicKey: `did:key:${string}` } 79 - > { 80 - const keypair = await Secp256k1PrivateKeyExportable.createKeypair(); 81 - const publicKey = await keypair.exportPublicKey("did"); 82 - const privateKey = await keypair.exportPrivateKey("multikey"); 83 - return { privateKey, publicKey }; 84 - } 85 - 86 - async getKeyPair( 87 - privateKeyString: string, 88 - type: "secp256k1" | "p256" = "secp256k1", 89 - ): Promise<KeypairInfo> { 90 - const HEX_REGEX = /^[0-9a-f]+$/i; 91 - const MULTIKEY_REGEX = /^z[a-km-zA-HJ-NP-Z1-9]+$/; 92 - let keypair: PrivateKey | undefined; 93 - 94 - const trimmed = privateKeyString.trim(); 95 - 96 - if (HEX_REGEX.test(trimmed) && trimmed.length === 64) { 97 - const privateKeyBytes = fromBase16(trimmed); 98 - if (type === "p256") { 99 - keypair = await P256PrivateKey.importRaw(privateKeyBytes); 100 - } else { 101 - keypair = await Secp256k1PrivateKey.importRaw(privateKeyBytes); 102 - } 103 - } else if (MULTIKEY_REGEX.test(trimmed)) { 104 - const match = parsePrivateMultikey(trimmed); 105 - const privateKeyBytes = match.privateKeyBytes; 106 - if (match.type === "p256") { 107 - keypair = await P256PrivateKey.importRaw(privateKeyBytes); 108 - } else if (match.type === "secp256k1") { 109 - keypair = await Secp256k1PrivateKey.importRaw(privateKeyBytes); 110 - } else { 111 - throw new Error(`Unsupported key type: ${match.type}`); 112 - } 113 - } else { 114 - throw new Error( 115 - "Invalid key format. Expected 64-char hex or multikey format.", 116 - ); 117 - } 118 - 119 - if (!keypair) { 120 - throw new Error("Failed to parse private key"); 121 - } 122 - 123 - return { 124 - type: "private_key", 125 - didPublicKey: await keypair.exportPublicKey("did"), 126 - keypair, 127 - }; 128 - } 129 - 130 - async signAndPublishNewOp( 131 - did: string, 132 - signingRotationKey: PrivateKey, 133 - alsoKnownAs: string[], 134 - rotationKeys: string[], 135 - pds: string, 136 - verificationKey: string, 137 - prev: string, 138 - ): Promise<void> { 139 - const rotationKeysToUse = [...new Set(rotationKeys)]; 140 - if (rotationKeysToUse.length === 0) { 141 - throw new Error("No rotation keys provided"); 142 - } 143 - if (rotationKeysToUse.length > 5) { 144 - throw new Error("Maximum 5 rotation keys allowed"); 145 - } 146 - 147 - const operation: PlcOperationData = { 148 - type: "plc_operation", 149 - prev, 150 - alsoKnownAs, 151 - rotationKeys: rotationKeysToUse, 152 - services: { 153 - atproto_pds: { 154 - type: "AtprotoPersonalDataServer", 155 - endpoint: pds, 156 - }, 157 - }, 158 - verificationMethods: { 159 - atproto: verificationKey, 160 - }, 161 - }; 162 - 163 - const opBytes = CBOR.encode(operation); 164 - const sigBytes = await signingRotationKey.sign(opBytes); 165 - const signature = toBase64Url(sigBytes); 166 - 167 - const signedOperation = { 168 - ...operation, 169 - sig: signature, 170 - }; 171 - 172 - await this.pushPlcOperation(did, signedOperation); 173 - } 174 - 175 - async pushPlcOperation( 176 - did: string, 177 - operation: PlcOperationData, 178 - ): Promise<void> { 179 - const response = await fetch(`${this.plcDirectoryUrl}/${did}`, { 180 - method: "POST", 181 - headers: { 182 - "Content-Type": "application/json", 183 - }, 184 - body: JSON.stringify(operation), 185 - }); 186 - 187 - if (!response.ok) { 188 - const contentType = response.headers.get("content-type"); 189 - if (contentType?.includes("application/json")) { 190 - const json = await response.json(); 191 - if ( 192 - typeof json === "object" && json !== null && 193 - typeof json.message === "string" 194 - ) { 195 - throw new Error(json.message); 196 - } 197 - } 198 - throw new Error(`PLC directory returned HTTP ${response.status}`); 199 - } 200 - } 201 - 202 - async createServiceAuthToken( 203 - iss: string, 204 - aud: string, 205 - keypair: PrivateKey, 206 - lxm: string, 207 - ): Promise<string> { 208 - const iat = Math.floor(Date.now() / 1000); 209 - const exp = iat + 60; 210 - 211 - const jti = (() => { 212 - const bytes = new Uint8Array(16); 213 - crypto.getRandomValues(bytes); 214 - return Array.from(bytes) 215 - .map((b) => b.toString(16).padStart(2, "0")) 216 - .join(""); 217 - })(); 218 - 219 - const header = { typ: "JWT", alg: "ES256K" }; 220 - const payload = { iat, iss, aud, exp, lxm, jti }; 221 - 222 - const headerB64 = jsonToB64Url(header); 223 - const payloadB64 = jsonToB64Url(payload); 224 - const toSignStr = `${headerB64}.${payloadB64}`; 225 - 226 - const toSignBytes = new TextEncoder().encode(toSignStr); 227 - const sigBytes = await keypair.sign(toSignBytes); 228 - const sigB64 = toBase64Url(sigBytes); 229 - 230 - return `${toSignStr}.${sigB64}`; 231 - } 232 - 233 - async signPlcOperationWithCredentials( 234 - did: string, 235 - signingKey: PrivateKey, 236 - credentials: { 237 - rotationKeys?: string[]; 238 - alsoKnownAs?: string[]; 239 - verificationMethods?: Record<string, string>; 240 - services?: Record<string, PlcService>; 241 - }, 242 - additionalRotationKeys: string[], 243 - prevCid: string, 244 - ): Promise<void> { 245 - const rotationKeys = [ 246 - ...new Set([ 247 - ...(additionalRotationKeys || []), 248 - ...(credentials.rotationKeys || []), 249 - ]), 250 - ]; 251 - 252 - if (rotationKeys.length === 0) { 253 - throw new Error("No rotation keys provided"); 254 - } 255 - if (rotationKeys.length > 5) { 256 - throw new Error("Maximum 5 rotation keys allowed"); 257 - } 258 - 259 - const operation: PlcOperationData = { 260 - type: "plc_operation", 261 - prev: prevCid, 262 - alsoKnownAs: credentials.alsoKnownAs || [], 263 - rotationKeys, 264 - services: credentials.services || {}, 265 - verificationMethods: credentials.verificationMethods || {}, 266 - }; 267 - 268 - const opBytes = CBOR.encode(operation); 269 - const sigBytes = await signingKey.sign(opBytes); 270 - const signature = toBase64Url(sigBytes); 271 - 272 - const signedOperation = { 273 - ...operation, 274 - sig: signature, 275 - }; 276 - 277 - await this.pushPlcOperation(did, signedOperation); 278 - } 279 - } 280 - 281 - export const plcOps = new PlcOps();
+98 -152
frontend/src/locales/en.json
··· 17 17 "dashboard": "Dashboard", 18 18 "backToDashboard": "โ† Dashboard", 19 19 "copied": "Copied!", 20 - "copyToClipboard": "Copy to Clipboard", 21 - 22 - "verifying": "Verifying...", 23 - "saving": "Saving...", 24 - "creating": "Creating...", 25 - "updating": "Updating...", 26 - "sending": "Sending...", 27 - "authenticating": "Authenticating...", 28 - "checking": "Checking...", 29 - "redirecting": "Redirecting...", 30 - 31 - "signIn": "Sign In", 32 - "verify": "Verify", 33 - "remove": "Remove", 34 - "revoke": "Revoke", 35 - "resendCode": "Resend Code", 36 - "startOver": "Start Over", 37 - "tryAgain": "Try Again", 38 - 39 - "password": "Password", 40 - "email": "Email", 41 - "emailAddress": "Email Address", 42 - "handle": "Handle", 43 - "did": "DID", 44 - "verificationCode": "Verification Code", 45 - "inviteCode": "Invite Code", 46 - "newPassword": "New Password", 47 - "confirmPassword": "Confirm Password", 48 - 49 - "enterSixDigitCode": "Enter 6-digit code", 50 - "passwordHint": "At least 8 characters", 51 - "enterPassword": "Enter your password", 52 - "emailPlaceholder": "you@example.com", 53 - 54 - "verified": "Verified", 55 - "disabled": "Disabled", 56 - "available": "Available", 57 - "deactivated": "Deactivated", 58 - "unverified": "Unverified", 59 - 60 - "backToLogin": "Back to Login", 61 - "backToSettings": "Back to Settings", 62 - "alreadyHaveAccount": "Already have an account?", 63 - "createAccount": "Create account", 64 - 65 - "passwordsMismatch": "Passwords do not match", 66 - "passwordTooShort": "Password must be at least 8 characters" 20 + "copyToClipboard": "Copy to Clipboard" 67 21 }, 68 22 "login": { 69 23 "title": "Sign In", ··· 95 49 "codeLabel": "Verification Code", 96 50 "codePlaceholder": "Enter 6-digit code", 97 51 "verifyButton": "Verify Account", 98 - "resent": "Verification code resent!" 52 + "verifying": "Verifying...", 53 + "resendButton": "Resend Code", 54 + "resending": "Resending...", 55 + "resent": "Verification code resent!", 56 + "backToLogin": "Back to Login" 99 57 }, 100 58 "register": { 101 59 "title": "Create Account", ··· 166 124 "inviteCodePlaceholder": "Enter your invite code", 167 125 "inviteCodeRequired": "required", 168 126 "createButton": "Create Account", 127 + "creating": "Creating account...", 169 128 "alreadyHaveAccount": "Already have an account?", 170 129 "signIn": "Sign in", 171 130 "wantPasswordless": "Want passwordless security?", ··· 220 179 "navAdminDesc": "Server stats and admin operations", 221 180 "navDidDocument": "DID Document", 222 181 "navDidDocumentDesc": "Manage your DID document for external migrations", 223 - "navDidDocumentDescActive": "Edit your DID document settings", 224 - "navBackup": "Download Backup", 225 - "navBackupDesc": "Download your repository as a CAR file", 226 - "downloadingBackup": "Downloading...", 227 - "backupFailed": "Failed to download backup", 228 182 "migrated": "Migrated", 229 183 "migratedTitle": "Account Migrated", 230 184 "migratedMessage": "Your account has migrated to {pds}. Your DID document is still hosted here, and you can update it for future migrations.", ··· 254 208 "serviceEndpointDesc": "The PDS that currently hosts your account data. Update this when migrating.", 255 209 "currentPds": "Current PDS URL", 256 210 "save": "Save Changes", 211 + "saving": "Saving...", 257 212 "success": "DID document updated successfully", 258 213 "saveFailed": "Failed to save DID document", 259 214 "loadFailed": "Failed to load DID document", ··· 291 246 "yourDomain": "Your Domain", 292 247 "yourDomainPlaceholder": "example.com", 293 248 "verifyAndUpdate": "Verify & Update Handle", 249 + "verifying": "Verifying...", 294 250 "newHandle": "New Handle", 295 251 "newHandlePlaceholder": "yourhandle", 296 252 "changeHandleButton": "Change Handle", ··· 306 262 "exportData": "Export Data", 307 263 "exportDataDescription": "Download your entire repository as a CAR (Content Addressable Archive) file. This includes all your posts, likes, follows, and other data.", 308 264 "downloadRepo": "Download Repository", 309 - "downloadBlobs": "Download Media", 310 265 "exporting": "Exporting...", 311 - "backups": { 312 - "title": "Backups", 313 - "description": "Your repository is automatically backed up daily. You can also create manual backups or restore from a previous backup.", 314 - "enableAutomatic": "Enable automatic backups", 315 - "enabled": "Automatic backups enabled", 316 - "disabled": "Automatic backups disabled", 317 - "toggleFailed": "Failed to update backup setting", 318 - "noBackups": "No backups available yet.", 319 - "blocks": "blocks", 320 - "download": "Download", 321 - "delete": "Delete", 322 - "createNow": "Create Backup Now", 323 - "created": "Backup created successfully", 324 - "createFailed": "Failed to create backup", 325 - "downloadFailed": "Failed to download backup", 326 - "deleted": "Backup deleted", 327 - "deleteFailed": "Failed to delete backup", 328 - "restoreTitle": "Restore from Backup", 329 - "restoreDescription": "Upload a CAR file to restore your repository. This will overwrite your current data.", 330 - "selectFile": "Select CAR file", 331 - "selectedFile": "Selected file", 332 - "restore": "Restore", 333 - "restoring": "Restoring...", 334 - "restored": "Repository restored successfully", 335 - "restoreFailed": "Failed to restore repository" 336 - }, 337 266 "deleteAccount": "Delete Account", 338 267 "deleteWarning": "This action is irreversible. All your data will be permanently deleted.", 339 268 "requestDeletion": "Request Account Deletion", ··· 362 291 "deleteConfirmation": "Are you absolutely sure you want to delete your account? This cannot be undone.", 363 292 "deletionFailed": "Failed to delete account", 364 293 "repoExported": "Repository exported successfully", 365 - "blobsExported": "Media files exported successfully", 366 - "noBlobsToExport": "No media files to export", 367 - "exportFailed": "Failed to export", 294 + "exportFailed": "Failed to export repository", 368 295 "confirmDelete": "Are you absolutely sure you want to delete your account? This cannot be undone." 369 296 } 370 297 }, ··· 379 306 "noPasswords": "No app passwords yet", 380 307 "revoke": "Revoke", 381 308 "revoking": "Revoking...", 309 + "creating": "Creating...", 382 310 "revokeConfirm": "Revoke app password \"{name}\"? Apps using this password will no longer be able to access your account.", 383 311 "saveWarningTitle": "Important: Save this app password!", 384 312 "saveWarningMessage": "This password is required to sign into apps that don't support passkeys or OAuth. You will only see it once.", ··· 426 354 "used": "Used by @{handle}", 427 355 "disabled": "Disabled", 428 356 "usedBy": "Used by", 357 + "creating": "Creating...", 429 358 "disableConfirm": "Disable this invite code? It can no longer be used.", 430 359 "created": "Invite Code Created", 431 360 "copy": "Copy", ··· 553 482 "verifyButton": "Verify", 554 483 "verifyCodePlaceholder": "Enter verification code", 555 484 "submit": "Submit", 485 + "saving": "Saving...", 556 486 "savePreferences": "Save Preferences", 557 487 "preferencesSaved": "Communication preferences saved", 558 488 "verifiedSuccess": "{channel} verified successfully", ··· 591 521 "noCollectionsYet": "No collections yet. Create your first record to get started.", 592 522 "loadMore": "Load More", 593 523 "recordJson": "Record JSON", 524 + "saving": "Saving...", 594 525 "updateRecord": "Update Record", 595 526 "collectionNsid": "Collection (NSID)", 596 527 "recordKeyOptional": "Record Key (optional)", 597 528 "autoGenerated": "Auto-generated if empty (TID)", 598 529 "autoGeneratedHint": "Leave empty to auto-generate a TID-based key", 530 + "creating": "Creating...", 599 531 "demoPostText": "Hello from my PDS! This is my first post.", 600 532 "demoDisplayName": "Your Display Name", 601 533 "demoBio": "A short bio about yourself." ··· 619 551 "secondaryLight": "Secondary (Light Mode)", 620 552 "secondaryDark": "Secondary (Dark Mode)", 621 553 "configSaved": "Server configuration saved", 554 + "saving": "Saving...", 622 555 "saveConfig": "Save Configuration", 623 556 "serverStats": "Server Statistics", 624 557 "users": "Users", ··· 706 639 "title": "Two-Factor Authentication", 707 640 "subtitle": "Additional verification is required", 708 641 "usePasskey": "Use Passkey", 709 - "useTotp": "Use Authenticator App" 642 + "useTotp": "Use Authenticator App", 643 + "verifying": "Verifying..." 710 644 }, 711 645 "twoFactorCode": { 712 646 "title": "Two-Factor Authentication", 713 647 "subtitle": "A verification code has been sent to your {channel}. Enter the code below to continue.", 714 648 "codeLabel": "Verification Code", 715 649 "codePlaceholder": "Enter 6-digit code", 650 + "verify": "Verify", 651 + "verifying": "Verifying...", 716 652 "errors": { 717 653 "missingRequestUri": "Missing request_uri parameter", 718 654 "verificationFailed": "Verification failed", ··· 724 660 "title": "Enter Authenticator Code", 725 661 "subtitle": "Enter the 6-digit code from your authenticator app", 726 662 "codePlaceholder": "Enter 6-digit code", 663 + "verify": "Verify", 664 + "verifying": "Verifying...", 727 665 "useBackupCode": "Use backup code instead", 728 666 "backupCodePlaceholder": "Enter backup code", 729 667 "trustDevice": "Trust this device for 30 days", ··· 753 691 "codeLabel": "Verification Code", 754 692 "codeHelp": "Copy the entire code from your message, including dashes", 755 693 "verifyButton": "Verify Account", 694 + "verify": "Verify", 695 + "verifying": "Verifying...", 756 696 "pleaseWait": "Please wait...", 697 + "resendCode": "Resend Code", 698 + "resending": "Resending...", 699 + "sending": "Sending...", 757 700 "codeResent": "Verification code resent!", 758 701 "codeResentDetail": "Verification code sent! Check your inbox.", 702 + "backToLogin": "Back to Login", 703 + "backToSettings": "Back to Settings", 759 704 "verifyingAccount": "Verifying account: @{handle}", 760 705 "startOver": "Start over with a different account", 761 706 "noPending": "No pending verification found.", ··· 801 746 "resetButton": "Reset Password", 802 747 "resetting": "Resetting...", 803 748 "success": "Password reset successfully!", 749 + "backToLogin": "Back to Sign In", 804 750 "requestNewCode": "Request New Code", 805 751 "passwordsMismatch": "Passwords do not match", 806 752 "passwordLength": "Password must be at least 8 characters" ··· 844 790 "howItWorks": "How it works", 845 791 "howItWorksDetail": "We'll send a secure link to your registered notification channel. Click the link to set a temporary password. Then you can sign in and add a new passkey.", 846 792 "sendRecoveryLink": "Send Recovery Link", 847 - "sending": "Sending..." 793 + "sending": "Sending...", 794 + "backToLogin": "Back to Sign In" 848 795 }, 849 796 "registerPasskey": { 850 797 "title": "Create Passkey Account", ··· 867 814 "inviteCode": "Invite Code", 868 815 "inviteCodePlaceholder": "Enter your invite code", 869 816 "createButton": "Create Account", 817 + "creating": "Creating...", 870 818 "continue": "Continue", 871 819 "back": "Back", 872 820 "alreadyHaveAccount": "Already have an account?", ··· 963 911 "useTotp": "Use Authenticator", 964 912 "passwordPlaceholder": "Enter your password", 965 913 "totpPlaceholder": "Enter 6-digit code", 914 + "verify": "Verify", 915 + "verifying": "Verifying...", 966 916 "authenticating": "Authenticating...", 967 917 "passkeyPrompt": "Click the button below to authenticate with your passkey.", 968 918 "cancel": "Cancel" ··· 997 947 "handle": "Handle", 998 948 "emailOptional": "Email (optional)", 999 949 "yourAccessLevel": "Your Access Level", 950 + "creating": "Creating...", 1000 951 "createAccount": "Create Account", 1001 952 "createDelegatedAccountButton": "+ Create Delegated Account", 1002 953 "accountCreated": "Created delegated account: {handle}", ··· 1108 1059 "navDesc": "Move your account to or from another PDS", 1109 1060 "migrateHere": "Migrate Here", 1110 1061 "migrateHereDesc": "Move your existing AT Protocol account to this PDS from another server.", 1062 + "migrateAway": "Migrate Away", 1063 + "migrateAwayDesc": "Move your account from this PDS to another server.", 1064 + "loginRequired": "Login required", 1111 1065 "bringDid": "Bring your DID and identity", 1112 1066 "transferData": "Transfer all your data", 1113 1067 "keepFollowers": "Keep your followers", 1068 + "exportRepo": "Export your repository", 1069 + "transferToPds": "Transfer to new PDS", 1070 + "updateIdentity": "Update your identity", 1114 1071 "whatIsMigration": "What is account migration?", 1115 1072 "whatIsMigrationDesc": "Account migration allows you to move your AT Protocol identity between Personal Data Servers (PDSes). Your DID (decentralized identifier) stays the same, so your followers and social connections are preserved.", 1116 1073 "beforeMigrate": "Before you migrate", ··· 1120 1077 "beforeMigrate4": "Your old PDS will be notified to deactivate your account", 1121 1078 "importantWarning": "Account migration is a significant action. Make sure you trust the destination PDS and understand that your data will be moved. If something goes wrong, recovery may require manual intervention.", 1122 1079 "learnMore": "Learn more about migration risks", 1123 - "offlineRestore": "Offline Restore", 1124 - "offlineRestoreDesc": "Restore from backup when your old PDS is unavailable.", 1125 - "offlineFeature1": "Use a CAR file backup", 1126 - "offlineFeature2": "Prove ownership with rotation key", 1127 - "offlineFeature3": "Recovery for shutdown servers", 1080 + "comingSoon": "Coming soon", 1128 1081 "oauthCompleting": "Completing authentication...", 1129 1082 "oauthFailed": "Authentication Failed", 1130 1083 "tryAgain": "Try Again", ··· 1133 1086 "incomplete": "You have an incomplete migration in progress:", 1134 1087 "direction": "Direction", 1135 1088 "migratingHere": "Migrating here", 1089 + "migratingAway": "Migrating away", 1136 1090 "from": "From", 1137 1091 "to": "To", 1138 1092 "progress": "Progress", ··· 1275 1229 "error": { 1276 1230 "title": "Migration Error", 1277 1231 "desc": "An error occurred during migration.", 1278 - "startOver": "Start Over", 1279 - "unknown": "An unknown error occurred." 1232 + "startOver": "Start Over" 1280 1233 }, 1281 1234 "common": { 1282 1235 "back": "Back", ··· 1294 1247 "warning3": "Your old account will be deactivated after migration" 1295 1248 } 1296 1249 }, 1297 - "offline": { 1250 + "outbound": { 1298 1251 "welcome": { 1299 - "title": "Offline Restore", 1300 - "desc": "Restore your account when your old PDS is unavailable. This is for disaster recovery when you cannot contact your previous server.", 1301 - "warningTitle": "Advanced Recovery Method", 1302 - "warningDesc": "This method requires your rotation key private key. Only use this if your previous PDS has shut down or you cannot access it.", 1303 - "requirementsTitle": "You will need:", 1304 - "requirement1": "Your DID (did:plc:...)", 1305 - "requirement2": "A CAR file backup of your repository", 1306 - "requirement3": "Your rotation key (private key in hex, base58, or JWK format)", 1307 - "understand": "I understand this is for offline recovery only" 1308 - }, 1309 - "provideDid": { 1310 - "title": "Enter Your DID", 1311 - "desc": "Enter the DID of the account you want to restore.", 1312 - "label": "Your DID", 1313 - "hint": "Your decentralized identifier (e.g., did:plc:abc123...)" 1314 - }, 1315 - "uploadCar": { 1316 - "title": "Upload Repository Backup", 1317 - "desc": "Upload the CAR file containing your repository data.", 1318 - "label": "CAR File", 1319 - "hint": "This should be a .car file from a previous backup of your repository", 1320 - "reuploadWarningTitle": "CAR File Required", 1321 - "reuploadWarning": "Your session was restored, but you need to re-upload your CAR file. For security reasons, file contents are not stored between sessions." 1252 + "title": "Migrate Away from This PDS", 1253 + "desc": "Move your account to another Personal Data Server.", 1254 + "warning": "After migration, your account here will be deactivated.", 1255 + "didWebNotice": "did:web Migration Notice", 1256 + "didWebNoticeDesc": "Your account uses a did:web identifier ({did}). After migrating, this PDS will continue to serve your DID document pointing to the new PDS. Your identity will remain functional as long as this server is online.", 1257 + "understand": "I understand the risks and want to proceed" 1322 1258 }, 1323 - "rotationKey": { 1324 - "title": "Provide Rotation Key", 1325 - "desc": "Enter your rotation key to prove ownership of this DID.", 1326 - "securityWarningTitle": "Security Warning", 1327 - "securityWarning1": "Your rotation key is extremely sensitive - anyone with it can take over your identity", 1328 - "securityWarning2": "Only enter it on trusted devices and connections", 1329 - "securityWarning3": "The key will not be stored after migration", 1330 - "label": "Rotation Key", 1331 - "placeholder": "Paste your rotation key (hex, base58, or JWK)...", 1332 - "hint": "Supports 64-character hex, base58, or JWK format", 1333 - "valid": "Rotation key verified! You have control of this DID.", 1334 - "invalid": "This key is not a valid rotation key for this DID.", 1259 + "targetPds": { 1260 + "title": "Choose Target PDS", 1261 + "desc": "Enter the URL of the PDS you want to migrate to.", 1262 + "url": "PDS URL", 1263 + "urlPlaceholder": "https://pds.example.com", 1264 + "validate": "Validate & Continue", 1335 1265 "validating": "Validating...", 1336 - "validate": "Validate Key" 1266 + "connected": "Connected to {name}", 1267 + "inviteRequired": "Invite code required", 1268 + "privacyPolicy": "Privacy Policy", 1269 + "termsOfService": "Terms of Service" 1337 1270 }, 1338 - "chooseHandle": { 1339 - "migratingDid": "Restoring DID" 1271 + "newAccount": { 1272 + "title": "New Account Details", 1273 + "desc": "Set up your account on the new PDS.", 1274 + "handle": "Handle", 1275 + "availableDomains": "Available domains", 1276 + "email": "Email", 1277 + "password": "Password", 1278 + "confirmPassword": "Confirm Password", 1279 + "inviteCode": "Invite Code" 1340 1280 }, 1341 1281 "review": { 1342 - "desc": "Please confirm the details of your offline restoration.", 1343 - "carFile": "CAR File", 1344 - "rotationKey": "Rotation Key", 1345 - "warning": "After you click \"Start Migration\", your repository will be imported and your DID will be updated to point to this PDS.", 1346 - "plcWarningTitle": "Point of No Return", 1347 - "plcWarning": "Once you start, your DID document will be updated to point to this PDS. If something goes wrong, you can use your rotation key to recover, but you should complete the migration to avoid a broken identity state." 1282 + "title": "Review Migration", 1283 + "desc": "Please review and confirm your migration details.", 1284 + "currentHandle": "Current Handle", 1285 + "newHandle": "New Handle", 1286 + "sourcePds": "This PDS", 1287 + "targetPds": "Target PDS", 1288 + "confirm": "I confirm I want to migrate my account", 1289 + "startMigration": "Start Migration" 1348 1290 }, 1349 1291 "migrating": { 1350 - "title": "Restoring Account", 1351 - "desc": "Please wait while your account is being restored...", 1352 - "creating": "Creating account", 1353 - "importing": "Importing repository", 1354 - "plcSigning": "Signing identity update", 1355 - "activating": "Activating account" 1292 + "title": "Migrating Your Account", 1293 + "desc": "Please wait while we transfer your data..." 1356 1294 }, 1357 - "blobs": { 1358 - "title": "Migrating Blobs", 1359 - "desc": "Attempting to recover images and media from your old PDS...", 1360 - "migrating": "Migrating blobs", 1361 - "failedTitle": "Some blobs could not be migrated", 1362 - "failedDesc": "{count} blobs could not be fetched from your old PDS. This may happen if the server is unreachable or the files were deleted.", 1363 - "sourceUnreachableTitle": "Source PDS Unreachable", 1364 - "sourceUnreachable": "Could not connect to your old PDS to fetch media files. This is common when migrating from a shut-down server. Your posts will work, but some images may be missing." 1295 + "plcToken": { 1296 + "title": "Verify Your Identity", 1297 + "desc": "A verification code has been sent to your email." 1298 + }, 1299 + "finalizing": { 1300 + "title": "Finalizing Migration", 1301 + "desc": "Please wait while we complete the migration...", 1302 + "updatingForwarding": "Updating DID document forwarding..." 1365 1303 }, 1366 1304 "success": { 1367 - "desc": "Your account has been successfully restored to this PDS." 1305 + "title": "Migration Complete!", 1306 + "desc": "Your account has been successfully migrated to your new PDS.", 1307 + "newHandle": "New Handle", 1308 + "newPds": "New PDS", 1309 + "nextSteps": "Next Steps", 1310 + "nextSteps1": "Sign in to your new PDS", 1311 + "nextSteps2": "Update any apps with your new credentials", 1312 + "nextSteps3": "Your followers will automatically see your new location", 1313 + "loggingOut": "Logging you out in {seconds} seconds..." 1368 1314 } 1369 1315 }, 1370 1316 "progress": {
+100 -154
frontend/src/locales/fi.json
··· 17 17 "dashboard": "Hallintapaneeli", 18 18 "backToDashboard": "โ† Hallintapaneeli", 19 19 "copied": "Kopioitu!", 20 - "copyToClipboard": "Kopioi", 21 - 22 - "verifying": "Vahvistetaan...", 23 - "saving": "Tallennetaan...", 24 - "creating": "Luodaan...", 25 - "updating": "Pรคivitetรครคn...", 26 - "sending": "Lรคhetetรครคn...", 27 - "authenticating": "Todennetaan...", 28 - "checking": "Tarkistetaan...", 29 - "redirecting": "Ohjataan...", 30 - 31 - "signIn": "Kirjaudu sisรครคn", 32 - "verify": "Vahvista", 33 - "remove": "Poista", 34 - "revoke": "Peruuta", 35 - "resendCode": "Lรคhetรค koodi uudelleen", 36 - "startOver": "Aloita alusta", 37 - "tryAgain": "Yritรค uudelleen", 38 - 39 - "password": "Salasana", 40 - "email": "Sรคhkรถposti", 41 - "emailAddress": "Sรคhkรถpostiosoite", 42 - "handle": "Kรคsittely", 43 - "did": "DID", 44 - "verificationCode": "Vahvistuskoodi", 45 - "inviteCode": "Kutsukoodi", 46 - "newPassword": "Uusi salasana", 47 - "confirmPassword": "Vahvista salasana", 48 - 49 - "enterSixDigitCode": "Syรถtรค 6-numeroinen koodi", 50 - "passwordHint": "Vรคhintรครคn 8 merkkiรค", 51 - "enterPassword": "Syรถtรค salasanasi", 52 - "emailPlaceholder": "sinรค@esimerkki.com", 53 - 54 - "verified": "Vahvistettu", 55 - "disabled": "Poistettu kรคytรถstรค", 56 - "available": "Saatavilla", 57 - "deactivated": "Deaktivoitu", 58 - "unverified": "Vahvistamaton", 59 - 60 - "backToLogin": "Takaisin kirjautumiseen", 61 - "backToSettings": "Takaisin asetuksiin", 62 - "alreadyHaveAccount": "Onko sinulla jo tili?", 63 - "createAccount": "Luo tili", 64 - 65 - "passwordsMismatch": "Salasanat eivรคt tรคsmรครค", 66 - "passwordTooShort": "Salasanan on oltava vรคhintรครคn 8 merkkiรค" 20 + "copyToClipboard": "Kopioi" 67 21 }, 68 22 "login": { 69 23 "title": "Kirjaudu sisรครคn", ··· 95 49 "codeLabel": "Vahvistuskoodi", 96 50 "codePlaceholder": "Syรถtรค 6-numeroinen koodi", 97 51 "verifyButton": "Vahvista tili", 98 - "resent": "Vahvistuskoodi lรคhetetty uudelleen!" 52 + "verifying": "Vahvistetaan...", 53 + "resendButton": "Lรคhetรค koodi uudelleen", 54 + "resending": "Lรคhetetรครคn uudelleen...", 55 + "resent": "Vahvistuskoodi lรคhetetty uudelleen!", 56 + "backToLogin": "Takaisin kirjautumiseen" 99 57 }, 100 58 "register": { 101 59 "title": "Luo tili", ··· 166 124 "inviteCodePlaceholder": "Syรถtรค kutsukoodisi", 167 125 "inviteCodeRequired": "vaaditaan", 168 126 "createButton": "Luo tili", 127 + "creating": "Luodaan tiliรค...", 169 128 "alreadyHaveAccount": "Onko sinulla jo tili?", 170 129 "signIn": "Kirjaudu sisรครคn", 171 130 "wantPasswordless": "Haluatko salasanattoman turvallisuuden?", ··· 220 179 "navAdminDesc": "Palvelintilastot ja yllรคpitotoiminnot", 221 180 "navDidDocument": "DID-dokumentti", 222 181 "navDidDocumentDesc": "Hallitse DID-dokumenttiasi ulkoisia siirtoja varten", 223 - "navDidDocumentDescActive": "Muokkaa DID-dokumentin asetuksia", 224 - "navBackup": "Lataa varmuuskopio", 225 - "navBackupDesc": "Lataa tietovarastosi CAR-tiedostona", 226 - "downloadingBackup": "Ladataan...", 227 - "backupFailed": "Varmuuskopion lataus epรคonnistui", 228 182 "migrated": "Siirretty", 229 183 "migratedTitle": "Tili siirretty", 230 184 "migratedMessage": "Tilisi on siirretty palvelimelle {pds}. DID-dokumenttisi isรคnnรถidรครคn edelleen tรครคllรค, ja voit pรคivittรครค sen tulevia siirtoja varten.", ··· 254 208 "serviceEndpointDesc": "PDS, joka tรคllรค hetkellรค isรคnnรถi tilitietojasi. Pรคivitรค tรคmรค siirron yhteydessรค.", 255 209 "currentPds": "Nykyinen PDS-URL", 256 210 "save": "Tallenna muutokset", 211 + "saving": "Tallennetaan...", 257 212 "success": "DID-dokumentti pรคivitetty onnistuneesti", 258 213 "saveFailed": "DID-dokumentin tallennus epรคonnistui", 259 214 "loadFailed": "DID-dokumentin lataus epรคonnistui", ··· 291 246 "yourDomain": "Verkkotunnuksesi", 292 247 "yourDomainPlaceholder": "esimerkki.fi", 293 248 "verifyAndUpdate": "Vahvista ja pรคivitรค kรคyttรคjรคnimi", 249 + "verifying": "Vahvistetaan...", 294 250 "newHandle": "Uusi kรคyttรคjรคnimi", 295 251 "newHandlePlaceholder": "kรคyttรคjรคnimesi", 296 252 "changeHandleButton": "Vaihda kรคyttรคjรคnimi", ··· 306 262 "exportData": "Vie tiedot", 307 263 "exportDataDescription": "Lataa koko tietovarastosi CAR-tiedostona (Content Addressable Archive). Tรคmรค sisรคltรครค kaikki julkaisusi, tykkรคyksesi, seuraamisesi ja muut tiedot.", 308 264 "downloadRepo": "Lataa tietovarasto", 309 - "downloadBlobs": "Lataa media", 310 265 "exporting": "Viedรครคn...", 311 - "backups": { 312 - "title": "Varmuuskopiot", 313 - "description": "Tietovarastosi varmuuskopioidaan automaattisesti pรคivittรคin. Voit myรถs luoda manuaalisia varmuuskopioita tai palauttaa aiemmasta varmuuskopiosta.", 314 - "enableAutomatic": "Ota automaattiset varmuuskopiot kรคyttรถรถn", 315 - "enabled": "Automaattiset varmuuskopiot kรคytรถssรค", 316 - "disabled": "Automaattiset varmuuskopiot pois kรคytรถstรค", 317 - "toggleFailed": "Varmuuskopioasetuksen pรคivitys epรคonnistui", 318 - "noBackups": "Varmuuskopioita ei ole vielรค saatavilla.", 319 - "blocks": "lohkoa", 320 - "download": "Lataa", 321 - "delete": "Poista", 322 - "createNow": "Luo varmuuskopio nyt", 323 - "created": "Varmuuskopio luotu onnistuneesti", 324 - "createFailed": "Varmuuskopion luonti epรคonnistui", 325 - "downloadFailed": "Varmuuskopion lataus epรคonnistui", 326 - "deleted": "Varmuuskopio poistettu", 327 - "deleteFailed": "Varmuuskopion poisto epรคonnistui", 328 - "restoreTitle": "Palauta varmuuskopiosta", 329 - "restoreDescription": "Lataa CAR-tiedosto palauttaaksesi tietovarastosi. Tรคmรค korvaa nykyiset tietosi.", 330 - "selectFile": "Valitse CAR-tiedosto", 331 - "selectedFile": "Valittu tiedosto", 332 - "restore": "Palauta", 333 - "restoring": "Palautetaan...", 334 - "restored": "Tietovarasto palautettu onnistuneesti", 335 - "restoreFailed": "Tietovaraston palautus epรคonnistui" 336 - }, 337 266 "deleteAccount": "Poista tili", 338 267 "deleteWarning": "Tรคmรค toiminto on peruuttamaton. Kaikki tietosi poistetaan pysyvรคsti.", 339 268 "requestDeletion": "Pyydรค tilin poistoa", ··· 362 291 "deleteConfirmation": "Oletko tรคysin varma, ettรค haluat poistaa tilisi? Tรคtรค ei voi perua.", 363 292 "deletionFailed": "Tilin poisto epรคonnistui", 364 293 "repoExported": "Tietovarasto viety", 365 - "blobsExported": "Mediatiedostot viety", 366 - "noBlobsToExport": "Ei vietรคviรค mediatiedostoja", 367 - "exportFailed": "Vienti epรคonnistui", 294 + "exportFailed": "Tietovaraston vienti epรคonnistui", 368 295 "confirmDelete": "Oletko tรคysin varma, ettรค haluat poistaa tilisi? Tรคtรค ei voi perua." 369 296 } 370 297 }, ··· 379 306 "noPasswords": "Ei vielรค sovellusten salasanoja", 380 307 "revoke": "Peruuta", 381 308 "revoking": "Peruutetaan...", 309 + "creating": "Luodaan...", 382 310 "revokeConfirm": "Peruuta sovelluksen salasana \"{name}\"? Sovellukset, jotka kรคyttรคvรคt tรคtรค salasanaa, eivรคt enรครค pรครคse tilillesi.", 383 311 "saveWarningTitle": "Tรคrkeรครค: Tallenna tรคmรค sovelluksen salasana!", 384 312 "saveWarningMessage": "Tรคmรค salasana tarvitaan kirjautumiseen sovelluksiin, jotka eivรคt tue pรครคsyavaimia tai OAuthia. Nรคet sen vain kerran.", ··· 426 354 "used": "Kรคyttรคnyt @{handle}", 427 355 "disabled": "Poistettu kรคytรถstรค", 428 356 "usedBy": "Kรคyttรคnyt", 357 + "creating": "Luodaan...", 429 358 "disableConfirm": "Poista tรคmรค kutsukoodi kรคytรถstรค? Sitรค ei voi enรครค kรคyttรครค.", 430 359 "created": "Kutsukoodi luotu", 431 360 "copy": "Kopioi", ··· 553 482 "verifyButton": "Vahvista", 554 483 "verifyCodePlaceholder": "Syรถtรค vahvistuskoodi", 555 484 "submit": "Lรคhetรค", 485 + "saving": "Tallennetaan...", 556 486 "savePreferences": "Tallenna asetukset", 557 487 "preferencesSaved": "Viestintรคasetukset tallennettu", 558 488 "verifiedSuccess": "{channel} vahvistettu", ··· 591 521 "noCollectionsYet": "Ei vielรค kokoelmia. Luo ensimmรคinen tietueesi aloittaaksesi.", 592 522 "loadMore": "Lataa lisรครค", 593 523 "recordJson": "Tietueen JSON", 524 + "saving": "Tallennetaan...", 594 525 "updateRecord": "Pรคivitรค tietue", 595 526 "collectionNsid": "Kokoelma (NSID)", 596 527 "recordKeyOptional": "Tietueavain (valinnainen)", 597 528 "autoGenerated": "Luodaan automaattisesti jos tyhjรค (TID)", 598 529 "autoGeneratedHint": "Jรคtรค tyhjรคksi luodaksesi TID-pohjaisen avaimen automaattisesti", 530 + "creating": "Luodaan...", 599 531 "demoPostText": "Hei PDS:ltรคni! Tรคmรค on ensimmรคinen julkaisuni.", 600 532 "demoDisplayName": "Nรคyttรถnimesi", 601 533 "demoBio": "Lyhyt kuvaus itsestรคsi." ··· 616 548 "primaryLight": "Ensisijainen (vaalea tila)", 617 549 "primaryDark": "Ensisijainen (tumma tila)", 618 550 "configSaved": "Palvelinasetukset tallennettu", 551 + "saving": "Tallennetaan...", 619 552 "saveConfig": "Tallenna asetukset", 620 553 "serverStats": "Palvelintilastot", 621 554 "users": "Kรคyttรคjรคt", ··· 706 639 "title": "Kaksivaiheinen tunnistautuminen", 707 640 "subtitle": "Lisรคvahvistus vaaditaan", 708 641 "usePasskey": "Kรคytรค pรครคsyavainta", 709 - "useTotp": "Kรคytรค todentajasovellusta" 642 + "useTotp": "Kรคytรค todentajasovellusta", 643 + "verifying": "Vahvistetaan..." 710 644 }, 711 645 "twoFactorCode": { 712 646 "title": "Kaksivaiheinen tunnistautuminen", 713 647 "subtitle": "Vahvistuskoodi on lรคhetetty {channel}. Syรถtรค koodi alla jatkaaksesi.", 714 648 "codeLabel": "Vahvistuskoodi", 715 649 "codePlaceholder": "Syรถtรค 6-numeroinen koodi", 650 + "verify": "Vahvista", 651 + "verifying": "Vahvistetaan...", 716 652 "errors": { 717 653 "missingRequestUri": "Puuttuva request_uri-parametri", 718 654 "verificationFailed": "Vahvistus epรคonnistui", ··· 724 660 "title": "Syรถtรค todentajakoodi", 725 661 "subtitle": "Syรถtรค 6-numeroinen koodi todentajasovelluksestasi", 726 662 "codePlaceholder": "Syรถtรค 6-numeroinen koodi", 663 + "verify": "Vahvista", 664 + "verifying": "Vahvistetaan...", 727 665 "useBackupCode": "Kรคytรค varakoodia sen sijaan", 728 666 "backupCodePlaceholder": "Syรถtรค varakoodi", 729 667 "trustDevice": "Luota tรคhรคn laitteeseen 30 pรคivรครค", ··· 753 691 "codeLabel": "Vahvistuskoodi", 754 692 "codeHelp": "Kopioi koko koodi viestistรคsi, mukaan lukien vรคliviivat", 755 693 "verifyButton": "Vahvista tili", 694 + "verify": "Vahvista", 695 + "verifying": "Vahvistetaan...", 756 696 "pleaseWait": "Odota...", 697 + "sending": "Lรคhetetรครคn...", 698 + "resendCode": "Lรคhetรค koodi uudelleen", 699 + "resending": "Lรคhetetรครคn uudelleen...", 757 700 "codeResent": "Vahvistuskoodi lรคhetetty uudelleen!", 758 701 "codeResentDetail": "Vahvistuskoodi lรคhetetty! Tarkista saapuneet-kansiosi.", 759 702 "verified": "Vahvistettu!", ··· 763 706 "identifierLabel": "Sรคhkรถposti tai tunniste", 764 707 "identifierPlaceholder": "sinรค@esimerkki.fi", 765 708 "identifierHelp": "Sรคhkรถpostiosoite tai tunniste, johon koodi lรคhetettiin", 709 + "backToLogin": "Takaisin kirjautumiseen", 766 710 "verifyingAccount": "Vahvistetaan tiliรค: @{handle}", 767 711 "startOver": "Aloita alusta toisella tilillรค", 768 712 "noPending": "Odottavaa vahvistusta ei lรถytynyt.", 769 713 "noPendingInfo": "Jos loit tilin รคskettรคin ja sinun on vahvistettava se, sinun on ehkรค luotava uusi tili. Jos olet jo vahvistanut tilisi, voit kirjautua sisรครคn.", 770 714 "createAccount": "Luo tili", 771 715 "signIn": "Kirjaudu sisรครคn", 716 + "backToSettings": "Takaisin asetuksiin", 772 717 "emailUpdateCodeHelp": "Koodi lรคhetettiin nykyiseen sรคhkรถpostiosoitteeseesi", 773 718 "emailUpdateFailed": "Sรคhkรถpostiosoitteen pรคivitys epรคonnistui", 774 719 "emailUpdateRequiresAuth": "Sinun on kirjauduttava sisรครคn pรคivittรครคksesi sรคhkรถpostiosoitteesi.", ··· 801 746 "resetButton": "Palauta salasana", 802 747 "resetting": "Palautetaan...", 803 748 "success": "Salasana palautettu!", 749 + "backToLogin": "Takaisin kirjautumiseen", 804 750 "requestNewCode": "Pyydรค uusi koodi", 805 751 "passwordsMismatch": "Salasanat eivรคt tรคsmรครค", 806 752 "passwordLength": "Salasanan on oltava vรคhintรครคn 8 merkkiรค" ··· 844 790 "howItWorks": "Miten se toimii", 845 791 "howItWorksDetail": "Lรคhetรคmme suojatun linkin rekisterรถityyn ilmoituskanavaasi. Klikkaa linkkiรค asettaaksesi vรคliaikaisen salasanan. Sitten voit kirjautua sisรครคn ja lisรคtรค uuden pรครคsyavaimen.", 846 792 "sendRecoveryLink": "Lรคhetรค palautuslinkki", 847 - "sending": "Lรคhetetรครคn..." 793 + "sending": "Lรคhetetรครคn...", 794 + "backToLogin": "Takaisin kirjautumiseen" 848 795 }, 849 796 "registerPasskey": { 850 797 "title": "Luo pรครคsyavaintili", ··· 865 812 "externalDid": "Sinun did:web", 866 813 "externalDidPlaceholder": "did:web:verkkotunnuksesi.fi", 867 814 "createButton": "Luo tili", 815 + "creating": "Luodaan...", 868 816 "alreadyHaveAccount": "Onko sinulla jo tili?", 869 817 "signIn": "Kirjaudu sisรครคn", 870 818 "wantPassword": "Haluatko kรคyttรครค salasanaa?", ··· 963 911 "useTotp": "Kรคytรค todentajaa", 964 912 "passwordPlaceholder": "Syรถtรค salasanasi", 965 913 "totpPlaceholder": "Syรถtรค 6-numeroinen koodi", 914 + "verify": "Vahvista", 915 + "verifying": "Vahvistetaan...", 966 916 "authenticating": "Todennetaan...", 967 917 "passkeyPrompt": "Klikkaa alla olevaa painiketta todentaaksesi pรครคsyavaimellasi.", 968 918 "cancel": "Peruuta" ··· 1017 967 "handle": "Kรคyttรคjรคnimi", 1018 968 "emailOptional": "Sรคhkรถposti (valinnainen)", 1019 969 "yourAccessLevel": "Kรคyttรถoikeustasosi", 970 + "creating": "Luodaan...", 1020 971 "createAccount": "Luo tili", 1021 972 "createDelegatedAccountButton": "+ Luo delegoitu tili", 1022 973 "accountCreated": "Delegoitu tili luotu: {handle}", ··· 1108 1059 "navDesc": "Siirrรค tilisi toiseen tai toisesta PDS:stรค", 1109 1060 "migrateHere": "Siirrรค tรคnne", 1110 1061 "migrateHereDesc": "Siirrรค olemassa oleva AT Protocol -tilisi tรคhรคn PDS:รครคn toiselta palvelimelta.", 1062 + "migrateAway": "Siirrรค pois", 1063 + "migrateAwayDesc": "Siirrรค tilisi tรคstรค PDS:stรค toiselle palvelimelle.", 1064 + "loginRequired": "Kirjautuminen vaaditaan", 1111 1065 "bringDid": "Tuo DID ja identiteettisi", 1112 1066 "transferData": "Siirrรค kaikki tietosi", 1113 1067 "keepFollowers": "Sรคilytรค seuraajasi", 1068 + "exportRepo": "Vie tietovarastosi", 1069 + "transferToPds": "Siirrรค uuteen PDS:รครคn", 1070 + "updateIdentity": "Pรคivitรค identiteettisi", 1114 1071 "whatIsMigration": "Mikรค on tilin siirto?", 1115 1072 "whatIsMigrationDesc": "Tilin siirto mahdollistaa AT Protocol -identiteettisi siirtรคmisen henkilรถkohtaisten datapalvelimien (PDS) vรคlillรค. DID (hajautettu tunniste) pysyy samana, joten seuraajasi ja sosiaaliset yhteytesi sรคilyvรคt.", 1116 1073 "beforeMigrate": "Ennen siirtoa", ··· 1120 1077 "beforeMigrate4": "Vanhalle PDS:llesi ilmoitetaan tilisi deaktivoinnista", 1121 1078 "importantWarning": "Tilin siirto on merkittรคvรค toimenpide. Varmista, ettรค luotat kohde-PDS:รครคn ja ymmรคrrรคt, ettรค tietosi siirretรครคn. Jos jokin menee pieleen, palautus voi vaatia manuaalista toimenpidettรค.", 1122 1079 "learnMore": "Lue lisรครค siirron riskeistรค", 1123 - "offlineRestore": "Offline-palautus", 1124 - "offlineRestoreDesc": "Palauta varmuuskopiosta, kun vanha PDS ei ole kรคytettรคvissรค.", 1125 - "offlineFeature1": "Kรคytรค CAR-tiedoston varmuuskopiota", 1126 - "offlineFeature2": "Todista omistajuus rotaatioavaimella", 1127 - "offlineFeature3": "Palautus suljetuille palvelimille", 1080 + "comingSoon": "Tulossa pian", 1128 1081 "oauthCompleting": "Viimeistellรครคn todennusta...", 1129 1082 "oauthFailed": "Todennus epรคonnistui", 1130 1083 "tryAgain": "Yritรค uudelleen", ··· 1133 1086 "incomplete": "Sinulla on keskenerรคinen siirto:", 1134 1087 "direction": "Suunta", 1135 1088 "migratingHere": "Siirretรครคn tรคnne", 1089 + "migratingAway": "Siirretรครคn pois", 1136 1090 "from": "Mistรค", 1137 1091 "to": "Minne", 1138 1092 "progress": "Edistyminen", ··· 1275 1229 "error": { 1276 1230 "title": "Siirtovirhe", 1277 1231 "desc": "Siirron aikana tapahtui virhe.", 1278 - "startOver": "Aloita alusta", 1279 - "unknown": "Tuntematon virhe tapahtui." 1232 + "startOver": "Aloita alusta" 1280 1233 }, 1281 1234 "common": { 1282 1235 "back": "Takaisin", ··· 1294 1247 "warning3": "Vanha tilisi deaktivoidaan siirron jรคlkeen" 1295 1248 } 1296 1249 }, 1297 - "offline": { 1250 + "outbound": { 1298 1251 "welcome": { 1299 - "title": "Palauta varmuuskopiosta", 1300 - "desc": "Palauta tilisi CAR-tiedoston varmuuskopiolla ja rotaatioavaimella. Kรคytรค tรคtรค, kun edellinen PDS ei ole kรคytettรคvissรค.", 1301 - "warningTitle": "Milloin kรคyttรครค tรคtรค menetelmรครค", 1302 - "warningDesc": "Tรคmรค offline-palautus on katastrofipalautukseen, kun vanha PDS on suljettu, tavoittamattomissa tai sinut on lukittu ulos. Jos vanha PDS on edelleen kรคytettรคvissรค, kรคytรค normaalia siirtoa.", 1303 - "requirementsTitle": "Tarvitset", 1304 - "requirement1": "CAR-tiedoston varmuuskopion tietovarastostasi", 1305 - "requirement2": "Rotaatioavaimesi (DID:n yksityinen avain)", 1306 - "requirement3": "DID:si (did:plc:xxx)", 1307 - "understand": "Ymmรคrrรคn ja haluan jatkaa" 1308 - }, 1309 - "provideDid": { 1310 - "title": "Syรถtรค DID:si", 1311 - "desc": "Syรถtรค palautettavan tilin DID.", 1312 - "label": "DID:si", 1313 - "hint": "Hajautettu tunnistesi (esim. did:plc:abc123)" 1314 - }, 1315 - "uploadCar": { 1316 - "title": "Lataa CAR-tiedosto", 1317 - "desc": "Lataa tietovaraston varmuuskopiotiedostosi.", 1318 - "label": "CAR-tiedosto", 1319 - "hint": "Valitse .car-tiedosto varmuuskopiostasi", 1320 - "reuploadWarningTitle": "CAR-tiedosto vaaditaan", 1321 - "reuploadWarning": "Istuntosi palautettiin, mutta sinun tรคytyy ladata CAR-tiedostosi uudelleen. Turvallisuussyistรค tiedostosisรคltรถรค ei tallenneta istuntojen vรคlillรค." 1252 + "title": "Siirrรค pois tรคstรค PDS:stรค", 1253 + "desc": "Siirrรค tilisi toiseen henkilรถkohtaiseen datapalvelimeen.", 1254 + "warning": "Siirron jรคlkeen tilisi tรครคllรค deaktivoidaan.", 1255 + "didWebNotice": "did:web-siirtoilmoitus", 1256 + "didWebNoticeDesc": "Tilisi kรคyttรครค did:web-tunnistetta ({did}). Siirron jรคlkeen tรคmรค PDS jatkaa DID-dokumenttisi tarjoamista osoittaen uuteen PDS:รครคn. Identiteettisi toimii niin kauan kuin tรคmรค palvelin on pรครคllรค.", 1257 + "understand": "Ymmรคrrรคn riskit ja haluan jatkaa" 1322 1258 }, 1323 - "rotationKey": { 1324 - "title": "Anna rotaatioavain", 1325 - "desc": "Anna rotaatioavaimesi todistaaksesi tรคmรคn DID:n omistajuuden.", 1326 - "securityWarningTitle": "Turvallisuusvaroitus", 1327 - "securityWarning1": "Rotaatioavaimesi on erittรคin arkaluonteinen - kohtele sitรค kuten pรครคsalasanaa", 1328 - "securityWarning2": "Syรถtรค se vain luotetuilla laitteilla ja verkoilla", 1329 - "securityWarning3": "Tรคtรค avainta ei tallenneta siirron jรคlkeen", 1330 - "label": "Rotaatioavain", 1331 - "placeholder": "Syรถtรค yksityinen avain (hex, base58 tai JWK)", 1332 - "hint": "Yksityinen avain, joka vastaa yhtรค DID-dokumentin rotaatioavaimista", 1333 - "valid": "Avain on kelvollinen ja vastaa DID:si rotaatioavainta", 1334 - "invalid": "Avain ei vastaa mitรครคn DID-dokumentin rotaatioavainta", 1335 - "validating": "Vahvistetaan avainta...", 1336 - "validate": "Vahvista avain" 1259 + "targetPds": { 1260 + "title": "Valitse kohde-PDS", 1261 + "desc": "Syรถtรค sen PDS:n URL, johon haluat siirtyรค.", 1262 + "url": "PDS URL", 1263 + "urlPlaceholder": "https://pds.example.com", 1264 + "validate": "Vahvista ja jatka", 1265 + "validating": "Vahvistetaan...", 1266 + "connected": "Yhdistetty: {name}", 1267 + "inviteRequired": "Kutsukoodi vaaditaan", 1268 + "privacyPolicy": "Tietosuojakรคytรคntรถ", 1269 + "termsOfService": "Kรคyttรถehdot" 1337 1270 }, 1338 - "chooseHandle": { 1339 - "migratingDid": "Palautetaan DID" 1271 + "newAccount": { 1272 + "title": "Uuden tilin tiedot", 1273 + "desc": "Mรครคritรค tilisi uudessa PDS:ssรค.", 1274 + "handle": "Kรคyttรคjรคtunnus", 1275 + "availableDomains": "Kรคytettรคvissรค olevat verkkotunnukset", 1276 + "email": "Sรคhkรถposti", 1277 + "password": "Salasana", 1278 + "confirmPassword": "Vahvista salasana", 1279 + "inviteCode": "Kutsukoodi" 1340 1280 }, 1341 1281 "review": { 1342 - "desc": "Tarkista offline-palautuksen tiedot.", 1343 - "carFile": "CAR-tiedosto", 1344 - "rotationKey": "Rotaatioavain", 1345 - "warning": "Kun aloitat palautuksen, identiteettisi pรคivitetรครคn osoittamaan tรคhรคn PDS:รครคn. Tรคtรค ei voi helposti perua.", 1346 - "plcWarningTitle": "Ei paluuta", 1347 - "plcWarning": "Kun aloitat, DID-dokumenttisi pรคivitetรครคn osoittamaan tรคhรคn PDS:รครคn. Jos jokin menee pieleen, voit kรคyttรครค rotaatioavaintasi palautumiseen, mutta sinun tulisi suorittaa siirto loppuun vรคlttรครคksesi rikkinรคisen identiteettitilan." 1282 + "title": "Tarkista siirto", 1283 + "desc": "Tarkista ja vahvista siirtotietosi.", 1284 + "currentHandle": "Nykyinen kรคyttรคjรคtunnus", 1285 + "newHandle": "Uusi kรคyttรคjรคtunnus", 1286 + "sourcePds": "Tรคmรค PDS", 1287 + "targetPds": "Kohde-PDS", 1288 + "confirm": "Vahvistan haluavani siirtรครค tilini", 1289 + "startMigration": "Aloita siirto" 1348 1290 }, 1349 1291 "migrating": { 1350 - "title": "Palautetaan tiliรค", 1351 - "desc": "Odota, tiliรคsi palautetaan...", 1352 - "creating": "Luodaan tili", 1353 - "importing": "Tuodaan tietovarastoa", 1354 - "plcSigning": "Pรคivitetรครคn identiteettiรค", 1355 - "activating": "Aktivoidaan tili" 1292 + "title": "Siirretรครคn tiliรคsi", 1293 + "desc": "Odota, kun siirrรคmme tietojasi..." 1356 1294 }, 1357 - "success": { 1358 - "desc": "Tilisi on palautettu onnistuneesti tรคhรคn PDS:รครคn." 1295 + "plcToken": { 1296 + "title": "Vahvista henkilรถllisyytesi", 1297 + "desc": "Vahvistuskoodi on lรคhetetty sรคhkรถpostiisi." 1359 1298 }, 1360 - "blobs": { 1361 - "title": "Siirretรครคn blob-tiedostoja", 1362 - "desc": "Yritetรครคn palauttaa kuvia ja mediaa vanhasta PDS:stรคsi...", 1363 - "migrating": "Siirretรครคn blob-tiedostoja", 1364 - "failedTitle": "Joitain blob-tiedostoja ei voitu siirtรครค", 1365 - "failedDesc": "{count} blob-tiedostoa ei voitu hakea vanhasta PDS:stรคsi. Tรคmรค voi tapahtua, jos palvelin ei ole tavoitettavissa tai tiedostot on poistettu.", 1366 - "sourceUnreachableTitle": "Lรคhde-PDS ei tavoitettavissa", 1367 - "sourceUnreachable": "Ei voitu yhdistรครค vanhaan PDS:รครคsi mediatiedostojen hakemiseksi. Tรคmรค on yleistรค siirrettรคessรค suljetulta palvelimelta. Julkaisusi toimivat, mutta joitain kuvia saattaa puuttua." 1299 + "finalizing": { 1300 + "title": "Viimeistellรครคn siirtoa", 1301 + "desc": "Odota, kun viimeistelemme siirtoa...", 1302 + "updatingForwarding": "Pรคivitetรครคn DID-dokumentin uudelleenohjausta..." 1303 + }, 1304 + "success": { 1305 + "title": "Siirto valmis!", 1306 + "desc": "Tilisi on siirretty onnistuneesti uuteen PDS:รครคsi.", 1307 + "newHandle": "Uusi kรคyttรคjรคtunnus", 1308 + "newPds": "Uusi PDS", 1309 + "nextSteps": "Seuraavat vaiheet", 1310 + "nextSteps1": "Kirjaudu uuteen PDS:รครคsi", 1311 + "nextSteps2": "Pรคivitรค sovellukset uusilla tunnuksillasi", 1312 + "nextSteps3": "Seuraajasi nรคkevรคt automaattisesti uuden sijaintisi", 1313 + "loggingOut": "Kirjaudutaan ulos {seconds} sekunnin kuluttua..." 1368 1314 } 1369 1315 }, 1370 1316 "progress": {
+100 -147
frontend/src/locales/ja.json
··· 17 17 "dashboard": "ใƒ€ใƒƒใ‚ทใƒฅใƒœใƒผใƒ‰", 18 18 "backToDashboard": "โ† ใƒ€ใƒƒใ‚ทใƒฅใƒœใƒผใƒ‰", 19 19 "copied": "ใ‚ณใƒ”ใƒผใ—ใพใ—ใŸ๏ผ", 20 - "copyToClipboard": "ใ‚ฏใƒชใƒƒใƒ—ใƒœใƒผใƒ‰ใซใ‚ณใƒ”ใƒผ", 21 - "verifying": "็ขบ่ชไธญ...", 22 - "saving": "ไฟๅญ˜ไธญ...", 23 - "creating": "ไฝœๆˆไธญ...", 24 - "updating": "ๆ›ดๆ–ฐไธญ...", 25 - "sending": "้€ไฟกไธญ...", 26 - "authenticating": "่ช่จผไธญ...", 27 - "checking": "็ขบ่ชไธญ...", 28 - "redirecting": "ใƒชใƒ€ใ‚คใƒฌใ‚ฏใƒˆไธญ...", 29 - "signIn": "ใ‚ตใ‚คใƒณใ‚คใƒณ", 30 - "verify": "็ขบ่ช", 31 - "remove": "ๅ‰Š้™ค", 32 - "revoke": "ๅ–ใ‚Šๆถˆใ—", 33 - "resendCode": "ใ‚ณใƒผใƒ‰ใ‚’ๅ†้€ไฟก", 34 - "startOver": "ๆœ€ๅˆใ‹ใ‚‰ใ‚„ใ‚Š็›ดใ™", 35 - "tryAgain": "ๅ†่ฉฆ่กŒ", 36 - "password": "ใƒ‘ใ‚นใƒฏใƒผใƒ‰", 37 - "email": "ใƒกใƒผใƒซ", 38 - "emailAddress": "ใƒกใƒผใƒซใ‚ขใƒ‰ใƒฌใ‚น", 39 - "handle": "ใƒใƒณใƒ‰ใƒซ", 40 - "did": "DID", 41 - "verificationCode": "็ขบ่ชใ‚ณใƒผใƒ‰", 42 - "inviteCode": "ๆ‹›ๅพ…ใ‚ณใƒผใƒ‰", 43 - "newPassword": "ๆ–ฐใ—ใ„ใƒ‘ใ‚นใƒฏใƒผใƒ‰", 44 - "confirmPassword": "ใƒ‘ใ‚นใƒฏใƒผใƒ‰ใ‚’็ขบ่ช", 45 - "enterSixDigitCode": "6ๆกใฎใ‚ณใƒผใƒ‰ใ‚’ๅ…ฅๅŠ›", 46 - "passwordHint": "8ๆ–‡ๅญ—ไปฅไธŠ", 47 - "enterPassword": "ใƒ‘ใ‚นใƒฏใƒผใƒ‰ใ‚’ๅ…ฅๅŠ›", 48 - "emailPlaceholder": "you@example.com", 49 - "verified": "็ขบ่ชๆธˆใฟ", 50 - "disabled": "็„กๅŠน", 51 - "available": "ๅˆฉ็”จๅฏ่ƒฝ", 52 - "deactivated": "้žใ‚ขใ‚ฏใƒ†ใ‚ฃใƒ–", 53 - "unverified": "ๆœช็ขบ่ช", 54 - "backToLogin": "ใƒญใ‚ฐใ‚คใƒณใซๆˆปใ‚‹", 55 - "backToSettings": "่จญๅฎšใซๆˆปใ‚‹", 56 - "alreadyHaveAccount": "ใ™ใงใซใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’ใŠๆŒใกใงใ™ใ‹๏ผŸ", 57 - "createAccount": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’ไฝœๆˆ", 58 - "passwordsMismatch": "ใƒ‘ใ‚นใƒฏใƒผใƒ‰ใŒไธ€่‡ดใ—ใพใ›ใ‚“", 59 - "passwordTooShort": "ใƒ‘ใ‚นใƒฏใƒผใƒ‰ใฏ8ๆ–‡ๅญ—ไปฅไธŠๅฟ…่ฆใงใ™" 20 + "copyToClipboard": "ใ‚ฏใƒชใƒƒใƒ—ใƒœใƒผใƒ‰ใซใ‚ณใƒ”ใƒผ" 60 21 }, 61 22 "login": { 62 23 "title": "ใ‚ตใ‚คใƒณใ‚คใƒณ", ··· 88 49 "codeLabel": "็ขบ่ชใ‚ณใƒผใƒ‰", 89 50 "codePlaceholder": "6ๆกใฎใ‚ณใƒผใƒ‰ใ‚’ๅ…ฅๅŠ›", 90 51 "verifyButton": "็ขบ่ชใ™ใ‚‹", 91 - "resent": "็ขบ่ชใ‚ณใƒผใƒ‰ใ‚’ๅ†้€ไฟกใ—ใพใ—ใŸ๏ผ" 52 + "verifying": "็ขบ่ชไธญ...", 53 + "resendButton": "ใ‚ณใƒผใƒ‰ใ‚’ๅ†้€ไฟก", 54 + "resending": "้€ไฟกไธญ...", 55 + "resent": "็ขบ่ชใ‚ณใƒผใƒ‰ใ‚’ๅ†้€ไฟกใ—ใพใ—ใŸ๏ผ", 56 + "backToLogin": "ใƒญใ‚ฐใ‚คใƒณใซๆˆปใ‚‹" 92 57 }, 93 58 "register": { 94 59 "title": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆไฝœๆˆ", ··· 159 124 "inviteCodePlaceholder": "ๆ‹›ๅพ…ใ‚ณใƒผใƒ‰ใ‚’ๅ…ฅๅŠ›", 160 125 "inviteCodeRequired": "ๅฟ…้ ˆ", 161 126 "createButton": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’ไฝœๆˆ", 127 + "creating": "ไฝœๆˆไธญ...", 162 128 "alreadyHaveAccount": "ใ™ใงใซใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’ใŠๆŒใกใงใ™ใ‹๏ผŸ", 163 129 "signIn": "ใ‚ตใ‚คใƒณใ‚คใƒณ", 164 130 "wantPasswordless": "ใƒ‘ใ‚นใƒฏใƒผใƒ‰ใƒฌใ‚นใ‚’ใ”ๅธŒๆœ›ใงใ™ใ‹๏ผŸ", ··· 213 179 "navAdminDesc": "ใ‚ตใƒผใƒใƒผ็ตฑ่จˆใจ็ฎก็†ๆ“ไฝœ", 214 180 "navDidDocument": "DID ใƒ‰ใ‚ญใƒฅใƒกใƒณใƒˆ", 215 181 "navDidDocumentDesc": "DID ใƒ‰ใ‚ญใƒฅใƒกใƒณใƒˆใจใ‚ญใƒผใ‚’็ฎก็†", 216 - "navDidDocumentDescActive": "DID ใƒ‰ใ‚ญใƒฅใƒกใƒณใƒˆ่จญๅฎšใ‚’็ทจ้›†", 217 - "navBackup": "ใƒใƒƒใ‚ฏใ‚ขใƒƒใƒ—ใ‚’ใƒ€ใ‚ฆใƒณใƒญใƒผใƒ‰", 218 - "navBackupDesc": "ใƒชใƒใ‚ธใƒˆใƒชใ‚’ CAR ใƒ•ใ‚กใ‚คใƒซใจใ—ใฆใƒ€ใ‚ฆใƒณใƒญใƒผใƒ‰", 219 - "downloadingBackup": "ใƒ€ใ‚ฆใƒณใƒญใƒผใƒ‰ไธญ...", 220 - "backupFailed": "ใƒใƒƒใ‚ฏใ‚ขใƒƒใƒ—ใฎใƒ€ใ‚ฆใƒณใƒญใƒผใƒ‰ใซๅคฑๆ•—ใ—ใพใ—ใŸ", 221 182 "migrated": "็งป่กŒๆธˆใฟ", 222 183 "migratedTitle": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆ็งป่กŒๆธˆใฟ", 223 184 "migratedMessage": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆใฏ {pds} ใซ็งป่กŒใ•ใ‚Œใพใ—ใŸใ€‚DID ใƒ‰ใ‚ญใƒฅใƒกใƒณใƒˆใฏๅผ•ใ็ถšใใ“ใ“ใงใƒ›ใ‚นใƒˆใ•ใ‚Œใฆใ„ใพใ™ใ€‚", ··· 247 208 "serviceEndpointDesc": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆใƒ‡ใƒผใ‚ฟใ‚’็พๅœจใƒ›ใ‚นใƒˆใ—ใฆใ„ใ‚‹PDSใ€‚็งป่กŒๆ™‚ใซๆ›ดๆ–ฐใ—ใฆใใ ใ•ใ„ใ€‚", 248 209 "currentPds": "็พๅœจใฎPDS URL", 249 210 "save": "ๅค‰ๆ›ดใ‚’ไฟๅญ˜", 211 + "saving": "ไฟๅญ˜ไธญ...", 250 212 "success": "DID ใƒ‰ใ‚ญใƒฅใƒกใƒณใƒˆใ‚’ๆ›ดๆ–ฐใ—ใพใ—ใŸ", 251 213 "saveFailed": "DIDใƒ‰ใ‚ญใƒฅใƒกใƒณใƒˆใฎไฟๅญ˜ใซๅคฑๆ•—ใ—ใพใ—ใŸ", 252 214 "loadFailed": "DIDใƒ‰ใ‚ญใƒฅใƒกใƒณใƒˆใฎ่ชญใฟ่พผใฟใซๅคฑๆ•—ใ—ใพใ—ใŸ", ··· 284 246 "yourDomain": "ใƒ‰ใƒกใ‚คใƒณ", 285 247 "yourDomainPlaceholder": "example.com", 286 248 "verifyAndUpdate": "็ขบ่ชใ—ใฆใƒใƒณใƒ‰ใƒซใ‚’ๆ›ดๆ–ฐ", 249 + "verifying": "็ขบ่ชไธญ...", 287 250 "newHandle": "ๆ–ฐใ—ใ„ใƒใƒณใƒ‰ใƒซ", 288 251 "newHandlePlaceholder": "yourhandle", 289 252 "changeHandleButton": "ใƒใƒณใƒ‰ใƒซใ‚’ๅค‰ๆ›ด", ··· 299 262 "exportData": "ใƒ‡ใƒผใ‚ฟใ‚จใ‚ฏใ‚นใƒใƒผใƒˆ", 300 263 "exportDataDescription": "ใƒชใƒใ‚ธใƒˆใƒชๅ…จไฝ“ใ‚’ CAR๏ผˆContent Addressable Archive๏ผ‰ใƒ•ใ‚กใ‚คใƒซใจใ—ใฆใƒ€ใ‚ฆใƒณใƒญใƒผใƒ‰ใ—ใพใ™ใ€‚ๆŠ•็จฟใ€ใ„ใ„ใญใ€ใƒ•ใ‚ฉใƒญใƒผใชใฉใ™ในใฆใฎใƒ‡ใƒผใ‚ฟใŒๅซใพใ‚Œใพใ™ใ€‚", 301 264 "downloadRepo": "ใƒชใƒใ‚ธใƒˆใƒชใ‚’ใƒ€ใ‚ฆใƒณใƒญใƒผใƒ‰", 302 - "downloadBlobs": "ใƒกใƒ‡ใ‚ฃใ‚ขใ‚’ใƒ€ใ‚ฆใƒณใƒญใƒผใƒ‰", 303 265 "exporting": "ใ‚จใ‚ฏใ‚นใƒใƒผใƒˆไธญ...", 304 - "backups": { 305 - "title": "ใƒใƒƒใ‚ฏใ‚ขใƒƒใƒ—", 306 - "description": "ใƒชใƒใ‚ธใƒˆใƒชใฏๆฏŽๆ—ฅ่‡ชๅ‹•็š„ใซใƒใƒƒใ‚ฏใ‚ขใƒƒใƒ—ใ•ใ‚Œใพใ™ใ€‚ๆ‰‹ๅ‹•ใงใƒใƒƒใ‚ฏใ‚ขใƒƒใƒ—ใ‚’ไฝœๆˆใ—ใŸใ‚Šใ€ไปฅๅ‰ใฎใƒใƒƒใ‚ฏใ‚ขใƒƒใƒ—ใ‹ใ‚‰ๅพฉๅ…ƒใ™ใ‚‹ใ“ใจใ‚‚ใงใใพใ™ใ€‚", 307 - "enableAutomatic": "่‡ชๅ‹•ใƒใƒƒใ‚ฏใ‚ขใƒƒใƒ—ใ‚’ๆœ‰ๅŠนใซใ™ใ‚‹", 308 - "enabled": "่‡ชๅ‹•ใƒใƒƒใ‚ฏใ‚ขใƒƒใƒ—ใŒๆœ‰ๅŠนใงใ™", 309 - "disabled": "่‡ชๅ‹•ใƒใƒƒใ‚ฏใ‚ขใƒƒใƒ—ใŒ็„กๅŠนใงใ™", 310 - "toggleFailed": "ใƒใƒƒใ‚ฏใ‚ขใƒƒใƒ—่จญๅฎšใฎๆ›ดๆ–ฐใซๅคฑๆ•—ใ—ใพใ—ใŸ", 311 - "noBackups": "ใƒใƒƒใ‚ฏใ‚ขใƒƒใƒ—ใฏใพใ ใ‚ใ‚Šใพใ›ใ‚“ใ€‚", 312 - "blocks": "ใƒ–ใƒญใƒƒใ‚ฏ", 313 - "download": "ใƒ€ใ‚ฆใƒณใƒญใƒผใƒ‰", 314 - "delete": "ๅ‰Š้™ค", 315 - "createNow": "ไปŠใ™ใใƒใƒƒใ‚ฏใ‚ขใƒƒใƒ—ใ‚’ไฝœๆˆ", 316 - "created": "ใƒใƒƒใ‚ฏใ‚ขใƒƒใƒ—ใŒๆญฃๅธธใซไฝœๆˆใ•ใ‚Œใพใ—ใŸ", 317 - "createFailed": "ใƒใƒƒใ‚ฏใ‚ขใƒƒใƒ—ใฎไฝœๆˆใซๅคฑๆ•—ใ—ใพใ—ใŸ", 318 - "downloadFailed": "ใƒใƒƒใ‚ฏใ‚ขใƒƒใƒ—ใฎใƒ€ใ‚ฆใƒณใƒญใƒผใƒ‰ใซๅคฑๆ•—ใ—ใพใ—ใŸ", 319 - "deleted": "ใƒใƒƒใ‚ฏใ‚ขใƒƒใƒ—ใŒๅ‰Š้™คใ•ใ‚Œใพใ—ใŸ", 320 - "deleteFailed": "ใƒใƒƒใ‚ฏใ‚ขใƒƒใƒ—ใฎๅ‰Š้™คใซๅคฑๆ•—ใ—ใพใ—ใŸ", 321 - "restoreTitle": "ใƒใƒƒใ‚ฏใ‚ขใƒƒใƒ—ใ‹ใ‚‰ๅพฉๅ…ƒ", 322 - "restoreDescription": "CARใƒ•ใ‚กใ‚คใƒซใ‚’ใ‚ขใƒƒใƒ—ใƒญใƒผใƒ‰ใ—ใฆใƒชใƒใ‚ธใƒˆใƒชใ‚’ๅพฉๅ…ƒใ—ใพใ™ใ€‚็พๅœจใฎใƒ‡ใƒผใ‚ฟใฏไธŠๆ›ธใใ•ใ‚Œใพใ™ใ€‚", 323 - "selectFile": "CARใƒ•ใ‚กใ‚คใƒซใ‚’้ธๆŠž", 324 - "selectedFile": "้ธๆŠžใ•ใ‚ŒใŸใƒ•ใ‚กใ‚คใƒซ", 325 - "restore": "ๅพฉๅ…ƒ", 326 - "restoring": "ๅพฉๅ…ƒไธญ...", 327 - "restored": "ใƒชใƒใ‚ธใƒˆใƒชใŒๆญฃๅธธใซๅพฉๅ…ƒใ•ใ‚Œใพใ—ใŸ", 328 - "restoreFailed": "ใƒชใƒใ‚ธใƒˆใƒชใฎๅพฉๅ…ƒใซๅคฑๆ•—ใ—ใพใ—ใŸ" 329 - }, 330 266 "deleteAccount": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆๅ‰Š้™ค", 331 267 "deleteWarning": "ใ“ใฎๆ“ไฝœใฏๅ–ใ‚Šๆถˆใ›ใพใ›ใ‚“ใ€‚ใ™ในใฆใฎใƒ‡ใƒผใ‚ฟใŒๅฎŒๅ…จใซๅ‰Š้™คใ•ใ‚Œใพใ™ใ€‚", 332 268 "requestDeletion": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆๅ‰Š้™คใ‚’ใƒชใ‚ฏใ‚จใ‚นใƒˆ", ··· 355 291 "deleteConfirmation": "ๆœฌๅฝ“ใซใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’ๅ‰Š้™คใ—ใพใ™ใ‹๏ผŸใ“ใฎๆ“ไฝœใฏๅ–ใ‚Šๆถˆใ›ใพใ›ใ‚“ใ€‚", 356 292 "deletionFailed": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆใฎๅ‰Š้™คใซๅคฑๆ•—ใ—ใพใ—ใŸ", 357 293 "repoExported": "ใƒชใƒใ‚ธใƒˆใƒชใ‚’ใ‚จใ‚ฏใ‚นใƒใƒผใƒˆใ—ใพใ—ใŸ", 358 - "blobsExported": "ใƒกใƒ‡ใ‚ฃใ‚ขใƒ•ใ‚กใ‚คใƒซใ‚’ใ‚จใ‚ฏใ‚นใƒใƒผใƒˆใ—ใพใ—ใŸ", 359 - "noBlobsToExport": "ใ‚จใ‚ฏใ‚นใƒใƒผใƒˆใ™ใ‚‹ใƒกใƒ‡ใ‚ฃใ‚ขใƒ•ใ‚กใ‚คใƒซใŒใ‚ใ‚Šใพใ›ใ‚“", 360 - "exportFailed": "ใ‚จใ‚ฏใ‚นใƒใƒผใƒˆใซๅคฑๆ•—ใ—ใพใ—ใŸ", 294 + "exportFailed": "ใƒชใƒใ‚ธใƒˆใƒชใฎใ‚จใ‚ฏใ‚นใƒใƒผใƒˆใซๅคฑๆ•—ใ—ใพใ—ใŸ", 361 295 "confirmDelete": "ๆœฌๅฝ“ใซใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’ๅ‰Š้™คใ—ใพใ™ใ‹๏ผŸใ“ใฎๆ“ไฝœใฏๅ–ใ‚Šๆถˆใ›ใพใ›ใ‚“ใ€‚" 362 296 } 363 297 }, ··· 372 306 "noPasswords": "ใ‚ขใƒ—ใƒชใƒ‘ใ‚นใƒฏใƒผใƒ‰ใฏใพใ ใ‚ใ‚Šใพใ›ใ‚“", 373 307 "revoke": "ๅ–ใ‚Šๆถˆใ™", 374 308 "revoking": "ๅ–ใ‚Šๆถˆใ—ไธญ...", 309 + "creating": "ไฝœๆˆไธญ...", 375 310 "revokeConfirm": "ใ‚ขใƒ—ใƒชใƒ‘ใ‚นใƒฏใƒผใƒ‰ใ€Œ{name}ใ€ใ‚’ๅ–ใ‚Šๆถˆใ—ใพใ™ใ‹๏ผŸใ“ใฎใƒ‘ใ‚นใƒฏใƒผใƒ‰ใ‚’ไฝฟ็”จใ—ใฆใ„ใ‚‹ใ‚ขใƒ—ใƒชใฏใ‚ขใ‚ซใ‚ฆใƒณใƒˆใซใ‚ขใ‚ฏใ‚ปใ‚นใงใใชใใชใ‚Šใพใ™ใ€‚", 376 311 "saveWarningTitle": "้‡่ฆ: ใ“ใฎใ‚ขใƒ—ใƒชใƒ‘ใ‚นใƒฏใƒผใƒ‰ใ‚’ไฟๅญ˜ใ—ใฆใใ ใ•ใ„๏ผ", 377 312 "saveWarningMessage": "ใ“ใฎใƒ‘ใ‚นใƒฏใƒผใƒ‰ใฏใƒ‘ใ‚นใ‚ญใƒผใ‚„ OAuth ใ‚’ใ‚ตใƒใƒผใƒˆใ—ใฆใ„ใชใ„ใ‚ขใƒ—ใƒชใซใ‚ตใ‚คใƒณใ‚คใƒณใ™ใ‚‹ใŸใ‚ใซๅฟ…่ฆใงใ™ใ€‚ไธ€ๅบฆใ—ใ‹่กจ็คบใ•ใ‚Œใพใ›ใ‚“ใ€‚", ··· 419 354 "used": "@{handle} ใŒไฝฟ็”จๆธˆใฟ", 420 355 "disabled": "็„กๅŠน", 421 356 "usedBy": "ไฝฟ็”จ่€…", 357 + "creating": "ไฝœๆˆไธญ...", 422 358 "disableConfirm": "ใ“ใฎๆ‹›ๅพ…ใ‚ณใƒผใƒ‰ใ‚’็„กๅŠนใซใ—ใพใ™ใ‹๏ผŸไฝฟ็”จใงใใชใใชใ‚Šใพใ™ใ€‚", 423 359 "created": "ๆ‹›ๅพ…ใ‚ณใƒผใƒ‰ใ‚’ไฝœๆˆใ—ใพใ—ใŸ", 424 360 "copy": "ใ‚ณใƒ”ใƒผ", ··· 546 482 "verifyButton": "็ขบ่ช", 547 483 "verifyCodePlaceholder": "็ขบ่ชใ‚ณใƒผใƒ‰ใ‚’ๅ…ฅๅŠ›", 548 484 "submit": "้€ไฟก", 485 + "saving": "ไฟๅญ˜ไธญ...", 549 486 "savePreferences": "่จญๅฎšใ‚’ไฟๅญ˜", 550 487 "preferencesSaved": "้€ฃ็ตก่จญๅฎšใ‚’ไฟๅญ˜ใ—ใพใ—ใŸ", 551 488 "verifiedSuccess": "{channel} ใ‚’็ขบ่ชใ—ใพใ—ใŸ", ··· 584 521 "noCollectionsYet": "ใ‚ณใƒฌใ‚ฏใ‚ทใƒงใƒณใŒใพใ ใ‚ใ‚Šใพใ›ใ‚“ใ€‚ๆœ€ๅˆใฎใƒฌใ‚ณใƒผใƒ‰ใ‚’ไฝœๆˆใ—ใฆ้–‹ๅง‹ใ—ใพใ—ใ‚‡ใ†ใ€‚", 585 522 "loadMore": "ใ•ใ‚‰ใซ่ชญใฟ่พผใ‚€", 586 523 "recordJson": "ใƒฌใ‚ณใƒผใƒ‰ JSON", 524 + "saving": "ไฟๅญ˜ไธญ...", 587 525 "updateRecord": "ใƒฌใ‚ณใƒผใƒ‰ใ‚’ๆ›ดๆ–ฐ", 588 526 "collectionNsid": "ใ‚ณใƒฌใ‚ฏใ‚ทใƒงใƒณ (NSID)", 589 527 "recordKeyOptional": "ใƒฌใ‚ณใƒผใƒ‰ใ‚ญใƒผ๏ผˆไปปๆ„๏ผ‰", 590 528 "autoGenerated": "็ฉบ็™ฝใง่‡ชๅ‹•็”Ÿๆˆ (TID)", 591 529 "autoGeneratedHint": "็ฉบ็™ฝใซใ™ใ‚‹ใจ TID ใƒ™ใƒผใ‚นใฎใ‚ญใƒผใŒ่‡ชๅ‹•็”Ÿๆˆใ•ใ‚Œใพใ™", 530 + "creating": "ไฝœๆˆไธญ...", 592 531 "demoPostText": "ใ“ใ‚“ใซใกใฏใ€็งใฎ PDS ใ‹ใ‚‰ใฎๅˆๆŠ•็จฟใงใ™๏ผ", 593 532 "demoDisplayName": "่กจ็คบๅ", 594 533 "demoBio": "่‡ชๅทฑ็ดนไป‹ใ‚’ๆ›ธใ„ใฆใใ ใ•ใ„ใ€‚" ··· 609 548 "primaryLight": "ใƒ—ใƒฉใ‚คใƒžใƒช๏ผˆใƒฉใ‚คใƒˆใƒขใƒผใƒ‰๏ผ‰", 610 549 "primaryDark": "ใƒ—ใƒฉใ‚คใƒžใƒช๏ผˆใƒ€ใƒผใ‚ฏใƒขใƒผใƒ‰๏ผ‰", 611 550 "configSaved": "ใ‚ตใƒผใƒใƒผ่จญๅฎšใ‚’ไฟๅญ˜ใ—ใพใ—ใŸ", 551 + "saving": "ไฟๅญ˜ไธญ...", 612 552 "saveConfig": "่จญๅฎšใ‚’ไฟๅญ˜", 613 553 "serverStats": "ใ‚ตใƒผใƒใƒผ็ตฑ่จˆ", 614 554 "users": "ใƒฆใƒผใ‚ถใƒผ", ··· 699 639 "title": "ไบŒ่ฆ็ด ่ช่จผ", 700 640 "subtitle": "่ฟฝๅŠ ใฎ็ขบ่ชใŒๅฟ…่ฆใงใ™", 701 641 "usePasskey": "ใƒ‘ใ‚นใ‚ญใƒผใ‚’ไฝฟ็”จ", 702 - "useTotp": "่ช่จผใ‚ขใƒ—ใƒชใ‚’ไฝฟ็”จ" 642 + "useTotp": "่ช่จผใ‚ขใƒ—ใƒชใ‚’ไฝฟ็”จ", 643 + "verifying": "็ขบ่ชไธญ..." 703 644 }, 704 645 "twoFactorCode": { 705 646 "title": "ไบŒ่ฆ็ด ่ช่จผ", 706 647 "subtitle": "{channel} ใซ็ขบ่ชใ‚ณใƒผใƒ‰ใ‚’้€ไฟกใ—ใพใ—ใŸใ€‚ไปฅไธ‹ใซใ‚ณใƒผใƒ‰ใ‚’ๅ…ฅๅŠ›ใ—ใฆ็ถš่กŒใ—ใฆใใ ใ•ใ„ใ€‚", 707 648 "codeLabel": "็ขบ่ชใ‚ณใƒผใƒ‰", 708 649 "codePlaceholder": "6ๆกใฎใ‚ณใƒผใƒ‰ใ‚’ๅ…ฅๅŠ›", 650 + "verify": "็ขบ่ช", 651 + "verifying": "็ขบ่ชไธญ...", 709 652 "errors": { 710 653 "missingRequestUri": "request_uri ใƒ‘ใƒฉใƒกใƒผใ‚ฟใŒใ‚ใ‚Šใพใ›ใ‚“", 711 654 "verificationFailed": "็ขบ่ชใซๅคฑๆ•—ใ—ใพใ—ใŸ", ··· 717 660 "title": "่ช่จผใ‚ณใƒผใƒ‰ใ‚’ๅ…ฅๅŠ›", 718 661 "subtitle": "่ช่จผใ‚ขใƒ—ใƒชใฎ6ๆกใฎใ‚ณใƒผใƒ‰ใ‚’ๅ…ฅๅŠ›", 719 662 "codePlaceholder": "6ๆกใฎใ‚ณใƒผใƒ‰ใ‚’ๅ…ฅๅŠ›", 663 + "verify": "็ขบ่ช", 664 + "verifying": "็ขบ่ชไธญ...", 720 665 "useBackupCode": "ใƒใƒƒใ‚ฏใ‚ขใƒƒใƒ—ใ‚ณใƒผใƒ‰ใ‚’ไฝฟ็”จ", 721 666 "backupCodePlaceholder": "ใƒใƒƒใ‚ฏใ‚ขใƒƒใƒ—ใ‚ณใƒผใƒ‰ใ‚’ๅ…ฅๅŠ›", 722 667 "trustDevice": "ใ“ใฎใƒ‡ใƒใ‚คใ‚นใ‚’30ๆ—ฅ้–“ไฟก้ ผใ™ใ‚‹", ··· 746 691 "codeLabel": "็ขบ่ชใ‚ณใƒผใƒ‰", 747 692 "codeHelp": "ใƒ€ใƒƒใ‚ทใƒฅใ‚’ๅซใ‚€ๅฎŒๅ…จใชใ‚ณใƒผใƒ‰ใ‚’ใƒกใƒƒใ‚ปใƒผใ‚ธใ‹ใ‚‰ใ‚ณใƒ”ใƒผใ—ใฆใใ ใ•ใ„", 748 693 "verifyButton": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’็ขบ่ช", 694 + "verify": "็ขบ่ช", 695 + "verifying": "็ขบ่ชไธญ...", 749 696 "pleaseWait": "ใŠๅพ…ใกใใ ใ•ใ„...", 697 + "sending": "้€ไฟกไธญ...", 698 + "resendCode": "ใ‚ณใƒผใƒ‰ใ‚’ๅ†้€ไฟก", 699 + "resending": "้€ไฟกไธญ...", 750 700 "codeResent": "็ขบ่ชใ‚ณใƒผใƒ‰ใ‚’ๅ†้€ไฟกใ—ใพใ—ใŸ๏ผ", 751 701 "codeResentDetail": "็ขบ่ชใ‚ณใƒผใƒ‰ใ‚’้€ไฟกใ—ใพใ—ใŸ๏ผๅ—ไฟกใƒˆใƒฌใ‚คใ‚’็ขบ่ชใ—ใฆใใ ใ•ใ„ใ€‚", 752 702 "verified": "็ขบ่ชๅฎŒไบ†๏ผ", ··· 756 706 "identifierLabel": "ใƒกใƒผใƒซใพใŸใฏ่ญ˜ๅˆฅๅญ", 757 707 "identifierPlaceholder": "you@example.com", 758 708 "identifierHelp": "ใ‚ณใƒผใƒ‰ใŒ้€ไฟกใ•ใ‚ŒใŸใƒกใƒผใƒซใ‚ขใƒ‰ใƒฌใ‚นใพใŸใฏ่ญ˜ๅˆฅๅญ", 709 + "backToLogin": "ใƒญใ‚ฐใ‚คใƒณใซๆˆปใ‚‹", 759 710 "verifyingAccount": "็ขบ่ชไธญใฎใ‚ขใ‚ซใ‚ฆใƒณใƒˆ: @{handle}", 760 711 "startOver": "ๅˆฅใฎใ‚ขใ‚ซใ‚ฆใƒณใƒˆใงใ‚„ใ‚Š็›ดใ™", 761 712 "noPending": "ไฟ็•™ไธญใฎ็ขบ่ชใŒ่ฆ‹ใคใ‹ใ‚Šใพใ›ใ‚“ใ€‚", 762 713 "noPendingInfo": "ๆœ€่ฟ‘ใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’ไฝœๆˆใ—ใฆ็ขบ่ชใŒๅฟ…่ฆใชๅ ดๅˆใฏใ€ๆ–ฐใ—ใ„ใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’ไฝœๆˆใ™ใ‚‹ๅฟ…่ฆใŒใ‚ใ‚Šใพใ™ใ€‚ใ™ใงใซใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’็ขบ่ชใ—ใŸๅ ดๅˆใฏใ€ใ‚ตใ‚คใƒณใ‚คใƒณใงใใพใ™ใ€‚", 763 714 "createAccount": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’ไฝœๆˆ", 764 715 "signIn": "ใ‚ตใ‚คใƒณใ‚คใƒณ", 716 + "backToSettings": "่จญๅฎšใซๆˆปใ‚‹", 765 717 "emailUpdateCodeHelp": "ใ‚ณใƒผใƒ‰ใฏ็พๅœจใฎใƒกใƒผใƒซใ‚ขใƒ‰ใƒฌใ‚นใซ้€ไฟกใ•ใ‚Œใพใ—ใŸ", 766 718 "emailUpdateFailed": "ใƒกใƒผใƒซใ‚ขใƒ‰ใƒฌใ‚นใฎๆ›ดๆ–ฐใซๅคฑๆ•—ใ—ใพใ—ใŸ", 767 719 "emailUpdateRequiresAuth": "ใƒกใƒผใƒซใ‚ขใƒ‰ใƒฌใ‚นใ‚’ๆ›ดๆ–ฐใ™ใ‚‹ใซใฏใ‚ตใ‚คใƒณใ‚คใƒณใŒๅฟ…่ฆใงใ™ใ€‚", ··· 794 746 "resetButton": "ใƒ‘ใ‚นใƒฏใƒผใƒ‰ใ‚’ใƒชใ‚ปใƒƒใƒˆ", 795 747 "resetting": "ใƒชใ‚ปใƒƒใƒˆไธญ...", 796 748 "success": "ใƒ‘ใ‚นใƒฏใƒผใƒ‰ใ‚’ใƒชใ‚ปใƒƒใƒˆใ—ใพใ—ใŸ๏ผ", 749 + "backToLogin": "ใ‚ตใ‚คใƒณใ‚คใƒณใซๆˆปใ‚‹", 797 750 "requestNewCode": "ๆ–ฐใ—ใ„ใ‚ณใƒผใƒ‰ใ‚’ใƒชใ‚ฏใ‚จใ‚นใƒˆ", 798 751 "passwordsMismatch": "ใƒ‘ใ‚นใƒฏใƒผใƒ‰ใŒไธ€่‡ดใ—ใพใ›ใ‚“", 799 752 "passwordLength": "ใƒ‘ใ‚นใƒฏใƒผใƒ‰ใฏ8ๆ–‡ๅญ—ไปฅไธŠใงใ‚ใ‚‹ๅฟ…่ฆใŒใ‚ใ‚Šใพใ™" ··· 837 790 "howItWorks": "ไป•็ต„ใฟ", 838 791 "howItWorksDetail": "็™ป้Œฒใ•ใ‚ŒใŸ้€š็Ÿฅใƒใƒฃใƒณใƒใƒซใซๅฎ‰ๅ…จใชใƒชใƒณใ‚ฏใ‚’้€ไฟกใ—ใพใ™ใ€‚ใƒชใƒณใ‚ฏใ‚’ใ‚ฏใƒชใƒƒใ‚ฏใ—ใฆไธ€ๆ™‚ใƒ‘ใ‚นใƒฏใƒผใƒ‰ใ‚’่จญๅฎšใ—ใพใ™ใ€‚ใใฎๅพŒใ‚ตใ‚คใƒณใ‚คใƒณใ—ใฆๆ–ฐใ—ใ„ใƒ‘ใ‚นใ‚ญใƒผใ‚’่ฟฝๅŠ ใงใใพใ™ใ€‚", 839 792 "sendRecoveryLink": "ๅพฉๆ—งใƒชใƒณใ‚ฏใ‚’้€ไฟก", 840 - "sending": "้€ไฟกไธญ..." 793 + "sending": "้€ไฟกไธญ...", 794 + "backToLogin": "ใ‚ตใ‚คใƒณใ‚คใƒณใซๆˆปใ‚‹" 841 795 }, 842 796 "registerPasskey": { 843 797 "title": "ใƒ‘ใ‚นใ‚ญใƒผใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’ไฝœๆˆ", ··· 858 812 "externalDid": "ใ‚ใชใŸใฎ did:web", 859 813 "externalDidPlaceholder": "did:web:yourdomain.com", 860 814 "createButton": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’ไฝœๆˆ", 815 + "creating": "ไฝœๆˆไธญ...", 861 816 "alreadyHaveAccount": "ใ™ใงใซใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’ใŠๆŒใกใงใ™ใ‹๏ผŸ", 862 817 "signIn": "ใ‚ตใ‚คใƒณใ‚คใƒณ", 863 818 "wantPassword": "ใƒ‘ใ‚นใƒฏใƒผใƒ‰ใ‚’ไฝฟ็”จใ—ใพใ™ใ‹๏ผŸ", ··· 956 911 "useTotp": "่ช่จผใ‚ขใƒ—ใƒชใ‚’ไฝฟ็”จ", 957 912 "passwordPlaceholder": "ใƒ‘ใ‚นใƒฏใƒผใƒ‰ใ‚’ๅ…ฅๅŠ›", 958 913 "totpPlaceholder": "6ๆกใฎใ‚ณใƒผใƒ‰ใ‚’ๅ…ฅๅŠ›", 914 + "verify": "็ขบ่ช", 915 + "verifying": "็ขบ่ชไธญ...", 959 916 "authenticating": "่ช่จผไธญ...", 960 917 "passkeyPrompt": "ไธ‹ใฎใƒœใ‚ฟใƒณใ‚’ใ‚ฏใƒชใƒƒใ‚ฏใ—ใฆใƒ‘ใ‚นใ‚ญใƒผใง่ช่จผใ—ใฆใใ ใ•ใ„ใ€‚", 961 918 "cancel": "ใ‚ญใƒฃใƒณใ‚ปใƒซ" ··· 1028 985 "createAccount": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’ไฝœๆˆ", 1029 986 "createDelegatedAccount": "ๅง”ไปปใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’ไฝœๆˆ", 1030 987 "createDelegatedAccountButton": "+ ๅง”ไปปใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’ไฝœๆˆ", 988 + "creating": "ไฝœๆˆไธญ...", 1031 989 "emailOptional": "ใƒกใƒผใƒซ๏ผˆไปปๆ„๏ผ‰", 1032 990 "failedToAddController": "ใ‚ณใƒณใƒˆใƒญใƒผใƒฉใƒผใฎ่ฟฝๅŠ ใซๅคฑๆ•—ใ—ใพใ—ใŸ", 1033 991 "failedToCreateAccount": "ๅง”ไปปใ‚ขใ‚ซใ‚ฆใƒณใƒˆใฎไฝœๆˆใซๅคฑๆ•—ใ—ใพใ—ใŸ", ··· 1101 1059 "navDesc": "ๅˆฅใฎPDSใธใ€ใพใŸใฏๅˆฅใฎPDSใ‹ใ‚‰ใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’็งปๅ‹•", 1102 1060 "migrateHere": "ใ“ใ“ใซ็งป่กŒ", 1103 1061 "migrateHereDesc": "ๆ—ขๅญ˜ใฎAT Protocolใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’ใ“ใฎPDSใซ็งปๅ‹•ใ—ใพใ™ใ€‚", 1062 + "migrateAway": "ๅˆฅใฎๅ ดๆ‰€ใซ็งป่กŒ", 1063 + "migrateAwayDesc": "ใ“ใฎPDSใ‹ใ‚‰ๅˆฅใฎใ‚ตใƒผใƒใƒผใซใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’็งปๅ‹•ใ—ใพใ™ใ€‚", 1064 + "loginRequired": "ใƒญใ‚ฐใ‚คใƒณใŒๅฟ…่ฆใงใ™", 1104 1065 "bringDid": "DIDใจใ‚ขใ‚คใƒ‡ใƒณใƒ†ใ‚ฃใƒ†ใ‚ฃใ‚’ๆŒใก่พผใ‚€", 1105 1066 "transferData": "ใ™ในใฆใฎใƒ‡ใƒผใ‚ฟใ‚’่ปข้€", 1106 1067 "keepFollowers": "ใƒ•ใ‚ฉใƒญใƒฏใƒผใ‚’็ถญๆŒ", 1068 + "exportRepo": "ใƒชใƒใ‚ธใƒˆใƒชใ‚’ใ‚จใ‚ฏใ‚นใƒใƒผใƒˆ", 1069 + "transferToPds": "ๆ–ฐใ—ใ„PDSใซ่ปข้€", 1070 + "updateIdentity": "ใ‚ขใ‚คใƒ‡ใƒณใƒ†ใ‚ฃใƒ†ใ‚ฃใ‚’ๆ›ดๆ–ฐ", 1107 1071 "whatIsMigration": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆ็งป่กŒใจใฏ๏ผŸ", 1108 1072 "whatIsMigrationDesc": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆ็งป่กŒใซใ‚ˆใ‚Šใ€AT Protocolใ‚ขใ‚คใƒ‡ใƒณใƒ†ใ‚ฃใƒ†ใ‚ฃใ‚’ใƒ‘ใƒผใ‚ฝใƒŠใƒซใƒ‡ใƒผใ‚ฟใ‚ตใƒผใƒใƒผ๏ผˆPDS๏ผ‰้–“ใง็งปๅ‹•ใงใใพใ™ใ€‚DID๏ผˆๅˆ†ๆ•ฃๅž‹่ญ˜ๅˆฅๅญ๏ผ‰ใฏๅค‰ใ‚ใ‚‰ใชใ„ใŸใ‚ใ€ใƒ•ใ‚ฉใƒญใƒฏใƒผใ‚„ใ‚ฝใƒผใ‚ทใƒฃใƒซใ‚ณใƒใ‚ฏใ‚ทใƒงใƒณใฏ็ถญๆŒใ•ใ‚Œใพใ™ใ€‚", 1109 1073 "beforeMigrate": "็งป่กŒๅ‰ใฎ็ขบ่ชไบ‹้ …", ··· 1113 1077 "beforeMigrate4": "ๅคใ„PDSใซใ‚ขใ‚ซใ‚ฆใƒณใƒˆใฎ็„กๅŠนๅŒ–ใŒ้€š็Ÿฅใ•ใ‚Œใพใ™", 1114 1078 "importantWarning": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆ็งป่กŒใฏ้‡่ฆใชๆ“ไฝœใงใ™ใ€‚็งป่กŒๅ…ˆใฎPDSใ‚’ไฟก้ ผใ—ใ€ใƒ‡ใƒผใ‚ฟใŒ็งปๅ‹•ใ•ใ‚Œใ‚‹ใ“ใจใ‚’็†่งฃใ—ใฆใใ ใ•ใ„ใ€‚ๅ•้กŒใŒ็™บ็”Ÿใ—ใŸๅ ดๅˆใ€ๆ‰‹ๅ‹•ใงใฎๅพฉๆ—งใŒๅฟ…่ฆใซใชใ‚‹ๅฏ่ƒฝๆ€งใŒใ‚ใ‚Šใพใ™ใ€‚", 1115 1079 "learnMore": "็งป่กŒใฎใƒชใ‚นใ‚ฏใซใคใ„ใฆ่ฉณใ—ใ", 1116 - "offlineRestore": "ใ‚ชใƒ•ใƒฉใ‚คใƒณๅพฉๅ…ƒ", 1117 - "offlineRestoreDesc": "ๆ—งPDSใŒๅˆฉ็”จใงใใชใ„ๅ ดๅˆใซใƒใƒƒใ‚ฏใ‚ขใƒƒใƒ—ใ‹ใ‚‰ๅพฉๅ…ƒใ—ใพใ™ใ€‚", 1118 - "offlineFeature1": "CARใƒ•ใ‚กใ‚คใƒซใƒใƒƒใ‚ฏใ‚ขใƒƒใƒ—ใ‚’ไฝฟ็”จ", 1119 - "offlineFeature2": "ใƒญใƒผใƒ†ใƒผใ‚ทใƒงใƒณใ‚ญใƒผใงๆ‰€ๆœ‰ๆจฉใ‚’่จผๆ˜Ž", 1120 - "offlineFeature3": "ใ‚ทใƒฃใƒƒใƒˆใƒ€ใ‚ฆใƒณใ—ใŸใ‚ตใƒผใƒใƒผใฎๅพฉๆ—ง", 1080 + "comingSoon": "่ฟ‘ๆ—ฅๅ…ฌ้–‹", 1121 1081 "oauthCompleting": "่ช่จผใ‚’ๅฎŒไบ†ใ—ใฆใ„ใพใ™...", 1122 1082 "oauthFailed": "่ช่จผใซๅคฑๆ•—ใ—ใพใ—ใŸ", 1123 1083 "tryAgain": "ๅ†่ฉฆ่กŒ", ··· 1126 1086 "incomplete": "ๆœชๅฎŒไบ†ใฎ็งป่กŒใŒใ‚ใ‚Šใพใ™๏ผš", 1127 1087 "direction": "ๆ–นๅ‘", 1128 1088 "migratingHere": "ใ“ใ“ใซ็งป่กŒไธญ", 1089 + "migratingAway": "ๅˆฅใฎๅ ดๆ‰€ใซ็งป่กŒไธญ", 1129 1090 "from": "็งป่กŒๅ…ƒ", 1130 1091 "to": "็งป่กŒๅ…ˆ", 1131 1092 "progress": "้€ฒ่กŒ็Šถๆณ", ··· 1268 1229 "error": { 1269 1230 "title": "็งป่กŒใ‚จใƒฉใƒผ", 1270 1231 "desc": "็งป่กŒไธญใซใ‚จใƒฉใƒผใŒ็™บ็”Ÿใ—ใพใ—ใŸใ€‚", 1271 - "startOver": "ๆœ€ๅˆใ‹ใ‚‰ใ‚„ใ‚Š็›ดใ™", 1272 - "unknown": "ไธๆ˜Žใชใ‚จใƒฉใƒผใŒ็™บ็”Ÿใ—ใพใ—ใŸใ€‚" 1232 + "startOver": "ๆœ€ๅˆใ‹ใ‚‰ใ‚„ใ‚Š็›ดใ™" 1273 1233 }, 1274 1234 "common": { 1275 1235 "back": "ๆˆปใ‚‹", ··· 1287 1247 "warning3": "็งป่กŒๅพŒใ€ๅคใ„ใ‚ขใ‚ซใ‚ฆใƒณใƒˆใฏ็„กๅŠนๅŒ–ใ•ใ‚Œใพใ™" 1288 1248 } 1289 1249 }, 1290 - "offline": { 1250 + "outbound": { 1291 1251 "welcome": { 1292 - "title": "ใƒใƒƒใ‚ฏใ‚ขใƒƒใƒ—ใ‹ใ‚‰ๅพฉๅ…ƒ", 1293 - "desc": "CARใƒ•ใ‚กใ‚คใƒซใƒใƒƒใ‚ฏใ‚ขใƒƒใƒ—ใจใƒญใƒผใƒ†ใƒผใ‚ทใƒงใƒณใ‚ญใƒผใ‚’ไฝฟ็”จใ—ใฆใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’ๅพฉๅ…ƒใ—ใพใ™ใ€‚ไปฅๅ‰ใฎPDSใŒๅˆฉ็”จใงใใชใ„ๅ ดๅˆใซไฝฟ็”จใ—ใฆใใ ใ•ใ„ใ€‚", 1294 - "warningTitle": "ใ“ใฎๆ–นๆณ•ใ‚’ไฝฟ็”จใ™ใ‚‹ใ‚ฟใ‚คใƒŸใƒณใ‚ฐ", 1295 - "warningDesc": "ใ“ใฎใ‚ชใƒ•ใƒฉใ‚คใƒณๅพฉๅ…ƒใฏใ€ๅคใ„PDSใŒใ‚ทใƒฃใƒƒใƒˆใƒ€ใ‚ฆใƒณใ—ใŸใ€ใ‚ขใ‚ฏใ‚ปใ‚นใงใใชใ„ใ€ใพใŸใฏใƒญใƒƒใ‚ฏใ‚ขใ‚ฆใƒˆใ•ใ‚ŒใŸๅ ดๅˆใฎ็ฝๅฎณๅพฉๆ—ง็”จใงใ™ใ€‚ๅคใ„PDSใŒใพใ ๅˆฉ็”จๅฏ่ƒฝใชๅ ดๅˆใฏใ€ไปฃใ‚ใ‚Šใซๆจ™ๆบ–ใฎ็งป่กŒใ‚’ไฝฟ็”จใ—ใฆใใ ใ•ใ„ใ€‚", 1296 - "requirementsTitle": "ๅฟ…่ฆใชใ‚‚ใฎ", 1297 - "requirement1": "ใƒชใƒใ‚ธใƒˆใƒชใฎCARใƒ•ใ‚กใ‚คใƒซใƒใƒƒใ‚ฏใ‚ขใƒƒใƒ—", 1298 - "requirement2": "ใƒญใƒผใƒ†ใƒผใ‚ทใƒงใƒณใ‚ญใƒผ๏ผˆDIDใฎ็ง˜ๅฏ†้ต๏ผ‰", 1299 - "requirement3": "ใ‚ใชใŸใฎDID (did:plc:xxx)", 1300 - "understand": "็†่งฃใ—ใ€็ถš่กŒใ—ใพใ™" 1301 - }, 1302 - "provideDid": { 1303 - "title": "DIDใ‚’ๅ…ฅๅŠ›", 1304 - "desc": "ๅพฉๅ…ƒใ™ใ‚‹ใ‚ขใ‚ซใ‚ฆใƒณใƒˆใฎDIDใ‚’ๅ…ฅๅŠ›ใ—ใฆใใ ใ•ใ„ใ€‚", 1305 - "label": "ใ‚ใชใŸใฎDID", 1306 - "hint": "ๅˆ†ๆ•ฃๅž‹่ญ˜ๅˆฅๅญ๏ผˆไพ‹๏ผšdid:plc:abc123๏ผ‰" 1307 - }, 1308 - "uploadCar": { 1309 - "title": "CARใƒ•ใ‚กใ‚คใƒซใ‚’ใ‚ขใƒƒใƒ—ใƒญใƒผใƒ‰", 1310 - "desc": "ใƒชใƒใ‚ธใƒˆใƒชใƒใƒƒใ‚ฏใ‚ขใƒƒใƒ—ใƒ•ใ‚กใ‚คใƒซใ‚’ใ‚ขใƒƒใƒ—ใƒญใƒผใƒ‰ใ—ใฆใใ ใ•ใ„ใ€‚", 1311 - "label": "CARใƒ•ใ‚กใ‚คใƒซ", 1312 - "hint": "ใƒใƒƒใ‚ฏใ‚ขใƒƒใƒ—ใ‹ใ‚‰.carใƒ•ใ‚กใ‚คใƒซใ‚’้ธๆŠž", 1313 - "reuploadWarningTitle": "CARใƒ•ใ‚กใ‚คใƒซใŒๅฟ…่ฆใงใ™", 1314 - "reuploadWarning": "ใ‚ปใƒƒใ‚ทใƒงใƒณใฏๅพฉๅ…ƒใ•ใ‚Œใพใ—ใŸใŒใ€CARใƒ•ใ‚กใ‚คใƒซใ‚’ๅ†ใ‚ขใƒƒใƒ—ใƒญใƒผใƒ‰ใ™ใ‚‹ๅฟ…่ฆใŒใ‚ใ‚Šใพใ™ใ€‚ใ‚ปใ‚ญใƒฅใƒชใƒ†ใ‚ฃไธŠใฎ็†็”ฑใ‹ใ‚‰ใ€ใƒ•ใ‚กใ‚คใƒซใฎๅ†…ๅฎนใฏใ‚ปใƒƒใ‚ทใƒงใƒณ้–“ใงไฟๅญ˜ใ•ใ‚Œใพใ›ใ‚“ใ€‚" 1252 + "title": "ใ“ใฎPDSใ‹ใ‚‰็งป่กŒ", 1253 + "desc": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’ๅˆฅใฎใƒ‘ใƒผใ‚ฝใƒŠใƒซใƒ‡ใƒผใ‚ฟใ‚ตใƒผใƒใƒผใซ็งปๅ‹•ใ—ใพใ™ใ€‚", 1254 + "warning": "็งป่กŒๅพŒใ€ใ“ใ“ใงใฎใ‚ขใ‚ซใ‚ฆใƒณใƒˆใฏ็„กๅŠนๅŒ–ใ•ใ‚Œใพใ™ใ€‚", 1255 + "didWebNotice": "did:web็งป่กŒใฎใŠ็Ÿฅใ‚‰ใ›", 1256 + "didWebNoticeDesc": "ใ‚ใชใŸใฎใ‚ขใ‚ซใ‚ฆใƒณใƒˆใฏdid:web่ญ˜ๅˆฅๅญ๏ผˆ{did}๏ผ‰ใ‚’ไฝฟ็”จใ—ใฆใ„ใพใ™ใ€‚็งป่กŒๅพŒใ€ใ“ใฎPDSใฏๆ–ฐใ—ใ„PDSใ‚’ๆŒ‡ใ™DIDใƒ‰ใ‚ญใƒฅใƒกใƒณใƒˆใ‚’ๅผ•ใ็ถšใๆไพ›ใ—ใพใ™ใ€‚ใ“ใฎใ‚ตใƒผใƒใƒผใŒใ‚ชใƒณใƒฉใ‚คใƒณใงใ‚ใ‚‹้™ใ‚Šใ€ใ‚ขใ‚คใƒ‡ใƒณใƒ†ใ‚ฃใƒ†ใ‚ฃใฏๆฉŸ่ƒฝใ—็ถšใ‘ใพใ™ใ€‚", 1257 + "understand": "ใƒชใ‚นใ‚ฏใ‚’็†่งฃใ—ใ€็ถš่กŒใ—ใพใ™" 1315 1258 }, 1316 - "rotationKey": { 1317 - "title": "ใƒญใƒผใƒ†ใƒผใ‚ทใƒงใƒณใ‚ญใƒผใ‚’ๆไพ›", 1318 - "desc": "ใ“ใฎDIDใฎๆ‰€ๆœ‰ๆจฉใ‚’่จผๆ˜Žใ™ใ‚‹ใŸใ‚ใซใƒญใƒผใƒ†ใƒผใ‚ทใƒงใƒณใ‚ญใƒผใ‚’ๅ…ฅๅŠ›ใ—ใฆใใ ใ•ใ„ใ€‚", 1319 - "securityWarningTitle": "ใ‚ปใ‚ญใƒฅใƒชใƒ†ใ‚ฃ่ญฆๅ‘Š", 1320 - "securityWarning1": "ใƒญใƒผใƒ†ใƒผใ‚ทใƒงใƒณใ‚ญใƒผใฏ้žๅธธใซๆฉŸๅฏ†ๆ€งใŒ้ซ˜ใ„ใงใ™ - ใƒžใ‚นใ‚ฟใƒผใƒ‘ใ‚นใƒฏใƒผใƒ‰ใฎใ‚ˆใ†ใซๆ‰ฑใฃใฆใใ ใ•ใ„", 1321 - "securityWarning2": "ไฟก้ ผใงใใ‚‹ใƒ‡ใƒใ‚คใ‚นใจใƒใƒƒใƒˆใƒฏใƒผใ‚ฏใงใฎใฟๅ…ฅๅŠ›ใ—ใฆใใ ใ•ใ„", 1322 - "securityWarning3": "ใ“ใฎใ‚ญใƒผใฏ็งป่กŒๅฎŒไบ†ๅพŒใซไฟๅญ˜ใ•ใ‚Œใพใ›ใ‚“", 1323 - "label": "ใƒญใƒผใƒ†ใƒผใ‚ทใƒงใƒณใ‚ญใƒผ", 1324 - "placeholder": "็ง˜ๅฏ†้ตใ‚’ๅ…ฅๅŠ›๏ผˆhexใ€base58ใ€ใพใŸใฏJWK๏ผ‰", 1325 - "hint": "DIDใƒ‰ใ‚ญใƒฅใƒกใƒณใƒˆใฎใƒญใƒผใƒ†ใƒผใ‚ทใƒงใƒณใ‚ญใƒผใฎ1ใคใซๅฏพๅฟœใ™ใ‚‹็ง˜ๅฏ†้ต", 1326 - "valid": "ใ‚ญใƒผใฏๆœ‰ๅŠนใงใ€DIDใฎใƒญใƒผใƒ†ใƒผใ‚ทใƒงใƒณใ‚ญใƒผใจไธ€่‡ดใ—ใพใ™", 1327 - "invalid": "ใ‚ญใƒผใฏDIDใƒ‰ใ‚ญใƒฅใƒกใƒณใƒˆใฎใฉใฎใƒญใƒผใƒ†ใƒผใ‚ทใƒงใƒณใ‚ญใƒผใจใ‚‚ไธ€่‡ดใ—ใพใ›ใ‚“", 1328 - "validating": "ใ‚ญใƒผใ‚’ๆคœ่จผไธญ...", 1329 - "validate": "ใ‚ญใƒผใ‚’ๆคœ่จผ" 1259 + "targetPds": { 1260 + "title": "็งป่กŒๅ…ˆPDSใ‚’้ธๆŠž", 1261 + "desc": "็งป่กŒๅ…ˆใฎPDSใฎURLใ‚’ๅ…ฅๅŠ›ใ—ใฆใใ ใ•ใ„ใ€‚", 1262 + "url": "PDS URL", 1263 + "urlPlaceholder": "https://pds.example.com", 1264 + "validate": "ๆคœ่จผใ—ใฆ็ถš่กŒ", 1265 + "validating": "ๆคœ่จผไธญ...", 1266 + "connected": "{name}ใซๆŽฅ็ถšใ—ใพใ—ใŸ", 1267 + "inviteRequired": "ๆ‹›ๅพ…ใ‚ณใƒผใƒ‰ใŒๅฟ…่ฆใงใ™", 1268 + "privacyPolicy": "ใƒ—ใƒฉใ‚คใƒใ‚ทใƒผใƒใƒชใ‚ทใƒผ", 1269 + "termsOfService": "ๅˆฉ็”จ่ฆ็ด„" 1330 1270 }, 1331 - "chooseHandle": { 1332 - "migratingDid": "DIDใ‚’ๅพฉๅ…ƒไธญ" 1271 + "newAccount": { 1272 + "title": "ๆ–ฐใ—ใ„ใ‚ขใ‚ซใ‚ฆใƒณใƒˆใฎ่ฉณ็ดฐ", 1273 + "desc": "ๆ–ฐใ—ใ„PDSใงใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’่จญๅฎšใ—ใพใ™ใ€‚", 1274 + "handle": "ใƒใƒณใƒ‰ใƒซ", 1275 + "availableDomains": "ๅˆฉ็”จๅฏ่ƒฝใชใƒ‰ใƒกใ‚คใƒณ", 1276 + "email": "ใƒกใƒผใƒซ", 1277 + "password": "ใƒ‘ใ‚นใƒฏใƒผใƒ‰", 1278 + "confirmPassword": "ใƒ‘ใ‚นใƒฏใƒผใƒ‰ใ‚’็ขบ่ช", 1279 + "inviteCode": "ๆ‹›ๅพ…ใ‚ณใƒผใƒ‰" 1333 1280 }, 1334 1281 "review": { 1335 - "desc": "ใ‚ชใƒ•ใƒฉใ‚คใƒณๅพฉๅ…ƒใฎ่ฉณ็ดฐใ‚’็ขบ่ชใ—ใฆใใ ใ•ใ„ใ€‚", 1336 - "carFile": "CARใƒ•ใ‚กใ‚คใƒซ", 1337 - "rotationKey": "ใƒญใƒผใƒ†ใƒผใ‚ทใƒงใƒณใ‚ญใƒผ", 1338 - "warning": "ๅพฉๅ…ƒใ‚’้–‹ๅง‹ใ™ใ‚‹ใจใ€ใ‚ขใ‚คใƒ‡ใƒณใƒ†ใ‚ฃใƒ†ใ‚ฃใŒใ“ใฎPDSใ‚’ๆŒ‡ใ™ใ‚ˆใ†ใซๆ›ดๆ–ฐใ•ใ‚Œใพใ™ใ€‚ใ“ใ‚Œใฏ็ฐกๅ˜ใซๅ…ƒใซๆˆปใ™ใ“ใจใŒใงใใพใ›ใ‚“ใ€‚", 1339 - "plcWarningTitle": "ๅผ•ใ่ฟ”ใ›ใชใ„ใƒใ‚คใƒณใƒˆ", 1340 - "plcWarning": "้–‹ๅง‹ใ™ใ‚‹ใจใ€DIDใƒ‰ใ‚ญใƒฅใƒกใƒณใƒˆใŒใ“ใฎPDSใ‚’ๆŒ‡ใ™ใ‚ˆใ†ใซๆ›ดๆ–ฐใ•ใ‚Œใพใ™ใ€‚ๅ•้กŒใŒ็™บ็”Ÿใ—ใŸๅ ดๅˆใฏใƒญใƒผใƒ†ใƒผใ‚ทใƒงใƒณใ‚ญใƒผใ‚’ไฝฟ็”จใ—ใฆๅ›žๅพฉใงใใพใ™ใŒใ€ๅฃŠใ‚ŒใŸใ‚ขใ‚คใƒ‡ใƒณใƒ†ใ‚ฃใƒ†ใ‚ฃ็Šถๆ…‹ใ‚’้ฟใ‘ใ‚‹ใŸใ‚ใซ็งป่กŒใ‚’ๅฎŒไบ†ใ™ใ‚‹ๅฟ…่ฆใŒใ‚ใ‚Šใพใ™ใ€‚" 1282 + "title": "็งป่กŒใฎ็ขบ่ช", 1283 + "desc": "็งป่กŒใฎ่ฉณ็ดฐใ‚’็ขบ่ชใ—ใฆใใ ใ•ใ„ใ€‚", 1284 + "currentHandle": "็พๅœจใฎใƒใƒณใƒ‰ใƒซ", 1285 + "newHandle": "ๆ–ฐใ—ใ„ใƒใƒณใƒ‰ใƒซ", 1286 + "sourcePds": "ใ“ใฎPDS", 1287 + "targetPds": "็งป่กŒๅ…ˆPDS", 1288 + "confirm": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’็งป่กŒใ™ใ‚‹ใ“ใจใ‚’็ขบ่ชใ—ใพใ™", 1289 + "startMigration": "็งป่กŒใ‚’้–‹ๅง‹" 1341 1290 }, 1342 1291 "migrating": { 1343 - "title": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’ๅพฉๅ…ƒไธญ", 1344 - "desc": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’ๅพฉๅ…ƒใ—ใฆใ„ใพใ™...", 1345 - "creating": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’ไฝœๆˆไธญ", 1346 - "importing": "ใƒชใƒใ‚ธใƒˆใƒชใ‚’ใ‚คใƒณใƒใƒผใƒˆไธญ", 1347 - "plcSigning": "ใ‚ขใ‚คใƒ‡ใƒณใƒ†ใ‚ฃใƒ†ใ‚ฃใ‚’ๆ›ดๆ–ฐไธญ", 1348 - "activating": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’ใ‚ขใ‚ฏใƒ†ใ‚ฃใƒ™ใƒผใƒˆไธญ" 1292 + "title": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’็งป่กŒไธญ", 1293 + "desc": "ใƒ‡ใƒผใ‚ฟใ‚’่ปข้€ใ—ใฆใ„ใพใ™..." 1349 1294 }, 1350 - "success": { 1351 - "desc": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆใฏใ“ใฎPDSใซๆญฃๅธธใซๅพฉๅ…ƒใ•ใ‚Œใพใ—ใŸใ€‚" 1295 + "plcToken": { 1296 + "title": "ๆœฌไบบ็ขบ่ช", 1297 + "desc": "็ขบ่ชใ‚ณใƒผใƒ‰ใŒใƒกใƒผใƒซใซ้€ไฟกใ•ใ‚Œใพใ—ใŸใ€‚" 1352 1298 }, 1353 - "blobs": { 1354 - "title": "Blobใ‚’็งป่กŒไธญ", 1355 - "desc": "ๅคใ„PDSใ‹ใ‚‰็”ปๅƒใจใƒกใƒ‡ใ‚ฃใ‚ขใฎๅพฉๅ…ƒใ‚’่ฉฆใฟใฆใ„ใพใ™...", 1356 - "migrating": "Blobใ‚’็งป่กŒไธญ", 1357 - "failedTitle": "ไธ€้ƒจใฎBlobใ‚’็งป่กŒใงใใพใ›ใ‚“ใงใ—ใŸ", 1358 - "failedDesc": "{count}ๅ€‹ใฎBlobใ‚’ๅคใ„PDSใ‹ใ‚‰ๅ–ๅพ—ใงใใพใ›ใ‚“ใงใ—ใŸใ€‚ใ‚ตใƒผใƒใƒผใซๆŽฅ็ถšใงใใชใ„ใ‹ใ€ใƒ•ใ‚กใ‚คใƒซใŒๅ‰Š้™คใ•ใ‚ŒใŸๅฏ่ƒฝๆ€งใŒใ‚ใ‚Šใพใ™ใ€‚", 1359 - "sourceUnreachableTitle": "ใ‚ฝใƒผใ‚นPDSใซๆŽฅ็ถšใงใใพใ›ใ‚“", 1360 - "sourceUnreachable": "ๅคใ„PDSใซๆŽฅ็ถšใ—ใฆใƒกใƒ‡ใ‚ฃใ‚ขใƒ•ใ‚กใ‚คใƒซใ‚’ๅ–ๅพ—ใงใใพใ›ใ‚“ใงใ—ใŸใ€‚ใ‚ทใƒฃใƒƒใƒˆใƒ€ใ‚ฆใƒณใ—ใŸใ‚ตใƒผใƒใƒผใ‹ใ‚‰ใฎ็งป่กŒใงใฏใ‚ˆใใ‚ใ‚‹ใ“ใจใงใ™ใ€‚ๆŠ•็จฟใฏๆฉŸ่ƒฝใ—ใพใ™ใŒใ€ไธ€้ƒจใฎ็”ปๅƒใŒๆฌ ่ฝใ™ใ‚‹ๅฏ่ƒฝๆ€งใŒใ‚ใ‚Šใพใ™ใ€‚" 1299 + "finalizing": { 1300 + "title": "็งป่กŒใ‚’ๅฎŒไบ†ไธญ", 1301 + "desc": "็งป่กŒใ‚’ๅฎŒไบ†ใ—ใฆใ„ใพใ™...", 1302 + "updatingForwarding": "DIDใƒ‰ใ‚ญใƒฅใƒกใƒณใƒˆใฎ่ปข้€ๅ…ˆใ‚’ๆ›ดๆ–ฐไธญ..." 1303 + }, 1304 + "success": { 1305 + "title": "็งป่กŒๅฎŒไบ†๏ผ", 1306 + "desc": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆใฏๆ–ฐใ—ใ„PDSใซๆญฃๅธธใซ็งป่กŒใ•ใ‚Œใพใ—ใŸใ€‚", 1307 + "newHandle": "ๆ–ฐใ—ใ„ใƒใƒณใƒ‰ใƒซ", 1308 + "newPds": "ๆ–ฐใ—ใ„PDS", 1309 + "nextSteps": "ๆฌกใฎใ‚นใƒ†ใƒƒใƒ—", 1310 + "nextSteps1": "ๆ–ฐใ—ใ„PDSใซใ‚ตใ‚คใƒณใ‚คใƒณ", 1311 + "nextSteps2": "ใ‚ขใƒ—ใƒชใฎ่ช่จผๆƒ…ๅ ฑใ‚’ๆ›ดๆ–ฐ", 1312 + "nextSteps3": "ใƒ•ใ‚ฉใƒญใƒฏใƒผใฏ่‡ชๅ‹•็š„ใซๆ–ฐใ—ใ„ๅ ดๆ‰€ใ‚’็ขบ่ชใงใใพใ™", 1313 + "loggingOut": "{seconds}็ง’ๅพŒใซใƒญใ‚ฐใ‚ขใ‚ฆใƒˆใ—ใพใ™..." 1361 1314 } 1362 1315 }, 1363 1316 "progress": {
+100 -147
frontend/src/locales/ko.json
··· 17 17 "dashboard": "๋Œ€์‹œ๋ณด๋“œ", 18 18 "backToDashboard": "โ† ๋Œ€์‹œ๋ณด๋“œ", 19 19 "copied": "๋ณต์‚ฌ๋จ!", 20 - "copyToClipboard": "ํด๋ฆฝ๋ณด๋“œ์— ๋ณต์‚ฌ", 21 - "verifying": "ํ™•์ธ ์ค‘...", 22 - "saving": "์ €์žฅ ์ค‘...", 23 - "creating": "์ƒ์„ฑ ์ค‘...", 24 - "updating": "์—…๋ฐ์ดํŠธ ์ค‘...", 25 - "sending": "์ „์†ก ์ค‘...", 26 - "authenticating": "์ธ์ฆ ์ค‘...", 27 - "checking": "ํ™•์ธ ์ค‘...", 28 - "redirecting": "๋ฆฌ๋””๋ ‰์…˜ ์ค‘...", 29 - "signIn": "๋กœ๊ทธ์ธ", 30 - "verify": "ํ™•์ธ", 31 - "remove": "์‚ญ์ œ", 32 - "revoke": "์ทจ์†Œ", 33 - "resendCode": "์ฝ”๋“œ ์žฌ์ „์†ก", 34 - "startOver": "์ฒ˜์Œ๋ถ€ํ„ฐ ๋‹ค์‹œ", 35 - "tryAgain": "๋‹ค์‹œ ์‹œ๋„", 36 - "password": "๋น„๋ฐ€๋ฒˆํ˜ธ", 37 - "email": "์ด๋ฉ”์ผ", 38 - "emailAddress": "์ด๋ฉ”์ผ ์ฃผ์†Œ", 39 - "handle": "ํ•ธ๋“ค", 40 - "did": "DID", 41 - "verificationCode": "์ธ์ฆ ์ฝ”๋“œ", 42 - "inviteCode": "์ดˆ๋Œ€ ์ฝ”๋“œ", 43 - "newPassword": "์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ", 44 - "confirmPassword": "๋น„๋ฐ€๋ฒˆํ˜ธ ํ™•์ธ", 45 - "enterSixDigitCode": "6์ž๋ฆฌ ์ฝ”๋“œ ์ž…๋ ฅ", 46 - "passwordHint": "8์ž ์ด์ƒ", 47 - "enterPassword": "๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š”", 48 - "emailPlaceholder": "you@example.com", 49 - "verified": "์ธ์ฆ๋จ", 50 - "disabled": "๋น„ํ™œ์„ฑํ™”๋จ", 51 - "available": "์‚ฌ์šฉ ๊ฐ€๋Šฅ", 52 - "deactivated": "๋น„ํ™œ์„ฑํ™”๋จ", 53 - "unverified": "๋ฏธ์ธ์ฆ", 54 - "backToLogin": "๋กœ๊ทธ์ธ์œผ๋กœ ๋Œ์•„๊ฐ€๊ธฐ", 55 - "backToSettings": "์„ค์ •์œผ๋กœ ๋Œ์•„๊ฐ€๊ธฐ", 56 - "alreadyHaveAccount": "์ด๋ฏธ ๊ณ„์ •์ด ์žˆ์œผ์‹ ๊ฐ€์š”?", 57 - "createAccount": "๊ณ„์ • ๋งŒ๋“ค๊ธฐ", 58 - "passwordsMismatch": "๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค", 59 - "passwordTooShort": "๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” 8์ž ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค" 20 + "copyToClipboard": "ํด๋ฆฝ๋ณด๋“œ์— ๋ณต์‚ฌ" 60 21 }, 61 22 "login": { 62 23 "title": "๋กœ๊ทธ์ธ", ··· 88 49 "codeLabel": "์ธ์ฆ ์ฝ”๋“œ", 89 50 "codePlaceholder": "6์ž๋ฆฌ ์ฝ”๋“œ ์ž…๋ ฅ", 90 51 "verifyButton": "๊ณ„์ • ์ธ์ฆ", 91 - "resent": "์ธ์ฆ ์ฝ”๋“œ๋ฅผ ๋‹ค์‹œ ๋ณด๋ƒˆ์Šต๋‹ˆ๋‹ค!" 52 + "verifying": "์ธ์ฆ ์ค‘...", 53 + "resendButton": "์ฝ”๋“œ ๋‹ค์‹œ ๋ณด๋‚ด๊ธฐ", 54 + "resending": "์ „์†ก ์ค‘...", 55 + "resent": "์ธ์ฆ ์ฝ”๋“œ๋ฅผ ๋‹ค์‹œ ๋ณด๋ƒˆ์Šต๋‹ˆ๋‹ค!", 56 + "backToLogin": "๋กœ๊ทธ์ธ์œผ๋กœ ๋Œ์•„๊ฐ€๊ธฐ" 92 57 }, 93 58 "register": { 94 59 "title": "๊ณ„์ • ๋งŒ๋“ค๊ธฐ", ··· 159 124 "inviteCodePlaceholder": "์ดˆ๋Œ€ ์ฝ”๋“œ ์ž…๋ ฅ", 160 125 "inviteCodeRequired": "ํ•„์ˆ˜", 161 126 "createButton": "๊ณ„์ • ๋งŒ๋“ค๊ธฐ", 127 + "creating": "๊ณ„์ • ์ƒ์„ฑ ์ค‘...", 162 128 "alreadyHaveAccount": "์ด๋ฏธ ๊ณ„์ •์ด ์žˆ์œผ์‹ ๊ฐ€์š”?", 163 129 "signIn": "๋กœ๊ทธ์ธ", 164 130 "wantPasswordless": "๋น„๋ฐ€๋ฒˆํ˜ธ ์—†๋Š” ๋ณด์•ˆ์„ ์›ํ•˜์‹œ๋‚˜์š”?", ··· 213 179 "navAdminDesc": "์„œ๋ฒ„ ํ†ต๊ณ„ ๋ฐ ๊ด€๋ฆฌ ์ž‘์—…", 214 180 "navDidDocument": "DID ๋ฌธ์„œ", 215 181 "navDidDocumentDesc": "DID ๋ฌธ์„œ ๋ฐ ํ‚ค ๊ด€๋ฆฌ", 216 - "navDidDocumentDescActive": "DID ๋ฌธ์„œ ์„ค์ • ํŽธ์ง‘", 217 - "navBackup": "๋ฐฑ์—… ๋‹ค์šด๋กœ๋“œ", 218 - "navBackupDesc": "์ €์žฅ์†Œ๋ฅผ CAR ํŒŒ์ผ๋กœ ๋‹ค์šด๋กœ๋“œ", 219 - "downloadingBackup": "๋‹ค์šด๋กœ๋“œ ์ค‘...", 220 - "backupFailed": "๋ฐฑ์—… ๋‹ค์šด๋กœ๋“œ ์‹คํŒจ", 221 182 "migrated": "๋งˆ์ด๊ทธ๋ ˆ์ด์…˜๋จ", 222 183 "migratedTitle": "๊ณ„์ • ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜๋จ", 223 184 "migratedMessage": "๊ณ„์ •์ด {pds}๋กœ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜๋˜์—ˆ์Šต๋‹ˆ๋‹ค. DID ๋ฌธ์„œ๋Š” ์—ฌ์ „ํžˆ ์—ฌ๊ธฐ์—์„œ ํ˜ธ์ŠคํŒ…๋ฉ๋‹ˆ๋‹ค.", ··· 247 208 "serviceEndpointDesc": "ํ˜„์žฌ ๊ณ„์ • ๋ฐ์ดํ„ฐ๋ฅผ ํ˜ธ์ŠคํŒ…ํ•˜๋Š” PDS์ž…๋‹ˆ๋‹ค. ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ํ•  ๋•Œ ์—…๋ฐ์ดํŠธํ•˜์„ธ์š”.", 248 209 "currentPds": "ํ˜„์žฌ PDS URL", 249 210 "save": "๋ณ€๊ฒฝ์‚ฌํ•ญ ์ €์žฅ", 211 + "saving": "์ €์žฅ ์ค‘...", 250 212 "success": "DID ๋ฌธ์„œ๊ฐ€ ์—…๋ฐ์ดํŠธ๋˜์—ˆ์Šต๋‹ˆ๋‹ค", 251 213 "saveFailed": "DID ๋ฌธ์„œ ์ €์žฅ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค", 252 214 "loadFailed": "DID ๋ฌธ์„œ ๋กœ๋“œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค", ··· 284 246 "yourDomain": "๋„๋ฉ”์ธ", 285 247 "yourDomainPlaceholder": "example.com", 286 248 "verifyAndUpdate": "ํ™•์ธ ํ›„ ํ•ธ๋“ค ์—…๋ฐ์ดํŠธ", 249 + "verifying": "ํ™•์ธ ์ค‘...", 287 250 "newHandle": "์ƒˆ ํ•ธ๋“ค", 288 251 "newHandlePlaceholder": "yourhandle", 289 252 "changeHandleButton": "ํ•ธ๋“ค ๋ณ€๊ฒฝ", ··· 299 262 "exportData": "๋ฐ์ดํ„ฐ ๋‚ด๋ณด๋‚ด๊ธฐ", 300 263 "exportDataDescription": "์ „์ฒด ์ €์žฅ์†Œ๋ฅผ CAR (Content Addressable Archive) ํŒŒ์ผ๋กœ ๋‹ค์šด๋กœ๋“œํ•ฉ๋‹ˆ๋‹ค. ๋ชจ๋“  ๊ฒŒ์‹œ๋ฌผ, ์ข‹์•„์š”, ํŒ”๋กœ์šฐ ๋ฐ ๊ธฐํƒ€ ๋ฐ์ดํ„ฐ๊ฐ€ ํฌํ•จ๋ฉ๋‹ˆ๋‹ค.", 301 264 "downloadRepo": "์ €์žฅ์†Œ ๋‹ค์šด๋กœ๋“œ", 302 - "downloadBlobs": "๋ฏธ๋””์–ด ๋‹ค์šด๋กœ๋“œ", 303 265 "exporting": "๋‚ด๋ณด๋‚ด๊ธฐ ์ค‘...", 304 - "backups": { 305 - "title": "๋ฐฑ์—…", 306 - "description": "์ž๋™ ๋ฐฑ์—…์„ ๊ด€๋ฆฌํ•˜๊ณ  ๊ณ„์ • ๋ฐ์ดํ„ฐ๋ฅผ ๋ณต์›ํ•˜์„ธ์š”. ๋ฐฑ์—…์—๋Š” ๋ชจ๋“  ๊ธฐ๋ก๊ณผ blob์ด ํฌํ•จ๋ฉ๋‹ˆ๋‹ค.", 307 - "enableAutomatic": "์ž๋™ ๋ฐฑ์—…", 308 - "enabled": "ํ™œ์„ฑํ™”๋จ", 309 - "disabled": "๋น„ํ™œ์„ฑํ™”๋จ", 310 - "toggleFailed": "๋ฐฑ์—… ์„ค์ • ๋ณ€๊ฒฝ ์‹คํŒจ", 311 - "noBackups": "์•„์ง ๋ฐฑ์—…์ด ์—†์Šต๋‹ˆ๋‹ค", 312 - "blocks": "๋ธ”๋ก", 313 - "download": "๋‹ค์šด๋กœ๋“œ", 314 - "delete": "์‚ญ์ œ", 315 - "createNow": "์ง€๊ธˆ ๋ฐฑ์—… ์ƒ์„ฑ", 316 - "created": "๋ฐฑ์—…์ด ์ƒ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค", 317 - "createFailed": "๋ฐฑ์—… ์ƒ์„ฑ ์‹คํŒจ", 318 - "downloadFailed": "๋ฐฑ์—… ๋‹ค์šด๋กœ๋“œ ์‹คํŒจ", 319 - "deleted": "๋ฐฑ์—…์ด ์‚ญ์ œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค", 320 - "deleteFailed": "๋ฐฑ์—… ์‚ญ์ œ ์‹คํŒจ", 321 - "restoreTitle": "๋ฐฑ์—…์—์„œ ๋ณต์›", 322 - "restoreDescription": "์ด์ „์— ๋‚ด๋ณด๋‚ธ CAR ํŒŒ์ผ์—์„œ ๊ณ„์ • ๋ฐ์ดํ„ฐ๋ฅผ ๋ณต์›ํ•ฉ๋‹ˆ๋‹ค. ์ด๋ ‡๊ฒŒ ํ•˜๋ฉด ํ˜„์žฌ ์ €์žฅ์†Œ๊ฐ€ ์—…๋กœ๋“œํ•œ ๋ฐฑ์—…์œผ๋กœ ๊ต์ฒด๋ฉ๋‹ˆ๋‹ค.", 323 - "selectFile": "CAR ํŒŒ์ผ ์„ ํƒ", 324 - "selectedFile": "์„ ํƒ๋œ ํŒŒ์ผ", 325 - "restore": "๋ฐฑ์—… ๋ณต์›", 326 - "restoring": "๋ณต์› ์ค‘...", 327 - "restored": "๋ฐฑ์—…์ด ์„ฑ๊ณต์ ์œผ๋กœ ๋ณต์›๋˜์—ˆ์Šต๋‹ˆ๋‹ค", 328 - "restoreFailed": "๋ฐฑ์—… ๋ณต์› ์‹คํŒจ" 329 - }, 330 266 "deleteAccount": "๊ณ„์ • ์‚ญ์ œ", 331 267 "deleteWarning": "์ด ์ž‘์—…์€ ๋˜๋Œ๋ฆด ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ๋ชจ๋“  ๋ฐ์ดํ„ฐ๊ฐ€ ์˜๊ตฌ์ ์œผ๋กœ ์‚ญ์ œ๋ฉ๋‹ˆ๋‹ค.", 332 268 "requestDeletion": "๊ณ„์ • ์‚ญ์ œ ์š”์ฒญ", ··· 355 291 "deleteConfirmation": "์ •๋ง๋กœ ๊ณ„์ •์„ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ? ์ด ์ž‘์—…์€ ๋˜๋Œ๋ฆด ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", 356 292 "deletionFailed": "๊ณ„์ • ์‚ญ์ œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค", 357 293 "repoExported": "์ €์žฅ์†Œ๋ฅผ ๋‚ด๋ณด๋ƒˆ์Šต๋‹ˆ๋‹ค", 358 - "blobsExported": "๋ฏธ๋””์–ด ํŒŒ์ผ์„ ๋‚ด๋ณด๋ƒˆ์Šต๋‹ˆ๋‹ค", 359 - "noBlobsToExport": "๋‚ด๋ณด๋‚ผ ๋ฏธ๋””์–ด ํŒŒ์ผ์ด ์—†์Šต๋‹ˆ๋‹ค", 360 - "exportFailed": "๋‚ด๋ณด๋‚ด๊ธฐ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค", 294 + "exportFailed": "์ €์žฅ์†Œ ๋‚ด๋ณด๋‚ด๊ธฐ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค", 361 295 "confirmDelete": "์ •๋ง๋กœ ๊ณ„์ •์„ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ? ์ด ์ž‘์—…์€ ๋˜๋Œ๋ฆด ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค." 362 296 } 363 297 }, ··· 372 306 "noPasswords": "์•ฑ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์•„์ง ์—†์Šต๋‹ˆ๋‹ค", 373 307 "revoke": "์ทจ์†Œ", 374 308 "revoking": "์ทจ์†Œ ์ค‘...", 309 + "creating": "์ƒ์„ฑ ์ค‘...", 375 310 "revokeConfirm": "์•ฑ ๋น„๋ฐ€๋ฒˆํ˜ธ \"{name}\"์„(๋ฅผ) ์ทจ์†Œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ? ์ด ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ์•ฑ์€ ๋” ์ด์ƒ ๊ณ„์ •์— ์•ก์„ธ์Šคํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", 376 311 "saveWarningTitle": "์ค‘์š”: ์ด ์•ฑ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ์ €์žฅํ•˜์„ธ์š”!", 377 312 "saveWarningMessage": "์ด ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ํŒจ์Šคํ‚ค ๋˜๋Š” OAuth๋ฅผ ์ง€์›ํ•˜์ง€ ์•Š๋Š” ์•ฑ์— ๋กœ๊ทธ์ธํ•˜๋Š” ๋ฐ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. ํ•œ ๋ฒˆ๋งŒ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.", ··· 419 354 "used": "@{handle}์ด(๊ฐ€) ์‚ฌ์šฉํ•จ", 420 355 "disabled": "๋น„ํ™œ์„ฑํ™”๋จ", 421 356 "usedBy": "์‚ฌ์šฉ์ž", 357 + "creating": "์ƒ์„ฑ ์ค‘...", 422 358 "disableConfirm": "์ด ์ดˆ๋Œ€ ์ฝ”๋“œ๋ฅผ ๋น„ํ™œ์„ฑํ™”ํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ? ๋” ์ด์ƒ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", 423 359 "created": "์ดˆ๋Œ€ ์ฝ”๋“œ๊ฐ€ ์ƒ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค", 424 360 "copy": "๋ณต์‚ฌ", ··· 546 482 "verifyButton": "์ธ์ฆ", 547 483 "verifyCodePlaceholder": "์ธ์ฆ ์ฝ”๋“œ ์ž…๋ ฅ", 548 484 "submit": "์ œ์ถœ", 485 + "saving": "์ €์žฅ ์ค‘...", 549 486 "savePreferences": "์„ค์ • ์ €์žฅ", 550 487 "preferencesSaved": "ํ†ต์‹  ์„ค์ •์ด ์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค", 551 488 "verifiedSuccess": "{channel} ์ธ์ฆ ์™„๋ฃŒ", ··· 584 521 "noCollectionsYet": "์ปฌ๋ ‰์…˜์ด ์•„์ง ์—†์Šต๋‹ˆ๋‹ค. ์ฒซ ๋ฒˆ์งธ ๋ ˆ์ฝ”๋“œ๋ฅผ ๋งŒ๋“ค์–ด ์‹œ์ž‘ํ•˜์„ธ์š”.", 585 522 "loadMore": "๋” ๋ถˆ๋Ÿฌ์˜ค๊ธฐ", 586 523 "recordJson": "๋ ˆ์ฝ”๋“œ JSON", 524 + "saving": "์ €์žฅ ์ค‘...", 587 525 "updateRecord": "๋ ˆ์ฝ”๋“œ ์—…๋ฐ์ดํŠธ", 588 526 "collectionNsid": "์ปฌ๋ ‰์…˜ (NSID)", 589 527 "recordKeyOptional": "๋ ˆ์ฝ”๋“œ ํ‚ค (์„ ํƒ์‚ฌํ•ญ)", 590 528 "autoGenerated": "๋น„์›Œ๋‘๋ฉด ์ž๋™ ์ƒ์„ฑ (TID)", 591 529 "autoGeneratedHint": "๋น„์›Œ๋‘๋ฉด TID ๊ธฐ๋ฐ˜ ํ‚ค๊ฐ€ ์ž๋™ ์ƒ์„ฑ๋ฉ๋‹ˆ๋‹ค", 530 + "creating": "์ƒ์„ฑ ์ค‘...", 592 531 "demoPostText": "์•ˆ๋…•ํ•˜์„ธ์š”, ์ œ PDS์—์„œ ๋ณด๋‚ด๋Š” ์ฒซ ๋ฒˆ์งธ ๊ฒŒ์‹œ๋ฌผ์ž…๋‹ˆ๋‹ค!", 593 532 "demoDisplayName": "ํ‘œ์‹œ ์ด๋ฆ„", 594 533 "demoBio": "๊ฐ„๋‹จํ•œ ์ž๊ธฐ์†Œ๊ฐœ๋ฅผ ์ž‘์„ฑํ•˜์„ธ์š”." ··· 609 548 "primaryLight": "๊ธฐ๋ณธ (๋ผ์ดํŠธ ๋ชจ๋“œ)", 610 549 "primaryDark": "๊ธฐ๋ณธ (๋‹คํฌ ๋ชจ๋“œ)", 611 550 "configSaved": "์„œ๋ฒ„ ์„ค์ •์ด ์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค", 551 + "saving": "์ €์žฅ ์ค‘...", 612 552 "saveConfig": "์„ค์ • ์ €์žฅ", 613 553 "serverStats": "์„œ๋ฒ„ ํ†ต๊ณ„", 614 554 "users": "์‚ฌ์šฉ์ž", ··· 699 639 "title": "2๋‹จ๊ณ„ ์ธ์ฆ", 700 640 "subtitle": "์ถ”๊ฐ€ ํ™•์ธ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค", 701 641 "usePasskey": "ํŒจ์Šคํ‚ค ์‚ฌ์šฉ", 702 - "useTotp": "์ธ์ฆ ์•ฑ ์‚ฌ์šฉ" 642 + "useTotp": "์ธ์ฆ ์•ฑ ์‚ฌ์šฉ", 643 + "verifying": "ํ™•์ธ ์ค‘..." 703 644 }, 704 645 "twoFactorCode": { 705 646 "title": "2๋‹จ๊ณ„ ์ธ์ฆ", 706 647 "subtitle": "{channel}(์œผ)๋กœ ์ธ์ฆ ์ฝ”๋“œ๋ฅผ ๋ณด๋ƒˆ์Šต๋‹ˆ๋‹ค. ์•„๋ž˜์— ์ฝ”๋“œ๋ฅผ ์ž…๋ ฅํ•˜์—ฌ ๊ณ„์†ํ•˜์„ธ์š”.", 707 648 "codeLabel": "์ธ์ฆ ์ฝ”๋“œ", 708 649 "codePlaceholder": "6์ž๋ฆฌ ์ฝ”๋“œ ์ž…๋ ฅ", 650 + "verify": "ํ™•์ธ", 651 + "verifying": "ํ™•์ธ ์ค‘...", 709 652 "errors": { 710 653 "missingRequestUri": "request_uri ๋งค๊ฐœ๋ณ€์ˆ˜๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค", 711 654 "verificationFailed": "์ธ์ฆ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค", ··· 717 660 "title": "์ธ์ฆ ์ฝ”๋“œ ์ž…๋ ฅ", 718 661 "subtitle": "์ธ์ฆ ์•ฑ์˜ 6์ž๋ฆฌ ์ฝ”๋“œ๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š”", 719 662 "codePlaceholder": "6์ž๋ฆฌ ์ฝ”๋“œ ์ž…๋ ฅ", 663 + "verify": "ํ™•์ธ", 664 + "verifying": "ํ™•์ธ ์ค‘...", 720 665 "useBackupCode": "๋ฐฑ์—… ์ฝ”๋“œ ์‚ฌ์šฉ", 721 666 "backupCodePlaceholder": "๋ฐฑ์—… ์ฝ”๋“œ ์ž…๋ ฅ", 722 667 "trustDevice": "์ด ๊ธฐ๊ธฐ๋ฅผ 30์ผ๊ฐ„ ์‹ ๋ขฐ", ··· 746 691 "codeLabel": "์ธ์ฆ ์ฝ”๋“œ", 747 692 "codeHelp": "๋ฉ”์‹œ์ง€์—์„œ ํ•˜์ดํ”ˆ์„ ํฌํ•จํ•œ ์ „์ฒด ์ฝ”๋“œ๋ฅผ ๋ณต์‚ฌํ•˜์„ธ์š”", 748 693 "verifyButton": "๊ณ„์ • ์ธ์ฆ", 694 + "verify": "์ธ์ฆ", 695 + "verifying": "์ธ์ฆ ์ค‘...", 749 696 "pleaseWait": "์ž ์‹œ ๊ธฐ๋‹ค๋ ค ์ฃผ์„ธ์š”...", 697 + "sending": "์ „์†ก ์ค‘...", 698 + "resendCode": "์ฝ”๋“œ ๋‹ค์‹œ ๋ณด๋‚ด๊ธฐ", 699 + "resending": "์ „์†ก ์ค‘...", 750 700 "codeResent": "์ธ์ฆ ์ฝ”๋“œ๋ฅผ ๋‹ค์‹œ ๋ณด๋ƒˆ์Šต๋‹ˆ๋‹ค!", 751 701 "codeResentDetail": "์ธ์ฆ ์ฝ”๋“œ๊ฐ€ ์ „์†ก๋˜์—ˆ์Šต๋‹ˆ๋‹ค! ๋ฐ›์€ ํŽธ์ง€ํ•จ์„ ํ™•์ธํ•˜์„ธ์š”.", 752 702 "verified": "์ธ์ฆ ์™„๋ฃŒ!", ··· 756 706 "identifierLabel": "์ด๋ฉ”์ผ ๋˜๋Š” ์‹๋ณ„์ž", 757 707 "identifierPlaceholder": "you@example.com", 758 708 "identifierHelp": "์ฝ”๋“œ๊ฐ€ ์ „์†ก๋œ ์ด๋ฉ”์ผ ์ฃผ์†Œ ๋˜๋Š” ์‹๋ณ„์ž", 709 + "backToLogin": "๋กœ๊ทธ์ธ์œผ๋กœ ๋Œ์•„๊ฐ€๊ธฐ", 759 710 "verifyingAccount": "์ธ์ฆ ์ค‘์ธ ๊ณ„์ •: @{handle}", 760 711 "startOver": "๋‹ค๋ฅธ ๊ณ„์ •์œผ๋กœ ๋‹ค์‹œ ์‹œ์ž‘", 761 712 "noPending": "๋ณด๋ฅ˜ ์ค‘์ธ ์ธ์ฆ์ด ์—†์Šต๋‹ˆ๋‹ค.", 762 713 "noPendingInfo": "์ตœ๊ทผ์— ๊ณ„์ •์„ ๋งŒ๋“ค๊ณ  ์ธ์ฆ์ด ํ•„์š”ํ•œ ๊ฒฝ์šฐ ์ƒˆ ๊ณ„์ •์„ ๋งŒ๋“ค์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์ด๋ฏธ ๊ณ„์ •์„ ์ธ์ฆํ•œ ๊ฒฝ์šฐ ๋กœ๊ทธ์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.", 763 714 "createAccount": "๊ณ„์ • ๋งŒ๋“ค๊ธฐ", 764 715 "signIn": "๋กœ๊ทธ์ธ", 716 + "backToSettings": "์„ค์ •์œผ๋กœ ๋Œ์•„๊ฐ€๊ธฐ", 765 717 "emailUpdateCodeHelp": "์ฝ”๋“œ๊ฐ€ ํ˜„์žฌ ์ด๋ฉ”์ผ ์ฃผ์†Œ๋กœ ์ „์†ก๋˜์—ˆ์Šต๋‹ˆ๋‹ค", 766 718 "emailUpdateFailed": "์ด๋ฉ”์ผ ์ฃผ์†Œ ์—…๋ฐ์ดํŠธ ์‹คํŒจ", 767 719 "emailUpdateRequiresAuth": "์ด๋ฉ”์ผ ์ฃผ์†Œ๋ฅผ ์—…๋ฐ์ดํŠธํ•˜๋ ค๋ฉด ๋กœ๊ทธ์ธํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.", ··· 794 746 "resetButton": "๋น„๋ฐ€๋ฒˆํ˜ธ ์žฌ์„ค์ •", 795 747 "resetting": "์žฌ์„ค์ • ์ค‘...", 796 748 "success": "๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์žฌ์„ค์ •๋˜์—ˆ์Šต๋‹ˆ๋‹ค!", 749 + "backToLogin": "๋กœ๊ทธ์ธ์œผ๋กœ ๋Œ์•„๊ฐ€๊ธฐ", 797 750 "requestNewCode": "์ƒˆ ์ฝ”๋“œ ์š”์ฒญ", 798 751 "passwordsMismatch": "๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค", 799 752 "passwordLength": "๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” 8์ž ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค" ··· 837 790 "howItWorks": "์ž‘๋™ ๋ฐฉ์‹", 838 791 "howItWorksDetail": "๋“ฑ๋ก๋œ ์•Œ๋ฆผ ์ฑ„๋„๋กœ ๋ณด์•ˆ ๋งํฌ๋ฅผ ๋ณด๋ƒ…๋‹ˆ๋‹ค. ๋งํฌ๋ฅผ ํด๋ฆญํ•˜์—ฌ ์ž„์‹œ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค. ๊ทธ๋Ÿฐ ๋‹ค์Œ ๋กœ๊ทธ์ธํ•˜์—ฌ ์ƒˆ ํŒจ์Šคํ‚ค๋ฅผ ์ถ”๊ฐ€ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.", 839 792 "sendRecoveryLink": "๋ณต๊ตฌ ๋งํฌ ๋ณด๋‚ด๊ธฐ", 840 - "sending": "์ „์†ก ์ค‘..." 793 + "sending": "์ „์†ก ์ค‘...", 794 + "backToLogin": "๋กœ๊ทธ์ธ์œผ๋กœ ๋Œ์•„๊ฐ€๊ธฐ" 841 795 }, 842 796 "registerPasskey": { 843 797 "title": "ํŒจ์Šคํ‚ค ๊ณ„์ • ๋งŒ๋“ค๊ธฐ", ··· 858 812 "externalDid": "๊ท€ํ•˜์˜ did:web", 859 813 "externalDidPlaceholder": "did:web:yourdomain.com", 860 814 "createButton": "๊ณ„์ • ๋งŒ๋“ค๊ธฐ", 815 + "creating": "์ƒ์„ฑ ์ค‘...", 861 816 "alreadyHaveAccount": "์ด๋ฏธ ๊ณ„์ •์ด ์žˆ์œผ์‹ ๊ฐ€์š”?", 862 817 "signIn": "๋กœ๊ทธ์ธ", 863 818 "wantPassword": "๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ์‚ฌ์šฉํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?", ··· 956 911 "useTotp": "์ธ์ฆ ์•ฑ ์‚ฌ์šฉ", 957 912 "passwordPlaceholder": "๋น„๋ฐ€๋ฒˆํ˜ธ ์ž…๋ ฅ", 958 913 "totpPlaceholder": "6์ž๋ฆฌ ์ฝ”๋“œ ์ž…๋ ฅ", 914 + "verify": "ํ™•์ธ", 915 + "verifying": "ํ™•์ธ ์ค‘...", 959 916 "authenticating": "์ธ์ฆ ์ค‘...", 960 917 "passkeyPrompt": "์•„๋ž˜ ๋ฒ„ํŠผ์„ ํด๋ฆญํ•˜์—ฌ ํŒจ์Šคํ‚ค๋กœ ์ธ์ฆํ•˜์„ธ์š”.", 961 918 "cancel": "์ทจ์†Œ" ··· 1028 985 "createAccount": "๊ณ„์ • ์ƒ์„ฑ", 1029 986 "createDelegatedAccount": "์œ„์ž„ ๊ณ„์ • ์ƒ์„ฑ", 1030 987 "createDelegatedAccountButton": "+ ์œ„์ž„ ๊ณ„์ • ์ƒ์„ฑ", 988 + "creating": "์ƒ์„ฑ ์ค‘...", 1031 989 "emailOptional": "์ด๋ฉ”์ผ (์„ ํƒ์‚ฌํ•ญ)", 1032 990 "failedToAddController": "์ปจํŠธ๋กค๋Ÿฌ ์ถ”๊ฐ€์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค", 1033 991 "failedToCreateAccount": "์œ„์ž„ ๊ณ„์ • ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค", ··· 1101 1059 "navDesc": "๋‹ค๋ฅธ PDS๋กœ ๋˜๋Š” ๋‹ค๋ฅธ PDS์—์„œ ๊ณ„์ • ์ด๋™", 1102 1060 "migrateHere": "์—ฌ๊ธฐ๋กœ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜", 1103 1061 "migrateHereDesc": "๊ธฐ์กด AT Protocol ๊ณ„์ •์„ ๋‹ค๋ฅธ ์„œ๋ฒ„์—์„œ ์ด PDS๋กœ ์ด๋™ํ•ฉ๋‹ˆ๋‹ค.", 1062 + "migrateAway": "๋‹ค๋ฅธ ๊ณณ์œผ๋กœ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜", 1063 + "migrateAwayDesc": "์ด PDS์—์„œ ๋‹ค๋ฅธ ์„œ๋ฒ„๋กœ ๊ณ„์ •์„ ์ด๋™ํ•ฉ๋‹ˆ๋‹ค.", 1064 + "loginRequired": "๋กœ๊ทธ์ธ ํ•„์š”", 1104 1065 "bringDid": "DID์™€ ์•„์ด๋ดํ‹ฐํ‹ฐ ๊ฐ€์ ธ์˜ค๊ธฐ", 1105 1066 "transferData": "๋ชจ๋“  ๋ฐ์ดํ„ฐ ์ „์†ก", 1106 1067 "keepFollowers": "ํŒ”๋กœ์›Œ ์œ ์ง€", 1068 + "exportRepo": "์ €์žฅ์†Œ ๋‚ด๋ณด๋‚ด๊ธฐ", 1069 + "transferToPds": "์ƒˆ PDS๋กœ ์ „์†ก", 1070 + "updateIdentity": "์•„์ด๋ดํ‹ฐํ‹ฐ ์—…๋ฐ์ดํŠธ", 1107 1071 "whatIsMigration": "๊ณ„์ • ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜์ด๋ž€?", 1108 1072 "whatIsMigrationDesc": "๊ณ„์ • ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜์„ ํ†ตํ•ด AT Protocol ์•„์ด๋ดํ‹ฐํ‹ฐ๋ฅผ ๊ฐœ์ธ ๋ฐ์ดํ„ฐ ์„œ๋ฒ„(PDS) ๊ฐ„์— ์ด๋™ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. DID(๋ถ„์‚ฐ ์‹๋ณ„์ž)๋Š” ๋™์ผํ•˜๊ฒŒ ์œ ์ง€๋˜๋ฏ€๋กœ ํŒ”๋กœ์›Œ์™€ ์†Œ์…œ ์—ฐ๊ฒฐ์ด ๋ณด์กด๋ฉ๋‹ˆ๋‹ค.", 1109 1073 "beforeMigrate": "๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์ „ ํ™•์ธ์‚ฌํ•ญ", ··· 1113 1077 "beforeMigrate4": "์ด์ „ PDS์— ๊ณ„์ • ๋น„ํ™œ์„ฑํ™”๊ฐ€ ํ†ต๋ณด๋ฉ๋‹ˆ๋‹ค", 1114 1078 "importantWarning": "๊ณ„์ • ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜์€ ์ค‘์š”ํ•œ ์ž‘์—…์ž…๋‹ˆ๋‹ค. ๋Œ€์ƒ PDS๋ฅผ ์‹ ๋ขฐํ•˜๊ณ  ๋ฐ์ดํ„ฐ๊ฐ€ ์ด๋™๋œ๋‹ค๋Š” ๊ฒƒ์„ ์ดํ•ดํ•˜์„ธ์š”. ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉด ์ˆ˜๋™ ๋ณต๊ตฌ๊ฐ€ ํ•„์š”ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.", 1115 1079 "learnMore": "๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์œ„ํ—˜์— ๋Œ€ํ•ด ์ž์„ธํžˆ ์•Œ์•„๋ณด๊ธฐ", 1116 - "offlineRestore": "์˜คํ”„๋ผ์ธ ๋ณต์›", 1117 - "offlineRestoreDesc": "์ด์ „ PDS๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†์„ ๋•Œ ๋ฐฑ์—…์—์„œ ๋ณต์›ํ•ฉ๋‹ˆ๋‹ค.", 1118 - "offlineFeature1": "CAR ํŒŒ์ผ ๋ฐฑ์—… ์‚ฌ์šฉ", 1119 - "offlineFeature2": "ํšŒ์ „ ํ‚ค๋กœ ์†Œ์œ ๊ถŒ ์ฆ๋ช…", 1120 - "offlineFeature3": "์ข…๋ฃŒ๋œ ์„œ๋ฒ„ ๋ณต๊ตฌ", 1080 + "comingSoon": "๊ณง ์ถœ์‹œ ์˜ˆ์ •", 1121 1081 "oauthCompleting": "์ธ์ฆ ์™„๋ฃŒ ์ค‘...", 1122 1082 "oauthFailed": "์ธ์ฆ ์‹คํŒจ", 1123 1083 "tryAgain": "๋‹ค์‹œ ์‹œ๋„", ··· 1126 1086 "incomplete": "์™„๋ฃŒ๋˜์ง€ ์•Š์€ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜์ด ์žˆ์Šต๋‹ˆ๋‹ค:", 1127 1087 "direction": "๋ฐฉํ–ฅ", 1128 1088 "migratingHere": "์—ฌ๊ธฐ๋กœ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์ค‘", 1089 + "migratingAway": "๋‹ค๋ฅธ ๊ณณ์œผ๋กœ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์ค‘", 1129 1090 "from": "์ถœ๋ฐœ์ง€", 1130 1091 "to": "๋ชฉ์ ์ง€", 1131 1092 "progress": "์ง„ํ–‰ ์ƒํ™ฉ", ··· 1268 1229 "error": { 1269 1230 "title": "๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์˜ค๋ฅ˜", 1270 1231 "desc": "๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.", 1271 - "startOver": "์ฒ˜์Œ๋ถ€ํ„ฐ ๋‹ค์‹œ ์‹œ์ž‘", 1272 - "unknown": "์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค." 1232 + "startOver": "์ฒ˜์Œ๋ถ€ํ„ฐ ๋‹ค์‹œ ์‹œ์ž‘" 1273 1233 }, 1274 1234 "common": { 1275 1235 "back": "๋’ค๋กœ", ··· 1287 1247 "warning3": "๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ํ›„ ์ด์ „ ๊ณ„์ •์€ ๋น„ํ™œ์„ฑํ™”๋ฉ๋‹ˆ๋‹ค" 1288 1248 } 1289 1249 }, 1290 - "offline": { 1250 + "outbound": { 1291 1251 "welcome": { 1292 - "title": "๋ฐฑ์—…์—์„œ ๋ณต์›", 1293 - "desc": "CAR ํŒŒ์ผ ๋ฐฑ์—…๊ณผ ํšŒ์ „ ํ‚ค๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ๊ณ„์ •์„ ๋ณต์›ํ•ฉ๋‹ˆ๋‹ค. ์ด์ „ PDS๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†์„ ๋•Œ ์‚ฌ์šฉํ•˜์„ธ์š”.", 1294 - "warningTitle": "์ด ๋ฐฉ๋ฒ•์„ ์‚ฌ์šฉํ•ด์•ผ ํ•  ๋•Œ", 1295 - "warningDesc": "์ด ์˜คํ”„๋ผ์ธ ๋ณต์›์€ ์ด์ „ PDS๊ฐ€ ์ข…๋ฃŒ๋˜์—ˆ๊ฑฐ๋‚˜, ์ ‘๊ทผํ•  ์ˆ˜ ์—†๊ฑฐ๋‚˜, ์ž ๊ธด ๊ฒฝ์šฐ์˜ ์žฌํ•ด ๋ณต๊ตฌ์šฉ์ž…๋‹ˆ๋‹ค. ์ด์ „ PDS๊ฐ€ ์—ฌ์ „ํžˆ ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•˜๋ฉด ํ‘œ์ค€ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜์„ ์‚ฌ์šฉํ•˜์„ธ์š”.", 1296 - "requirementsTitle": "ํ•„์š”ํ•œ ๊ฒƒ", 1297 - "requirement1": "์ €์žฅ์†Œ์˜ CAR ํŒŒ์ผ ๋ฐฑ์—…", 1298 - "requirement2": "ํšŒ์ „ ํ‚ค (DID์˜ ๊ฐœ์ธ ํ‚ค)", 1299 - "requirement3": "๋‹น์‹ ์˜ DID (did:plc:xxx)", 1300 - "understand": "์ดํ•ดํ•˜๊ณ  ๊ณ„์† ์ง„ํ–‰ํ•ฉ๋‹ˆ๋‹ค" 1301 - }, 1302 - "provideDid": { 1303 - "title": "DID ์ž…๋ ฅ", 1304 - "desc": "๋ณต์›ํ•  ๊ณ„์ •์˜ DID๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š”.", 1305 - "label": "๋‹น์‹ ์˜ DID", 1306 - "hint": "๋ถ„์‚ฐ ์‹๋ณ„์ž (์˜ˆ: did:plc:abc123)" 1307 - }, 1308 - "uploadCar": { 1309 - "title": "CAR ํŒŒ์ผ ์—…๋กœ๋“œ", 1310 - "desc": "์ €์žฅ์†Œ ๋ฐฑ์—… ํŒŒ์ผ์„ ์—…๋กœ๋“œํ•˜์„ธ์š”.", 1311 - "label": "CAR ํŒŒ์ผ", 1312 - "hint": "๋ฐฑ์—…์—์„œ .car ํŒŒ์ผ์„ ์„ ํƒํ•˜์„ธ์š”", 1313 - "reuploadWarningTitle": "CAR ํŒŒ์ผ ํ•„์š”", 1314 - "reuploadWarning": "์„ธ์…˜์ด ๋ณต์›๋˜์—ˆ์ง€๋งŒ CAR ํŒŒ์ผ์„ ๋‹ค์‹œ ์—…๋กœ๋“œํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ๋ณด์•ˆ์ƒ์˜ ์ด์œ ๋กœ ํŒŒ์ผ ๋‚ด์šฉ์€ ์„ธ์…˜ ๊ฐ„์— ์ €์žฅ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค." 1252 + "title": "์ด PDS์—์„œ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜", 1253 + "desc": "๊ณ„์ •์„ ๋‹ค๋ฅธ ๊ฐœ์ธ ๋ฐ์ดํ„ฐ ์„œ๋ฒ„๋กœ ์ด๋™ํ•ฉ๋‹ˆ๋‹ค.", 1254 + "warning": "๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ํ›„ ์ด PDS์—์„œ ๊ณ„์ •์ด ๋น„ํ™œ์„ฑํ™”๋ฉ๋‹ˆ๋‹ค.", 1255 + "didWebNotice": "did:web ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์•Œ๋ฆผ", 1256 + "didWebNoticeDesc": "๊ท€ํ•˜์˜ ๊ณ„์ •์€ did:web ์‹๋ณ„์ž({did})๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ํ›„ ์ด PDS๋Š” ์ƒˆ PDS๋ฅผ ๊ฐ€๋ฆฌํ‚ค๋Š” DID ๋ฌธ์„œ๋ฅผ ๊ณ„์† ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. ์ด ์„œ๋ฒ„๊ฐ€ ์˜จ๋ผ์ธ์ธ ํ•œ ์•„์ด๋ดํ‹ฐํ‹ฐ๋Š” ๊ณ„์† ์ž‘๋™ํ•ฉ๋‹ˆ๋‹ค.", 1257 + "understand": "์œ„ํ—˜์„ ์ดํ•ดํ•˜๊ณ  ๊ณ„์† ์ง„ํ–‰ํ•ฉ๋‹ˆ๋‹ค" 1315 1258 }, 1316 - "rotationKey": { 1317 - "title": "ํšŒ์ „ ํ‚ค ์ œ๊ณต", 1318 - "desc": "์ด DID์˜ ์†Œ์œ ๊ถŒ์„ ์ฆ๋ช…ํ•˜๊ธฐ ์œ„ํ•ด ํšŒ์ „ ํ‚ค๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š”.", 1319 - "securityWarningTitle": "๋ณด์•ˆ ๊ฒฝ๊ณ ", 1320 - "securityWarning1": "ํšŒ์ „ ํ‚ค๋Š” ๋งค์šฐ ๋ฏผ๊ฐํ•ฉ๋‹ˆ๋‹ค - ๋งˆ์Šคํ„ฐ ๋น„๋ฐ€๋ฒˆํ˜ธ์ฒ˜๋Ÿผ ์ทจ๊ธ‰ํ•˜์„ธ์š”", 1321 - "securityWarning2": "์‹ ๋ขฐํ•  ์ˆ˜ ์žˆ๋Š” ์žฅ์น˜์™€ ๋„คํŠธ์›Œํฌ์—์„œ๋งŒ ์ž…๋ ฅํ•˜์„ธ์š”", 1322 - "securityWarning3": "์ด ํ‚ค๋Š” ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์™„๋ฃŒ ํ›„ ์ €์žฅ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค", 1323 - "label": "ํšŒ์ „ ํ‚ค", 1324 - "placeholder": "๊ฐœ์ธ ํ‚ค ์ž…๋ ฅ (hex, base58 ๋˜๋Š” JWK)", 1325 - "hint": "DID ๋ฌธ์„œ์˜ ํšŒ์ „ ํ‚ค ์ค‘ ํ•˜๋‚˜์— ํ•ด๋‹นํ•˜๋Š” ๊ฐœ์ธ ํ‚ค", 1326 - "valid": "ํ‚ค๊ฐ€ ์œ ํšจํ•˜๊ณ  DID์˜ ํšŒ์ „ ํ‚ค์™€ ์ผ์น˜ํ•ฉ๋‹ˆ๋‹ค", 1327 - "invalid": "ํ‚ค๊ฐ€ DID ๋ฌธ์„œ์˜ ์–ด๋–ค ํšŒ์ „ ํ‚ค์™€๋„ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค", 1328 - "validating": "ํ‚ค ๊ฒ€์ฆ ์ค‘...", 1329 - "validate": "ํ‚ค ๊ฒ€์ฆ" 1259 + "targetPds": { 1260 + "title": "๋Œ€์ƒ PDS ์„ ํƒ", 1261 + "desc": "๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ํ•  PDS์˜ URL์„ ์ž…๋ ฅํ•˜์„ธ์š”.", 1262 + "url": "PDS URL", 1263 + "urlPlaceholder": "https://pds.example.com", 1264 + "validate": "ํ™•์ธ ๋ฐ ๊ณ„์†", 1265 + "validating": "ํ™•์ธ ์ค‘...", 1266 + "connected": "{name}์— ์—ฐ๊ฒฐ๋จ", 1267 + "inviteRequired": "์ดˆ๋Œ€ ์ฝ”๋“œ ํ•„์š”", 1268 + "privacyPolicy": "๊ฐœ์ธ์ •๋ณด ์ฒ˜๋ฆฌ๋ฐฉ์นจ", 1269 + "termsOfService": "์„œ๋น„์Šค ์•ฝ๊ด€" 1330 1270 }, 1331 - "chooseHandle": { 1332 - "migratingDid": "DID ๋ณต์› ์ค‘" 1271 + "newAccount": { 1272 + "title": "์ƒˆ ๊ณ„์ • ์„ธ๋ถ€ ์ •๋ณด", 1273 + "desc": "์ƒˆ PDS์—์„œ ๊ณ„์ •์„ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค.", 1274 + "handle": "ํ•ธ๋“ค", 1275 + "availableDomains": "์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ๋„๋ฉ”์ธ", 1276 + "email": "์ด๋ฉ”์ผ", 1277 + "password": "๋น„๋ฐ€๋ฒˆํ˜ธ", 1278 + "confirmPassword": "๋น„๋ฐ€๋ฒˆํ˜ธ ํ™•์ธ", 1279 + "inviteCode": "์ดˆ๋Œ€ ์ฝ”๋“œ" 1333 1280 }, 1334 1281 "review": { 1335 - "desc": "์˜คํ”„๋ผ์ธ ๋ณต์› ์„ธ๋ถ€ ์ •๋ณด๋ฅผ ํ™•์ธํ•˜์„ธ์š”.", 1336 - "carFile": "CAR ํŒŒ์ผ", 1337 - "rotationKey": "ํšŒ์ „ ํ‚ค", 1338 - "warning": "๋ณต์›์„ ์‹œ์ž‘ํ•˜๋ฉด ์•„์ด๋ดํ‹ฐํ‹ฐ๊ฐ€ ์ด PDS๋ฅผ ๊ฐ€๋ฆฌํ‚ค๋„๋ก ์—…๋ฐ์ดํŠธ๋ฉ๋‹ˆ๋‹ค. ์ด๊ฒƒ์€ ์‰ฝ๊ฒŒ ๋˜๋Œ๋ฆด ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", 1339 - "plcWarningTitle": "๋˜๋Œ๋ฆด ์ˆ˜ ์—†๋Š” ์ง€์ ", 1340 - "plcWarning": "์‹œ์ž‘ํ•˜๋ฉด DID ๋ฌธ์„œ๊ฐ€ ์ด PDS๋ฅผ ๊ฐ€๋ฆฌํ‚ค๋„๋ก ์—…๋ฐ์ดํŠธ๋ฉ๋‹ˆ๋‹ค. ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉด ํšŒ์ „ ํ‚ค๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ๋ณต๊ตฌํ•  ์ˆ˜ ์žˆ์ง€๋งŒ, ์†์ƒ๋œ ์•„์ด๋ดํ‹ฐํ‹ฐ ์ƒํƒœ๋ฅผ ํ”ผํ•˜๋ ค๋ฉด ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜์„ ์™„๋ฃŒํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค." 1282 + "title": "๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ๊ฒ€ํ† ", 1283 + "desc": "๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์„ธ๋ถ€ ์ •๋ณด๋ฅผ ๊ฒ€ํ† ํ•˜๊ณ  ํ™•์ธํ•˜์„ธ์š”.", 1284 + "currentHandle": "ํ˜„์žฌ ํ•ธ๋“ค", 1285 + "newHandle": "์ƒˆ ํ•ธ๋“ค", 1286 + "sourcePds": "์ด PDS", 1287 + "targetPds": "๋Œ€์ƒ PDS", 1288 + "confirm": "๊ณ„์ • ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜์„ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค", 1289 + "startMigration": "๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์‹œ์ž‘" 1341 1290 }, 1342 1291 "migrating": { 1343 - "title": "๊ณ„์ • ๋ณต์› ์ค‘", 1344 - "desc": "๊ณ„์ •์„ ๋ณต์›ํ•˜๋Š” ์ค‘์ž…๋‹ˆ๋‹ค...", 1345 - "creating": "๊ณ„์ • ์ƒ์„ฑ ์ค‘", 1346 - "importing": "์ €์žฅ์†Œ ๊ฐ€์ ธ์˜ค๋Š” ์ค‘", 1347 - "plcSigning": "์•„์ด๋ดํ‹ฐํ‹ฐ ์—…๋ฐ์ดํŠธ ์ค‘", 1348 - "activating": "๊ณ„์ • ํ™œ์„ฑํ™” ์ค‘" 1292 + "title": "๊ณ„์ • ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์ค‘", 1293 + "desc": "๋ฐ์ดํ„ฐ๋ฅผ ์ „์†กํ•˜๋Š” ์ค‘์ž…๋‹ˆ๋‹ค..." 1349 1294 }, 1350 - "success": { 1351 - "desc": "๊ณ„์ •์ด ์ด PDS์— ์„ฑ๊ณต์ ์œผ๋กœ ๋ณต์›๋˜์—ˆ์Šต๋‹ˆ๋‹ค." 1295 + "plcToken": { 1296 + "title": "์‹ ์› ํ™•์ธ", 1297 + "desc": "์ด๋ฉ”์ผ๋กœ ์ธ์ฆ ์ฝ”๋“œ๊ฐ€ ์ „์†ก๋˜์—ˆ์Šต๋‹ˆ๋‹ค." 1352 1298 }, 1353 - "blobs": { 1354 - "title": "Blob ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์ค‘", 1355 - "desc": "์ด์ „ PDS์—์„œ ์ด๋ฏธ์ง€์™€ ๋ฏธ๋””์–ด๋ฅผ ๋ณต๊ตฌํ•˜๋Š” ์ค‘...", 1356 - "migrating": "Blob ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์ค‘", 1357 - "failedTitle": "์ผ๋ถ€ Blob์„ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ํ•  ์ˆ˜ ์—†์Œ", 1358 - "failedDesc": "{count}๊ฐœ์˜ Blob์„ ์ด์ „ PDS์—์„œ ๊ฐ€์ ธ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ์„œ๋ฒ„์— ์—ฐ๊ฒฐํ•  ์ˆ˜ ์—†๊ฑฐ๋‚˜ ํŒŒ์ผ์ด ์‚ญ์ œ๋˜์—ˆ์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.", 1359 - "sourceUnreachableTitle": "์›๋ณธ PDS์— ์—ฐ๊ฒฐํ•  ์ˆ˜ ์—†์Œ", 1360 - "sourceUnreachable": "์ด์ „ PDS์— ์—ฐ๊ฒฐํ•˜์—ฌ ๋ฏธ๋””์–ด ํŒŒ์ผ์„ ๊ฐ€์ ธ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ์ข…๋ฃŒ๋œ ์„œ๋ฒ„์—์„œ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ํ•  ๋•Œ ํ”ํžˆ ๋ฐœ์ƒํ•ฉ๋‹ˆ๋‹ค. ๊ฒŒ์‹œ๋ฌผ์€ ์ž‘๋™ํ•˜์ง€๋งŒ ์ผ๋ถ€ ์ด๋ฏธ์ง€๊ฐ€ ๋ˆ„๋ฝ๋  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค." 1299 + "finalizing": { 1300 + "title": "๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์™„๋ฃŒ ์ค‘", 1301 + "desc": "๋งˆ์ด๊ทธ๋ ˆ์ด์…˜์„ ์™„๋ฃŒํ•˜๋Š” ์ค‘์ž…๋‹ˆ๋‹ค...", 1302 + "updatingForwarding": "DID ๋ฌธ์„œ ํฌ์›Œ๋”ฉ ์—…๋ฐ์ดํŠธ ์ค‘..." 1303 + }, 1304 + "success": { 1305 + "title": "๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์™„๋ฃŒ!", 1306 + "desc": "๊ณ„์ •์ด ์ƒˆ PDS๋กœ ์„ฑ๊ณต์ ์œผ๋กœ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜๋˜์—ˆ์Šต๋‹ˆ๋‹ค.", 1307 + "newHandle": "์ƒˆ ํ•ธ๋“ค", 1308 + "newPds": "์ƒˆ PDS", 1309 + "nextSteps": "๋‹ค์Œ ๋‹จ๊ณ„", 1310 + "nextSteps1": "์ƒˆ PDS์— ๋กœ๊ทธ์ธ", 1311 + "nextSteps2": "์ƒˆ ์ธ์ฆ ์ •๋ณด๋กœ ์•ฑ ์—…๋ฐ์ดํŠธ", 1312 + "nextSteps3": "ํŒ”๋กœ์›Œ๊ฐ€ ์ž๋™์œผ๋กœ ์ƒˆ ์œ„์น˜๋ฅผ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค", 1313 + "loggingOut": "{seconds}์ดˆ ํ›„ ๋กœ๊ทธ์•„์›ƒ๋ฉ๋‹ˆ๋‹ค..." 1361 1314 } 1362 1315 }, 1363 1316 "progress": {
+100 -147
frontend/src/locales/sv.json
··· 17 17 "dashboard": "Kontrollpanel", 18 18 "backToDashboard": "โ† Kontrollpanel", 19 19 "copied": "Kopierat!", 20 - "copyToClipboard": "Kopiera", 21 - "verifying": "Verifierar...", 22 - "saving": "Sparar...", 23 - "creating": "Skapar...", 24 - "updating": "Uppdaterar...", 25 - "sending": "Skickar...", 26 - "authenticating": "Autentiserar...", 27 - "checking": "Kontrollerar...", 28 - "redirecting": "Omdirigerar...", 29 - "signIn": "Logga in", 30 - "verify": "Verifiera", 31 - "remove": "Ta bort", 32 - "revoke": "ร…terkalla", 33 - "resendCode": "Skicka kod igen", 34 - "startOver": "Bรถrja om", 35 - "tryAgain": "Fรถrsรถk igen", 36 - "password": "Lรถsenord", 37 - "email": "E-post", 38 - "emailAddress": "E-postadress", 39 - "handle": "Anvรคndarnamn", 40 - "did": "DID", 41 - "verificationCode": "Verifieringskod", 42 - "inviteCode": "Inbjudningskod", 43 - "newPassword": "Nytt lรถsenord", 44 - "confirmPassword": "Bekrรคfta lรถsenord", 45 - "enterSixDigitCode": "Ange 6-siffrig kod", 46 - "passwordHint": "Minst 8 tecken", 47 - "enterPassword": "Ange ditt lรถsenord", 48 - "emailPlaceholder": "du@exempel.se", 49 - "verified": "Verifierad", 50 - "disabled": "Inaktiverad", 51 - "available": "Tillgรคnglig", 52 - "deactivated": "Avaktiverad", 53 - "unverified": "Overifierad", 54 - "backToLogin": "Tillbaka till inloggning", 55 - "backToSettings": "Tillbaka till instรคllningar", 56 - "alreadyHaveAccount": "Har du redan ett konto?", 57 - "createAccount": "Skapa konto", 58 - "passwordsMismatch": "Lรถsenorden matchar inte", 59 - "passwordTooShort": "Lรถsenordet mรฅste vara minst 8 tecken" 20 + "copyToClipboard": "Kopiera" 60 21 }, 61 22 "login": { 62 23 "title": "Logga in", ··· 88 49 "codeLabel": "Verifieringskod", 89 50 "codePlaceholder": "Ange 6-siffrig kod", 90 51 "verifyButton": "Verifiera konto", 91 - "resent": "Verifieringskod skickad igen!" 52 + "verifying": "Verifierar...", 53 + "resendButton": "Skicka kod igen", 54 + "resending": "Skickar igen...", 55 + "resent": "Verifieringskod skickad igen!", 56 + "backToLogin": "Tillbaka till inloggning" 92 57 }, 93 58 "register": { 94 59 "title": "Skapa konto", ··· 159 124 "inviteCodePlaceholder": "Ange din inbjudningskod", 160 125 "inviteCodeRequired": "krรคvs", 161 126 "createButton": "Skapa konto", 127 + "creating": "Skapar konto...", 162 128 "alreadyHaveAccount": "Har du redan ett konto?", 163 129 "signIn": "Logga in", 164 130 "wantPasswordless": "Vill du ha lรถsenordsfri sรคkerhet?", ··· 213 179 "navAdminDesc": "Serverstatistik och administratรถrsoperationer", 214 180 "navDidDocument": "DID-dokument", 215 181 "navDidDocumentDesc": "Hantera ditt DID-dokument och nycklar", 216 - "navDidDocumentDescActive": "Redigera dina DID-dokumentinstรคllningar", 217 - "navBackup": "Ladda ner sรคkerhetskopia", 218 - "navBackupDesc": "Ladda ner ditt datafรถrvar som en CAR-fil", 219 - "downloadingBackup": "Laddar ner...", 220 - "backupFailed": "Kunde inte ladda ner sรคkerhetskopia", 221 182 "migrated": "Flyttad", 222 183 "migratedTitle": "Konto flyttat", 223 184 "migratedMessage": "Ditt konto har flyttats till {pds}. Ditt DID-dokument finns fortfarande hรคr.", ··· 247 208 "serviceEndpointDesc": "PDS som fรถr nรคrvarande lagrar din kontodata. Uppdatera detta vid migrering.", 248 209 "currentPds": "Nuvarande PDS-URL", 249 210 "save": "Spara รคndringar", 211 + "saving": "Sparar...", 250 212 "success": "DID-dokumentet har uppdaterats", 251 213 "saveFailed": "Kunde inte spara DID-dokument", 252 214 "loadFailed": "Kunde inte ladda DID-dokument", ··· 284 246 "yourDomain": "Din domรคn", 285 247 "yourDomainPlaceholder": "exempel.se", 286 248 "verifyAndUpdate": "Verifiera och uppdatera anvรคndarnamn", 249 + "verifying": "Verifierar...", 287 250 "newHandle": "Nytt anvรคndarnamn", 288 251 "newHandlePlaceholder": "dittanvรคndarnamn", 289 252 "changeHandleButton": "ร„ndra anvรคndarnamn", ··· 299 262 "exportData": "Exportera data", 300 263 "exportDataDescription": "Ladda ner hela ditt arkiv som en CAR-fil (Content Addressable Archive). Detta inkluderar alla dina inlรคgg, gillanden, fรถljningar och annan data.", 301 264 "downloadRepo": "Ladda ner arkiv", 302 - "downloadBlobs": "Ladda ner media", 303 265 "exporting": "Exporterar...", 304 - "backups": { 305 - "title": "Sรคkerhetskopior", 306 - "description": "Hantera automatiska sรคkerhetskopior och รฅterstรคll din kontodata. Sรคkerhetskopior inkluderar alla poster och blobbar.", 307 - "enableAutomatic": "Automatiska sรคkerhetskopior", 308 - "enabled": "Aktiverad", 309 - "disabled": "Inaktiverad", 310 - "toggleFailed": "Kunde inte รคndra sรคkerhetskopieringsinstรคllning", 311 - "noBackups": "Inga sรคkerhetskopior รคnnu", 312 - "blocks": "block", 313 - "download": "Ladda ner", 314 - "delete": "Radera", 315 - "createNow": "Skapa sรคkerhetskopia nu", 316 - "created": "Sรคkerhetskopia skapad", 317 - "createFailed": "Kunde inte skapa sรคkerhetskopia", 318 - "downloadFailed": "Kunde inte ladda ner sรคkerhetskopia", 319 - "deleted": "Sรคkerhetskopia raderad", 320 - "deleteFailed": "Kunde inte radera sรคkerhetskopia", 321 - "restoreTitle": "ร…terstรคll frรฅn sรคkerhetskopia", 322 - "restoreDescription": "ร…terstรคll din kontodata frรฅn en tidigare exporterad CAR-fil. Detta ersรคtter ditt nuvarande datafรถrvar med den uppladdade sรคkerhetskopian.", 323 - "selectFile": "Vรคlj CAR-fil", 324 - "selectedFile": "Vald fil", 325 - "restore": "ร…terstรคll sรคkerhetskopia", 326 - "restoring": "ร…terstรคller...", 327 - "restored": "Sรคkerhetskopia รฅterstรคlld", 328 - "restoreFailed": "Kunde inte รฅterstรคlla sรคkerhetskopia" 329 - }, 330 266 "deleteAccount": "Radera konto", 331 267 "deleteWarning": "Denna รฅtgรคrd รคr oรฅterkallelig. All din data kommer att raderas permanent.", 332 268 "requestDeletion": "Begรคr kontoradering", ··· 355 291 "deleteConfirmation": "ร„r du helt sรคker pรฅ att du vill radera ditt konto? Detta kan inte รฅngras.", 356 292 "deletionFailed": "Kunde inte radera kontot", 357 293 "repoExported": "Arkiv exporterat", 358 - "blobsExported": "Mediafiler exporterade", 359 - "noBlobsToExport": "Inga mediafiler att exportera", 360 - "exportFailed": "Export misslyckades", 294 + "exportFailed": "Kunde inte exportera arkiv", 361 295 "confirmDelete": "ร„r du helt sรคker pรฅ att du vill radera ditt konto? Detta kan inte รฅngras." 362 296 } 363 297 }, ··· 372 306 "noPasswords": "Inga applรถsenord รคnnu", 373 307 "revoke": "ร…terkalla", 374 308 "revoking": "ร…terkallar...", 309 + "creating": "Skapar...", 375 310 "revokeConfirm": "ร…terkalla applรถsenord \"{name}\"? Appar som anvรคnder detta lรถsenord kommer inte lรคngre att kunna komma รฅt ditt konto.", 376 311 "saveWarningTitle": "Viktigt: Spara detta applรถsenord!", 377 312 "saveWarningMessage": "Detta lรถsenord krรคvs fรถr att logga in i appar som inte stรถder passkeys eller OAuth. Du ser det bara en gรฅng.", ··· 419 354 "used": "Anvรคnd av @{handle}", 420 355 "disabled": "Inaktiverad", 421 356 "usedBy": "Anvรคnd av", 357 + "creating": "Skapar...", 422 358 "disableConfirm": "Inaktivera denna inbjudningskod? Den kan inte lรคngre anvรคndas.", 423 359 "created": "Inbjudningskod skapad", 424 360 "copy": "Kopiera", ··· 546 482 "verifyButton": "Verifiera", 547 483 "verifyCodePlaceholder": "Ange verifieringskod", 548 484 "submit": "Skicka", 485 + "saving": "Sparar...", 549 486 "savePreferences": "Spara instรคllningar", 550 487 "preferencesSaved": "Kommunikationsinstรคllningar sparade", 551 488 "verifiedSuccess": "{channel} verifierad", ··· 584 521 "noCollectionsYet": "Inga samlingar รคnnu. Skapa din fรถrsta post fรถr att komma igรฅng.", 585 522 "loadMore": "Ladda fler", 586 523 "recordJson": "Post-JSON", 524 + "saving": "Sparar...", 587 525 "updateRecord": "Uppdatera post", 588 526 "collectionNsid": "Samling (NSID)", 589 527 "recordKeyOptional": "Postnyckel (valfri)", 590 528 "autoGenerated": "Genereras automatiskt om tom (TID)", 591 529 "autoGeneratedHint": "Lรคmna tom fรถr att automatiskt generera en TID-baserad nyckel", 530 + "creating": "Skapar...", 592 531 "demoPostText": "Hej frรฅn min PDS! Detta รคr mitt fรถrsta inlรคgg.", 593 532 "demoDisplayName": "Ditt visningsnamn", 594 533 "demoBio": "En kort presentation om dig sjรคlv." ··· 609 548 "primaryLight": "Primรคr (ljust lรคge)", 610 549 "primaryDark": "Primรคr (mรถrkt lรคge)", 611 550 "configSaved": "Serverkonfiguration sparad", 551 + "saving": "Sparar...", 612 552 "saveConfig": "Spara konfiguration", 613 553 "serverStats": "Serverstatistik", 614 554 "users": "Anvรคndare", ··· 699 639 "title": "Tvรฅfaktorsautentisering", 700 640 "subtitle": "Ytterligare verifiering krรคvs", 701 641 "usePasskey": "Anvรคnd nyckel", 702 - "useTotp": "Anvรคnd autentiseringsapp" 642 + "useTotp": "Anvรคnd autentiseringsapp", 643 + "verifying": "Verifierar..." 703 644 }, 704 645 "twoFactorCode": { 705 646 "title": "Tvรฅfaktorsautentisering", 706 647 "subtitle": "En verifieringskod har skickats till din {channel}. Ange koden nedan fรถr att fortsรคtta.", 707 648 "codeLabel": "Verifieringskod", 708 649 "codePlaceholder": "Ange 6-siffrig kod", 650 + "verify": "Verifiera", 651 + "verifying": "Verifierar...", 709 652 "errors": { 710 653 "missingRequestUri": "Saknar request_uri-parameter", 711 654 "verificationFailed": "Verifiering misslyckades", ··· 717 660 "title": "Ange autentiseringskod", 718 661 "subtitle": "Ange den 6-siffriga koden frรฅn din autentiseringsapp", 719 662 "codePlaceholder": "Ange 6-siffrig kod", 663 + "verify": "Verifiera", 664 + "verifying": "Verifierar...", 720 665 "useBackupCode": "Anvรคnd reservkod istรคllet", 721 666 "backupCodePlaceholder": "Ange reservkod", 722 667 "trustDevice": "Lita pรฅ denna enhet i 30 dagar", ··· 746 691 "codeLabel": "Verifieringskod", 747 692 "codeHelp": "Kopiera hela koden frรฅn ditt meddelande, inklusive bindestreck", 748 693 "verifyButton": "Verifiera konto", 694 + "verify": "Verifiera", 695 + "verifying": "Verifierar...", 749 696 "pleaseWait": "Vรคnta...", 697 + "sending": "Skickar...", 698 + "resendCode": "Skicka kod igen", 699 + "resending": "Skickar igen...", 750 700 "codeResent": "Verifieringskod skickad igen!", 751 701 "codeResentDetail": "Verifieringskod skickad! Kontrollera din inkorg.", 752 702 "verified": "Verifierad!", ··· 756 706 "identifierLabel": "E-post eller identifierare", 757 707 "identifierPlaceholder": "du@exempel.se", 758 708 "identifierHelp": "E-postadressen eller identifieraren koden skickades till", 709 + "backToLogin": "Tillbaka till inloggning", 759 710 "verifyingAccount": "Verifierar konto: @{handle}", 760 711 "startOver": "Bรถrja om med ett annat konto", 761 712 "noPending": "Ingen vรคntande verifiering hittades.", 762 713 "noPendingInfo": "Om du nyligen skapade ett konto och behรถver verifiera det kan du behรถva skapa ett nytt konto. Om du redan verifierat ditt konto kan du logga in.", 763 714 "createAccount": "Skapa konto", 764 715 "signIn": "Logga in", 716 + "backToSettings": "Tillbaka till instรคllningar", 765 717 "emailUpdateCodeHelp": "Koden skickades till din nuvarande e-postadress", 766 718 "emailUpdateFailed": "Kunde inte uppdatera e-postadress", 767 719 "emailUpdateRequiresAuth": "Du mรฅste vara inloggad fรถr att uppdatera din e-postadress.", ··· 794 746 "resetButton": "ร…terstรคll lรถsenord", 795 747 "resetting": "ร…terstรคller...", 796 748 "success": "Lรถsenord รฅterstรคllt!", 749 + "backToLogin": "Tillbaka till inloggning", 797 750 "requestNewCode": "Begรคr ny kod", 798 751 "passwordsMismatch": "Lรถsenorden matchar inte", 799 752 "passwordLength": "Lรถsenordet mรฅste vara minst 8 tecken" ··· 837 790 "howItWorks": "Sรฅ fungerar det", 838 791 "howItWorksDetail": "Vi skickar en sรคker lรคnk till din registrerade meddelandekanal. Klicka pรฅ lรคnken fรถr att stรคlla in ett tillfรคlligt lรถsenord. Sedan kan du logga in och lรคgga till en ny nyckel.", 839 792 "sendRecoveryLink": "Skicka รฅterstรคllningslรคnk", 840 - "sending": "Skickar..." 793 + "sending": "Skickar...", 794 + "backToLogin": "Tillbaka till inloggning" 841 795 }, 842 796 "registerPasskey": { 843 797 "title": "Skapa nyckelkonto", ··· 858 812 "externalDid": "Din did:web", 859 813 "externalDidPlaceholder": "did:web:dindomรคn.se", 860 814 "createButton": "Skapa konto", 815 + "creating": "Skapar...", 861 816 "alreadyHaveAccount": "Har du redan ett konto?", 862 817 "signIn": "Logga in", 863 818 "wantPassword": "Vill du anvรคnda ett lรถsenord?", ··· 956 911 "useTotp": "Anvรคnd autentiserare", 957 912 "passwordPlaceholder": "Ange ditt lรถsenord", 958 913 "totpPlaceholder": "Ange 6-siffrig kod", 914 + "verify": "Verifiera", 915 + "verifying": "Verifierar...", 959 916 "authenticating": "Autentiserar...", 960 917 "passkeyPrompt": "Klicka pรฅ knappen nedan fรถr att autentisera med din passkey.", 961 918 "cancel": "Avbryt" ··· 1028 985 "createAccount": "Skapa konto", 1029 986 "createDelegatedAccount": "Skapa delegerat konto", 1030 987 "createDelegatedAccountButton": "+ Skapa delegerat konto", 988 + "creating": "Skapar...", 1031 989 "emailOptional": "E-post (valfritt)", 1032 990 "failedToAddController": "Kunde inte lรคgga till kontrollant", 1033 991 "failedToCreateAccount": "Kunde inte skapa delegerat konto", ··· 1101 1059 "navDesc": "Flytta ditt konto till eller frรฅn en annan PDS", 1102 1060 "migrateHere": "Flytta hit", 1103 1061 "migrateHereDesc": "Flytta ditt befintliga AT Protocol-konto till denna PDS frรฅn en annan server.", 1062 + "migrateAway": "Flytta bort", 1063 + "migrateAwayDesc": "Flytta ditt konto frรฅn denna PDS till en annan server.", 1064 + "loginRequired": "Inloggning krรคvs", 1104 1065 "bringDid": "Ta med din DID och identitet", 1105 1066 "transferData": "ร–verfรถr all din data", 1106 1067 "keepFollowers": "Behรฅll dina fรถljare", 1068 + "exportRepo": "Exportera ditt arkiv", 1069 + "transferToPds": "ร–verfรถr till ny PDS", 1070 + "updateIdentity": "Uppdatera din identitet", 1107 1071 "whatIsMigration": "Vad รคr kontoflyttning?", 1108 1072 "whatIsMigrationDesc": "Kontoflyttning lรฅter dig flytta din AT Protocol-identitet mellan personliga dataservrar (PDS). Din DID (decentraliserad identifierare) fรถrblir densamma, sรฅ dina fรถljare och sociala kopplingar bevaras.", 1109 1073 "beforeMigrate": "Innan du flyttar", ··· 1113 1077 "beforeMigrate4": "Din gamla PDS kommer att meddelas om kontoinaktivering", 1114 1078 "importantWarning": "Kontoflyttning รคr en betydande รฅtgรคrd. Se till att du litar pรฅ mรฅl-PDS och fรถrstรฅr att din data kommer att flyttas. Om nรฅgot gรฅr fel kan manuell รฅterstรคllning krรคvas.", 1115 1079 "learnMore": "Lรคs mer om flyttningsrisker", 1116 - "offlineRestore": "Offline-รฅterstรคllning", 1117 - "offlineRestoreDesc": "ร…terstรคll frรฅn backup nรคr din gamla PDS inte รคr tillgรคnglig.", 1118 - "offlineFeature1": "Anvรคnd en CAR-fil backup", 1119 - "offlineFeature2": "Bevisa รคgande med rotationsnyckel", 1120 - "offlineFeature3": "ร…terstรคllning fรถr nedstรคngda servrar", 1080 + "comingSoon": "Kommer snart", 1121 1081 "oauthCompleting": "Slutfรถr autentisering...", 1122 1082 "oauthFailed": "Autentisering misslyckades", 1123 1083 "tryAgain": "Fรถrsรถk igen", ··· 1126 1086 "incomplete": "Du har en ofullstรคndig flytt pรฅgรฅende:", 1127 1087 "direction": "Riktning", 1128 1088 "migratingHere": "Flyttar hit", 1089 + "migratingAway": "Flyttar bort", 1129 1090 "from": "Frรฅn", 1130 1091 "to": "Till", 1131 1092 "progress": "Framsteg", ··· 1268 1229 "error": { 1269 1230 "title": "Flyttfel", 1270 1231 "desc": "Ett fel uppstod under flytten.", 1271 - "startOver": "Bรถrja om", 1272 - "unknown": "Ett okรคnt fel uppstod." 1232 + "startOver": "Bรถrja om" 1273 1233 }, 1274 1234 "common": { 1275 1235 "back": "Tillbaka", ··· 1287 1247 "warning3": "Ditt gamla konto kommer att inaktiveras efter flytten" 1288 1248 } 1289 1249 }, 1290 - "offline": { 1250 + "outbound": { 1291 1251 "welcome": { 1292 - "title": "ร…terstรคll frรฅn backup", 1293 - "desc": "ร…terstรคll ditt konto med en CAR-fil backup och rotationsnyckel. Anvรคnd detta nรคr din tidigare PDS inte รคr tillgรคnglig.", 1294 - "warningTitle": "Nรคr du ska anvรคnda denna metod", 1295 - "warningDesc": "Denna offline-รฅterstรคllning รคr fรถr katastrofรฅterstรคllning nรคr din gamla PDS har stรคngts ner, รคr oรฅtkomlig eller du blev utelรฅst. Om din gamla PDS fortfarande รคr tillgรคnglig, anvรคnd standardflytten istรคllet.", 1296 - "requirementsTitle": "Du behรถver", 1297 - "requirement1": "En CAR-fil backup av ditt arkiv", 1298 - "requirement2": "Din rotationsnyckel (privat nyckel fรถr ditt DID)", 1299 - "requirement3": "Ditt DID (did:plc:xxx)", 1300 - "understand": "Jag fรถrstรฅr och vill fortsรคtta" 1301 - }, 1302 - "provideDid": { 1303 - "title": "Ange ditt DID", 1304 - "desc": "Ange DID fรถr kontot du vill รฅterstรคlla.", 1305 - "label": "Ditt DID", 1306 - "hint": "Din decentraliserade identifierare (t.ex. did:plc:abc123)" 1307 - }, 1308 - "uploadCar": { 1309 - "title": "Ladda upp CAR-fil", 1310 - "desc": "Ladda upp din arkiv-backupfil.", 1311 - "label": "CAR-fil", 1312 - "hint": "Vรคlj .car-filen frรฅn din backup", 1313 - "reuploadWarningTitle": "CAR-fil krรคvs", 1314 - "reuploadWarning": "Din session har รฅterstรคllts, men du mรฅste ladda upp din CAR-fil igen. Av sรคkerhetsskรคl lagras inte filinnehรฅll mellan sessioner." 1252 + "title": "Flytta frรฅn denna PDS", 1253 + "desc": "Flytta ditt konto till en annan personlig dataserver.", 1254 + "warning": "Efter flytten kommer ditt konto hรคr att inaktiveras.", 1255 + "didWebNotice": "did:web-flyttmeddelande", 1256 + "didWebNoticeDesc": "Ditt konto anvรคnder en did:web-identifierare ({did}). Efter flytten kommer denna PDS att fortsรคtta servera ditt DID-dokument som pekar till den nya PDS. Din identitet kommer att fungera sรฅ lรคnge denna server รคr online.", 1257 + "understand": "Jag fรถrstรฅr riskerna och vill fortsรคtta" 1315 1258 }, 1316 - "rotationKey": { 1317 - "title": "Ange rotationsnyckel", 1318 - "desc": "Ange din rotationsnyckel fรถr att bevisa รคgande av detta DID.", 1319 - "securityWarningTitle": "Sรคkerhetsvarning", 1320 - "securityWarning1": "Din rotationsnyckel รคr extremt kรคnslig - behandla den som ett huvudlรถsenord", 1321 - "securityWarning2": "Ange den endast pรฅ betrodda enheter och nรคtverk", 1322 - "securityWarning3": "Denna nyckel kommer inte att lagras efter att flytten slutfรถrts", 1323 - "label": "Rotationsnyckel", 1324 - "placeholder": "Ange privat nyckel (hex, base58 eller JWK)", 1325 - "hint": "Den privata nyckeln som motsvarar en av rotationsnycklarna i ditt DID-dokument", 1326 - "valid": "Nyckeln รคr giltig och matchar en rotationsnyckel i ditt DID", 1327 - "invalid": "Nyckeln matchar inte nรฅgon rotationsnyckel i ditt DID-dokument", 1328 - "validating": "Validerar nyckel...", 1329 - "validate": "Validera nyckel" 1259 + "targetPds": { 1260 + "title": "Vรคlj mรฅl-PDS", 1261 + "desc": "Ange URL:en fรถr PDS du vill flytta till.", 1262 + "url": "PDS URL", 1263 + "urlPlaceholder": "https://pds.example.com", 1264 + "validate": "Validera och fortsรคtt", 1265 + "validating": "Validerar...", 1266 + "connected": "Ansluten till {name}", 1267 + "inviteRequired": "Inbjudningskod krรคvs", 1268 + "privacyPolicy": "Integritetspolicy", 1269 + "termsOfService": "Anvรคndarvillkor" 1330 1270 }, 1331 - "chooseHandle": { 1332 - "migratingDid": "ร…terstรคller DID" 1271 + "newAccount": { 1272 + "title": "Nya kontouppgifter", 1273 + "desc": "Konfigurera ditt konto pรฅ den nya PDS.", 1274 + "handle": "Anvรคndarnamn", 1275 + "availableDomains": "Tillgรคngliga domรคner", 1276 + "email": "E-post", 1277 + "password": "Lรถsenord", 1278 + "confirmPassword": "Bekrรคfta lรถsenord", 1279 + "inviteCode": "Inbjudningskod" 1333 1280 }, 1334 1281 "review": { 1335 - "desc": "Granska dina offline-รฅterstรคllningsuppgifter.", 1336 - "carFile": "CAR-fil", 1337 - "rotationKey": "Rotationsnyckel", 1338 - "warning": "Nรคr du startar รฅterstรคllningen kommer din identitet att uppdateras fรถr att peka pรฅ denna PDS. Detta kan inte enkelt รฅngras.", 1339 - "plcWarningTitle": "Ingen รฅtervรคndo", 1340 - "plcWarning": "Nรคr du startar kommer ditt DID-dokument att uppdateras fรถr att peka pรฅ denna PDS. Om nรฅgot gรฅr fel kan du anvรคnda din rotationsnyckel fรถr att รฅterstรคlla, men du bรถr slutfรถra flytten fรถr att undvika ett trasigt identitetstillstรฅnd." 1282 + "title": "Granska flytt", 1283 + "desc": "Granska och bekrรคfta dina flyttdetaljer.", 1284 + "currentHandle": "Nuvarande anvรคndarnamn", 1285 + "newHandle": "Nytt anvรคndarnamn", 1286 + "sourcePds": "Denna PDS", 1287 + "targetPds": "Mรฅl-PDS", 1288 + "confirm": "Jag bekrรคftar att jag vill flytta mitt konto", 1289 + "startMigration": "Starta flytt" 1341 1290 }, 1342 1291 "migrating": { 1343 - "title": "ร…terstรคller konto", 1344 - "desc": "Vรคnta medan ditt konto รฅterstรคlls...", 1345 - "creating": "Skapar konto", 1346 - "importing": "Importerar arkiv", 1347 - "plcSigning": "Uppdaterar identitet", 1348 - "activating": "Aktiverar konto" 1292 + "title": "Flyttar ditt konto", 1293 + "desc": "Vรคnta medan vi รถverfรถr din data..." 1349 1294 }, 1350 - "success": { 1351 - "desc": "Ditt konto har framgรฅngsrikt รฅterstรคllts till denna PDS." 1295 + "plcToken": { 1296 + "title": "Verifiera din identitet", 1297 + "desc": "En verifieringskod har skickats till din e-post." 1352 1298 }, 1353 - "blobs": { 1354 - "title": "Flyttar blobbar", 1355 - "desc": "Fรถrsรถker รฅterstรคlla bilder och media frรฅn din gamla PDS...", 1356 - "migrating": "Flyttar blobbar", 1357 - "failedTitle": "Vissa blobbar kunde inte flyttas", 1358 - "failedDesc": "{count} blobbar kunde inte hรคmtas frรฅn din gamla PDS. Detta kan hรคnda om servern รคr otillgรคnglig eller om filerna raderades.", 1359 - "sourceUnreachableTitle": "Kรคll-PDS otillgรคnglig", 1360 - "sourceUnreachable": "Kunde inte ansluta till din gamla PDS fรถr att hรคmta mediafiler. Detta รคr vanligt vid flytt frรฅn en nedstรคngd server. Dina inlรคgg kommer att fungera, men vissa bilder kan saknas." 1299 + "finalizing": { 1300 + "title": "Slutfรถr flytt", 1301 + "desc": "Vรคnta medan vi slutfรถr flytten...", 1302 + "updatingForwarding": "Uppdaterar DID-dokumentvidarebefordran..." 1303 + }, 1304 + "success": { 1305 + "title": "Flytt klar!", 1306 + "desc": "Ditt konto har framgรฅngsrikt flyttats till din nya PDS.", 1307 + "newHandle": "Nytt anvรคndarnamn", 1308 + "newPds": "Ny PDS", 1309 + "nextSteps": "Nรคsta steg", 1310 + "nextSteps1": "Logga in pรฅ din nya PDS", 1311 + "nextSteps2": "Uppdatera dina appar med nya uppgifter", 1312 + "nextSteps3": "Dina fรถljare kommer automatiskt se din nya plats", 1313 + "loggingOut": "Loggar ut om {seconds} sekunder..." 1361 1314 } 1362 1315 }, 1363 1316 "progress": {
+100 -147
frontend/src/locales/zh.json
··· 17 17 "dashboard": "ๆŽงๅˆถๅฐ", 18 18 "backToDashboard": "โ† ่ฟ”ๅ›žๆŽงๅˆถๅฐ", 19 19 "copied": "ๅทฒๅคๅˆถ๏ผ", 20 - "copyToClipboard": "ๅคๅˆถ", 21 - "verifying": "้ชŒ่ฏไธญ...", 22 - "saving": "ไฟๅญ˜ไธญ...", 23 - "creating": "ๅˆ›ๅปบไธญ...", 24 - "updating": "ๆ›ดๆ–ฐไธญ...", 25 - "sending": "ๅ‘้€ไธญ...", 26 - "authenticating": "่ฎค่ฏไธญ...", 27 - "checking": "ๆฃ€ๆŸฅไธญ...", 28 - "redirecting": "่ทณ่ฝฌไธญ...", 29 - "signIn": "็™ปๅฝ•", 30 - "verify": "้ชŒ่ฏ", 31 - "remove": "็งป้™ค", 32 - "revoke": "ๆ’ค้”€", 33 - "resendCode": "้‡ๆ–ฐๅ‘้€้ชŒ่ฏ็ ", 34 - "startOver": "้‡ๆ–ฐๅผ€ๅง‹", 35 - "tryAgain": "้‡่ฏ•", 36 - "password": "ๅฏ†็ ", 37 - "email": "้‚ฎ็ฎฑ", 38 - "emailAddress": "้‚ฎ็ฎฑๅœฐๅ€", 39 - "handle": "็”จๆˆทๅ", 40 - "did": "DID", 41 - "verificationCode": "้ชŒ่ฏ็ ", 42 - "inviteCode": "้‚€่ฏท็ ", 43 - "newPassword": "ๆ–ฐๅฏ†็ ", 44 - "confirmPassword": "็กฎ่ฎคๅฏ†็ ", 45 - "enterSixDigitCode": "่พ“ๅ…ฅ6ไฝ้ชŒ่ฏ็ ", 46 - "passwordHint": "่‡ณๅฐ‘8ไธชๅญ—็ฌฆ", 47 - "enterPassword": "่ฏท่พ“ๅ…ฅๅฏ†็ ", 48 - "emailPlaceholder": "you@example.com", 49 - "verified": "ๅทฒ้ชŒ่ฏ", 50 - "disabled": "ๅทฒ็ฆ็”จ", 51 - "available": "ๅฏ็”จ", 52 - "deactivated": "ๅทฒๅœ็”จ", 53 - "unverified": "ๆœช้ชŒ่ฏ", 54 - "backToLogin": "่ฟ”ๅ›ž็™ปๅฝ•", 55 - "backToSettings": "่ฟ”ๅ›ž่ฎพ็ฝฎ", 56 - "alreadyHaveAccount": "ๅทฒๆœ‰่ดฆๆˆท๏ผŸ", 57 - "createAccount": "็ซ‹ๅณๆณจๅ†Œ", 58 - "passwordsMismatch": "ๅฏ†็ ไธๅŒน้…", 59 - "passwordTooShort": "ๅฏ†็ ่‡ณๅฐ‘้œ€่ฆ8ไธชๅญ—็ฌฆ" 20 + "copyToClipboard": "ๅคๅˆถ" 60 21 }, 61 22 "login": { 62 23 "title": "็™ปๅฝ•", ··· 88 49 "codeLabel": "้ชŒ่ฏ็ ", 89 50 "codePlaceholder": "่พ“ๅ…ฅ6ไฝ้ชŒ่ฏ็ ", 90 51 "verifyButton": "้ชŒ่ฏ่ดฆๆˆท", 91 - "resent": "้ชŒ่ฏ็ ๅทฒ้‡ๆ–ฐๅ‘้€๏ผ" 52 + "verifying": "้ชŒ่ฏไธญ...", 53 + "resendButton": "้‡ๆ–ฐๅ‘้€้ชŒ่ฏ็ ", 54 + "resending": "ๅ‘้€ไธญ...", 55 + "resent": "้ชŒ่ฏ็ ๅทฒ้‡ๆ–ฐๅ‘้€๏ผ", 56 + "backToLogin": "่ฟ”ๅ›ž็™ปๅฝ•" 92 57 }, 93 58 "register": { 94 59 "title": "ๅˆ›ๅปบ่ดฆๆˆท", ··· 159 124 "inviteCodePlaceholder": "่พ“ๅ…ฅๆ‚จ็š„้‚€่ฏท็ ", 160 125 "inviteCodeRequired": "ๅฟ…ๅกซ", 161 126 "createButton": "ๅˆ›ๅปบ่ดฆๆˆท", 127 + "creating": "ๆญฃๅœจๅˆ›ๅปบ...", 162 128 "alreadyHaveAccount": "ๅทฒๆœ‰่ดฆๆˆท๏ผŸ", 163 129 "signIn": "็ซ‹ๅณ็™ปๅฝ•", 164 130 "wantPasswordless": "ๆƒณ่ฆๆ— ๅฏ†็ ็™ปๅฝ•๏ผŸ", ··· 213 179 "navAdminDesc": "ๆœๅŠกๅ™จ็ปŸ่ฎกๅ’Œ็ฎก็†ๆ“ไฝœ", 214 180 "navDidDocument": "DID ๆ–‡ๆกฃ", 215 181 "navDidDocumentDesc": "็ฎก็†ๆ‚จ็š„ DID ๆ–‡ๆกฃๅ’Œๅฏ†้’ฅ", 216 - "navDidDocumentDescActive": "็ผ–่พ‘ๆ‚จ็š„ DID ๆ–‡ๆกฃ่ฎพ็ฝฎ", 217 - "navBackup": "ไธ‹่ฝฝๅค‡ไปฝ", 218 - "navBackupDesc": "ๅฐ†ๆ‚จ็š„ๅญ˜ๅ‚จๅบ“ไธ‹่ฝฝไธบ CAR ๆ–‡ไปถ", 219 - "downloadingBackup": "ไธ‹่ฝฝไธญ...", 220 - "backupFailed": "ไธ‹่ฝฝๅค‡ไปฝๅคฑ่ดฅ", 221 182 "migrated": "ๅทฒ่ฟ็งป", 222 183 "migratedTitle": "่ดฆๆˆทๅทฒ่ฟ็งป", 223 184 "migratedMessage": "ๆ‚จ็š„่ดฆๆˆทๅทฒ่ฟ็งปๅˆฐ {pds}ใ€‚ๆ‚จ็š„ DID ๆ–‡ๆกฃไปๅœจๆญคๅค„ๆ‰˜็ฎกใ€‚", ··· 247 208 "serviceEndpointDesc": "ๅฝ“ๅ‰ๆ‰˜็ฎกๆ‚จ่ดฆๆˆทๆ•ฐๆฎ็š„ PDSใ€‚่ฟ็งปๆ—ถ่ฏทๆ›ดๆ–ฐๆญค้กนใ€‚", 248 209 "currentPds": "ๅฝ“ๅ‰ PDS URL", 249 210 "save": "ไฟๅญ˜ๆ›ดๆ”น", 211 + "saving": "ไฟๅญ˜ไธญ...", 250 212 "success": "DID ๆ–‡ๆกฃๅทฒๆ›ดๆ–ฐ", 251 213 "saveFailed": "ไฟๅญ˜ DID ๆ–‡ๆกฃๅคฑ่ดฅ", 252 214 "loadFailed": "ๅŠ ่ฝฝ DID ๆ–‡ๆกฃๅคฑ่ดฅ", ··· 284 246 "yourDomain": "ๆ‚จ็š„ๅŸŸๅ", 285 247 "yourDomainPlaceholder": "example.com", 286 248 "verifyAndUpdate": "้ชŒ่ฏๅนถๆ›ดๆ–ฐ็”จๆˆทๅ", 249 + "verifying": "้ชŒ่ฏไธญ...", 287 250 "newHandle": "ๆ–ฐ็”จๆˆทๅ", 288 251 "newHandlePlaceholder": "yourhandle", 289 252 "changeHandleButton": "ๆ›ดๆ”น็”จๆˆทๅ", ··· 299 262 "exportData": "ๅฏผๅ‡บๆ•ฐๆฎ", 300 263 "exportDataDescription": "ๅฐ†ๆ‚จ็š„ๆ‰€ๆœ‰ๆ•ฐๆฎไธ‹่ฝฝไธบ CAR ๆ–‡ไปถใ€‚ๅŒ…ๆ‹ฌๆ‚จ็š„ๆ‰€ๆœ‰ๅธ–ๅญใ€็‚น่ตžใ€ๅ…ณๆณจ็ญ‰ๆ•ฐๆฎใ€‚", 301 264 "downloadRepo": "ไธ‹่ฝฝๆ•ฐๆฎ", 302 - "downloadBlobs": "ไธ‹่ฝฝๅช’ไฝ“ๆ–‡ไปถ", 303 265 "exporting": "ๅฏผๅ‡บไธญ...", 304 - "backups": { 305 - "title": "ๅค‡ไปฝ", 306 - "description": "็ฎก็†่‡ชๅŠจๅค‡ไปฝๅนถๆขๅค่ดฆๆˆทๆ•ฐๆฎใ€‚ๅค‡ไปฝๅŒ…ๆ‹ฌๆ‰€ๆœ‰่ฎฐๅฝ•ๅ’Œๆ–‡ไปถใ€‚", 307 - "enableAutomatic": "่‡ชๅŠจๅค‡ไปฝ", 308 - "enabled": "ๅทฒๅฏ็”จ", 309 - "disabled": "ๅทฒ็ฆ็”จ", 310 - "toggleFailed": "ๆ›ดๆ”นๅค‡ไปฝ่ฎพ็ฝฎๅคฑ่ดฅ", 311 - "noBackups": "ๆš‚ๆ— ๅค‡ไปฝ", 312 - "blocks": "ๅ—", 313 - "download": "ไธ‹่ฝฝ", 314 - "delete": "ๅˆ ้™ค", 315 - "createNow": "็ซ‹ๅณๅˆ›ๅปบๅค‡ไปฝ", 316 - "created": "ๅค‡ไปฝๅทฒๅˆ›ๅปบ", 317 - "createFailed": "ๅˆ›ๅปบๅค‡ไปฝๅคฑ่ดฅ", 318 - "downloadFailed": "ไธ‹่ฝฝๅค‡ไปฝๅคฑ่ดฅ", 319 - "deleted": "ๅค‡ไปฝๅทฒๅˆ ้™ค", 320 - "deleteFailed": "ๅˆ ้™คๅค‡ไปฝๅคฑ่ดฅ", 321 - "restoreTitle": "ไปŽๅค‡ไปฝๆขๅค", 322 - "restoreDescription": "ไปŽไน‹ๅ‰ๅฏผๅ‡บ็š„ CAR ๆ–‡ไปถๆขๅค่ดฆๆˆทๆ•ฐๆฎใ€‚่ฟ™ๅฐ†็”จไธŠไผ ็š„ๅค‡ไปฝๆ›ฟๆขๅฝ“ๅ‰็š„ๅญ˜ๅ‚จๅบ“ใ€‚", 323 - "selectFile": "้€‰ๆ‹ฉ CAR ๆ–‡ไปถ", 324 - "selectedFile": "ๅทฒ้€‰ๆ–‡ไปถ", 325 - "restore": "ๆขๅคๅค‡ไปฝ", 326 - "restoring": "ๆขๅคไธญ...", 327 - "restored": "ๅค‡ไปฝๆขๅคๆˆๅŠŸ", 328 - "restoreFailed": "ๅค‡ไปฝๆขๅคๅคฑ่ดฅ" 329 - }, 330 266 "deleteAccount": "ๅˆ ้™ค่ดฆๆˆท", 331 267 "deleteWarning": "ๆญคๆ“ไฝœไธๅฏ้€†ใ€‚ๆ‚จ็š„ๆ‰€ๆœ‰ๆ•ฐๆฎๅฐ†่ขซๆฐธไน…ๅˆ ้™คใ€‚", 332 268 "requestDeletion": "่ฏทๆฑ‚ๅˆ ้™ค่ดฆๆˆท", ··· 355 291 "deleteConfirmation": "ๆ‚จ็กฎๅฎš่ฆๅˆ ้™ค่ดฆๆˆทๅ—๏ผŸๆญคๆ“ไฝœๆ— ๆณ•ๆ’ค้”€ใ€‚", 356 292 "deletionFailed": "่ดฆๆˆทๅˆ ้™คๅคฑ่ดฅ", 357 293 "repoExported": "ๆ•ฐๆฎๅฏผๅ‡บๆˆๅŠŸ", 358 - "blobsExported": "ๅช’ไฝ“ๆ–‡ไปถๅฏผๅ‡บๆˆๅŠŸ", 359 - "noBlobsToExport": "ๆฒกๆœ‰ๅฏๅฏผๅ‡บ็š„ๅช’ไฝ“ๆ–‡ไปถ", 360 - "exportFailed": "ๅฏผๅ‡บๅคฑ่ดฅ", 294 + "exportFailed": "ๆ•ฐๆฎๅฏผๅ‡บๅคฑ่ดฅ", 361 295 "confirmDelete": "ๆ‚จ็กฎๅฎš่ฆๅˆ ้™ค่ดฆๆˆทๅ—๏ผŸๆญคๆ“ไฝœๆ— ๆณ•ๆ’ค้”€ใ€‚" 362 296 } 363 297 }, ··· 372 306 "noPasswords": "ๆš‚ๆ— ๅบ”็”จไธ“็”จๅฏ†็ ", 373 307 "revoke": "ๆ’ค้”€", 374 308 "revoking": "ๆ’ค้”€ไธญ...", 309 + "creating": "ๅˆ›ๅปบไธญ...", 375 310 "revokeConfirm": "ๆ’ค้”€ใ€Œ{name}ใ€็š„ๅฏ†็ ๏ผŸไฝฟ็”จๆญคๅฏ†็ ็š„ๅบ”็”จๅฐ†ๆ— ๆณ•ๅ†่ฎฟ้—ฎๆ‚จ็š„่ดฆๆˆทใ€‚", 376 311 "saveWarningTitle": "้‡่ฆ๏ผš่ฏทไฟๅญ˜ๆญคๅบ”็”จไธ“็”จๅฏ†็ ๏ผ", 377 312 "saveWarningMessage": "ๆญคๅฏ†็ ็”จไบŽ็™ปๅฝ•ไธๆ”ฏๆŒ้€š่กŒๅฏ†้’ฅๆˆ– OAuth ็š„ๅบ”็”จใ€‚ๆ‚จๅช่ƒฝ็œ‹ๅˆฐไธ€ๆฌกใ€‚", ··· 419 354 "used": "ๅทฒ่ขซ @{handle} ไฝฟ็”จ", 420 355 "disabled": "ๅทฒ็ฆ็”จ", 421 356 "usedBy": "ไฝฟ็”จ่€…", 357 + "creating": "ๅˆ›ๅปบไธญ...", 422 358 "disableConfirm": "็ฆ็”จๆญค้‚€่ฏท็ ๏ผŸๅฎƒๅฐ†ๆ— ๆณ•ๅ†่ขซไฝฟ็”จใ€‚", 423 359 "created": "้‚€่ฏท็ ๅทฒๅˆ›ๅปบ", 424 360 "copy": "ๅคๅˆถ", ··· 546 482 "verifyButton": "้ชŒ่ฏ", 547 483 "verifyCodePlaceholder": "่พ“ๅ…ฅ้ชŒ่ฏ็ ", 548 484 "submit": "ๆไบค", 485 + "saving": "ไฟๅญ˜ไธญ...", 549 486 "savePreferences": "ไฟๅญ˜ๅๅฅฝ่ฎพ็ฝฎ", 550 487 "preferencesSaved": "้€š่ฎฏๅๅฅฝๅทฒไฟๅญ˜", 551 488 "verifiedSuccess": "{channel} ้ชŒ่ฏๆˆๅŠŸ", ··· 584 521 "noCollectionsYet": "ๆš‚ๆ— ้›†ๅˆใ€‚ๅˆ›ๅปบๆ‚จ็š„็ฌฌไธ€ๆก่ฎฐๅฝ•ๅผ€ๅง‹ไฝฟ็”จใ€‚", 585 522 "loadMore": "ๅŠ ่ฝฝๆ›ดๅคš", 586 523 "recordJson": "่ฎฐๅฝ• JSON", 524 + "saving": "ไฟๅญ˜ไธญ...", 587 525 "updateRecord": "ๆ›ดๆ–ฐ่ฎฐๅฝ•", 588 526 "collectionNsid": "้›†ๅˆ (NSID)", 589 527 "recordKeyOptional": "่ฎฐๅฝ•้”ฎ๏ผˆๅฏ้€‰๏ผ‰", 590 528 "autoGenerated": "็•™็ฉบ่‡ชๅŠจ็”Ÿๆˆ (TID)", 591 529 "autoGeneratedHint": "็•™็ฉบๅฐ†่‡ชๅŠจ็”ŸๆˆๅŸบไบŽ TID ็š„้”ฎ", 530 + "creating": "ๅˆ›ๅปบไธญ...", 592 531 "demoPostText": "ไฝ ๅฅฝ๏ผŒ่ฟ™ๆ˜ฏๆˆ‘็š„็ฌฌไธ€ๆกๅธ–ๅญ๏ผๆฅ่‡ชๆˆ‘็š„ PDSใ€‚", 593 532 "demoDisplayName": "ไฝ ็š„ๆ˜พ็คบๅ็งฐ", 594 533 "demoBio": "ๅ†™ไธ€ๆฎต็ฎ€็Ÿญ็š„่‡ชๆˆ‘ไป‹็ปใ€‚" ··· 612 551 "secondaryLight": "ๅ‰ฏ่‰ฒ๏ผˆๆต…่‰ฒๆจกๅผ๏ผ‰", 613 552 "secondaryDark": "ๅ‰ฏ่‰ฒ๏ผˆๆทฑ่‰ฒๆจกๅผ๏ผ‰", 614 553 "configSaved": "ๆœๅŠกๅ™จ้…็ฝฎๅทฒไฟๅญ˜", 554 + "saving": "ไฟๅญ˜ไธญ...", 615 555 "saveConfig": "ไฟๅญ˜้…็ฝฎ", 616 556 "serverStats": "ๆœๅŠกๅ™จ็ปŸ่ฎก", 617 557 "users": "็”จๆˆท", ··· 699 639 "title": "ๅŒ้‡่บซไปฝ้ชŒ่ฏ", 700 640 "subtitle": "้œ€่ฆ้ขๅค–้ชŒ่ฏ", 701 641 "usePasskey": "ไฝฟ็”จ้€š่กŒๅฏ†้’ฅ", 702 - "useTotp": "ไฝฟ็”จ่บซไปฝ้ชŒ่ฏๅ™จ" 642 + "useTotp": "ไฝฟ็”จ่บซไปฝ้ชŒ่ฏๅ™จ", 643 + "verifying": "้ชŒ่ฏไธญ..." 703 644 }, 704 645 "twoFactorCode": { 705 646 "title": "ๅŒ้‡่บซไปฝ้ชŒ่ฏ", 706 647 "subtitle": "้ชŒ่ฏ็ ๅทฒๅ‘้€ๅˆฐๆ‚จ็š„ {channel}ใ€‚่ฏทๅœจไธ‹ๆ–น่พ“ๅ…ฅ้ชŒ่ฏ็ ็ปง็ปญใ€‚", 707 648 "codeLabel": "้ชŒ่ฏ็ ", 708 649 "codePlaceholder": "่พ“ๅ…ฅ6ไฝ้ชŒ่ฏ็ ", 650 + "verify": "้ชŒ่ฏ", 651 + "verifying": "้ชŒ่ฏไธญ...", 709 652 "errors": { 710 653 "missingRequestUri": "็ผบๅฐ‘ request_uri ๅ‚ๆ•ฐ", 711 654 "verificationFailed": "้ชŒ่ฏๅคฑ่ดฅ", ··· 717 660 "title": "่พ“ๅ…ฅ้ชŒ่ฏ็ ", 718 661 "subtitle": "่ฏท่พ“ๅ…ฅ่บซไปฝ้ชŒ่ฏๅ™จๅบ”็”จไธญ็š„6ไฝ้ชŒ่ฏ็ ", 719 662 "codePlaceholder": "่พ“ๅ…ฅ6ไฝ้ชŒ่ฏ็ ", 663 + "verify": "้ชŒ่ฏ", 664 + "verifying": "้ชŒ่ฏไธญ...", 720 665 "useBackupCode": "ไฝฟ็”จๅค‡็”จ้ชŒ่ฏ็ ", 721 666 "backupCodePlaceholder": "่พ“ๅ…ฅๅค‡็”จ้ชŒ่ฏ็ ", 722 667 "trustDevice": "ไฟกไปปๆญค่ฎพๅค‡30ๅคฉ", ··· 746 691 "codeLabel": "้ชŒ่ฏ็ ", 747 692 "codeHelp": "ๅคๅˆถๆถˆๆฏไธญ็š„ๅฎŒๆ•ด้ชŒ่ฏ็ ๏ผŒๅŒ…ๆ‹ฌๆจช็บฟ", 748 693 "verifyButton": "้ชŒ่ฏ่ดฆๆˆท", 694 + "verify": "้ชŒ่ฏ", 695 + "verifying": "้ชŒ่ฏไธญ...", 749 696 "pleaseWait": "่ฏท็จๅ€™...", 697 + "resendCode": "้‡ๆ–ฐๅ‘้€้ชŒ่ฏ็ ", 698 + "resending": "ๅ‘้€ไธญ...", 699 + "sending": "ๅ‘้€ไธญ...", 750 700 "codeResent": "้ชŒ่ฏ็ ๅทฒ้‡ๆ–ฐๅ‘้€๏ผ", 751 701 "codeResentDetail": "้ชŒ่ฏ็ ๅทฒๅ‘้€๏ผ่ฏทๆŸฅๆ”ถใ€‚", 702 + "backToLogin": "่ฟ”ๅ›ž็™ปๅฝ•", 752 703 "verifyingAccount": "ๆญฃๅœจ้ชŒ่ฏ่ดฆๆˆท๏ผš@{handle}", 753 704 "startOver": "ไฝฟ็”จๅ…ถไป–่ดฆๆˆท้‡ๆ–ฐๅผ€ๅง‹", 754 705 "noPending": "ๆœชๆ‰พๅˆฐๅพ…้ชŒ่ฏ็š„่ดฆๆˆท", ··· 762 713 "identifierLabel": "้‚ฎ็ฎฑๆˆ–ๆ ‡่ฏ†็ฌฆ", 763 714 "identifierPlaceholder": "you@example.com", 764 715 "identifierHelp": "ๆŽฅๆ”ถ้ชŒ่ฏ็ ็š„้‚ฎ็ฎฑๅœฐๅ€ๆˆ–ๆ ‡่ฏ†็ฌฆ", 716 + "backToSettings": "่ฟ”ๅ›ž่ฎพ็ฝฎ", 765 717 "emailUpdateCodeHelp": "้ชŒ่ฏ็ ๅทฒๅ‘้€ๅˆฐๆ‚จๅฝ“ๅ‰็š„้‚ฎ็ฎฑๅœฐๅ€", 766 718 "emailUpdateFailed": "ๆ›ดๆ–ฐ้‚ฎ็ฎฑๅœฐๅ€ๅคฑ่ดฅ", 767 719 "emailUpdateRequiresAuth": "ๆ‚จ้œ€่ฆ็™ปๅฝ•ๆ‰่ƒฝๆ›ดๆ–ฐ้‚ฎ็ฎฑๅœฐๅ€ใ€‚", ··· 794 746 "resetButton": "้‡็ฝฎๅฏ†็ ", 795 747 "resetting": "้‡็ฝฎไธญ...", 796 748 "success": "ๅฏ†็ ้‡็ฝฎๆˆๅŠŸ๏ผ", 749 + "backToLogin": "่ฟ”ๅ›ž็™ปๅฝ•", 797 750 "requestNewCode": "้‡ๆ–ฐ่Žทๅ–้ชŒ่ฏ็ ", 798 751 "passwordsMismatch": "ไธคๆฌก่พ“ๅ…ฅ็š„ๅฏ†็ ไธไธ€่‡ด", 799 752 "passwordLength": "ๅฏ†็ ่‡ณๅฐ‘้œ€่ฆ8ไฝๅญ—็ฌฆ" ··· 837 790 "howItWorks": "ๅฆ‚ไฝ•ๆขๅค", 838 791 "howItWorksDetail": "ๆˆ‘ไปฌๅฐ†ๅ‘ๆ‚จๆณจๅ†Œ็š„้€š็Ÿฅๆธ ้“ๅ‘้€ๅฎ‰ๅ…จ้“พๆŽฅใ€‚็‚นๅ‡ป้“พๆŽฅ่ฎพ็ฝฎไธดๆ—ถๅฏ†็ ๏ผŒ็„ถๅŽๆ‚จๅฐฑๅฏไปฅ็™ปๅฝ•ๅนถๆทปๅŠ ๆ–ฐ็š„้€š่กŒๅฏ†้’ฅใ€‚", 839 792 "sendRecoveryLink": "ๅ‘้€ๆขๅค้“พๆŽฅ", 840 - "sending": "ๅ‘้€ไธญ..." 793 + "sending": "ๅ‘้€ไธญ...", 794 + "backToLogin": "่ฟ”ๅ›ž็™ปๅฝ•" 841 795 }, 842 796 "registerPasskey": { 843 797 "title": "ๅˆ›ๅปบ้€š่กŒๅฏ†้’ฅ่ดฆๆˆท", ··· 860 814 "inviteCode": "้‚€่ฏท็ ", 861 815 "inviteCodePlaceholder": "่พ“ๅ…ฅๆ‚จ็š„้‚€่ฏท็ ", 862 816 "createButton": "ๅˆ›ๅปบ่ดฆๆˆท", 817 + "creating": "ๅˆ›ๅปบไธญ...", 863 818 "continue": "็ปง็ปญ", 864 819 "back": "่ฟ”ๅ›ž", 865 820 "alreadyHaveAccount": "ๅทฒๆœ‰่ดฆๆˆท๏ผŸ", ··· 956 911 "useTotp": "ไฝฟ็”จ่บซไปฝ้ชŒ่ฏๅ™จ", 957 912 "passwordPlaceholder": "่พ“ๅ…ฅๆ‚จ็š„ๅฏ†็ ", 958 913 "totpPlaceholder": "่พ“ๅ…ฅ6ไฝ้ชŒ่ฏ็ ", 914 + "verify": "้ชŒ่ฏ", 915 + "verifying": "้ชŒ่ฏไธญ...", 959 916 "authenticating": "ๆญฃๅœจ้ชŒ่ฏ...", 960 917 "passkeyPrompt": "็‚นๅ‡ปไธ‹ๆ–นๆŒ‰้’ฎไฝฟ็”จ้€š่กŒๅฏ†้’ฅ่ฟ›่กŒ้ชŒ่ฏใ€‚", 961 918 "cancel": "ๅ–ๆถˆ" ··· 1029 986 "createAccount": "ๅˆ›ๅปบ่ดฆๆˆท", 1030 987 "createDelegatedAccount": "ๅˆ›ๅปบๅง”ๆ‰˜่ดฆๆˆท", 1031 988 "createDelegatedAccountButton": "+ ๅˆ›ๅปบๅง”ๆ‰˜่ดฆๆˆท", 989 + "creating": "ๅˆ›ๅปบไธญ...", 1032 990 "emailOptional": "้‚ฎ็ฎฑ๏ผˆๅฏ้€‰๏ผ‰", 1033 991 "failedToAddController": "ๆทปๅŠ ๆŽงๅˆถ่€…ๅคฑ่ดฅ", 1034 992 "failedToCreateAccount": "ๅˆ›ๅปบๅง”ๆ‰˜่ดฆๆˆทๅคฑ่ดฅ", ··· 1101 1059 "navDesc": "ๅฐ†ๆ‚จ็š„่ดฆๆˆท็งป่‡ณๅ…ถไป–PDSๆˆ–ไปŽๅ…ถไป–PDS็งปๅ…ฅ", 1102 1060 "migrateHere": "่ฟ็งปๅˆฐๆญคๅค„", 1103 1061 "migrateHereDesc": "ๅฐ†ๆ‚จ็Žฐๆœ‰็š„AT Protocol่ดฆๆˆทไปŽๅ…ถไป–ๆœๅŠกๅ™จ็งป่‡ณๆญคPDSใ€‚", 1062 + "migrateAway": "่ฟ็งป็ฆปๅผ€", 1063 + "migrateAwayDesc": "ๅฐ†ๆ‚จ็š„่ดฆๆˆทไปŽๆญคPDS็งป่‡ณๅ…ถไป–ๆœๅŠกๅ™จใ€‚", 1064 + "loginRequired": "้œ€่ฆ็™ปๅฝ•", 1104 1065 "bringDid": "ๆบๅธฆๆ‚จ็š„DIDๅ’Œ่บซไปฝ", 1105 1066 "transferData": "่ฝฌ็งปๆ‰€ๆœ‰ๆ•ฐๆฎ", 1106 1067 "keepFollowers": "ไฟ็•™ๆ‚จ็š„ๅ…ณๆณจ่€…", 1068 + "exportRepo": "ๅฏผๅ‡บๆ‚จ็š„ๅญ˜ๅ‚จๅบ“", 1069 + "transferToPds": "่ฝฌ็งปๅˆฐๆ–ฐPDS", 1070 + "updateIdentity": "ๆ›ดๆ–ฐๆ‚จ็š„่บซไปฝ", 1107 1071 "whatIsMigration": "ไป€ไนˆๆ˜ฏ่ดฆๆˆท่ฟ็งป๏ผŸ", 1108 1072 "whatIsMigrationDesc": "่ดฆๆˆท่ฟ็งปๅ…่ฎธๆ‚จๅœจไธชไบบๆ•ฐๆฎๆœๅŠกๅ™จ๏ผˆPDS๏ผ‰ไน‹้—ด็งปๅŠจAT Protocol่บซไปฝใ€‚ๆ‚จ็š„DID๏ผˆๅŽปไธญๅฟƒๅŒ–ๆ ‡่ฏ†็ฌฆ๏ผ‰ไฟๆŒไธๅ˜๏ผŒๅ› ๆญคๆ‚จ็š„ๅ…ณๆณจ่€…ๅ’Œ็คพไบค่ฟžๆŽฅๅพ—ไปฅไฟ็•™ใ€‚", 1109 1073 "beforeMigrate": "่ฟ็งปๅ‰้กป็Ÿฅ", ··· 1113 1077 "beforeMigrate4": "ๆ‚จ็š„ๆ—งPDSๅฐ†ๆ”ถๅˆฐ่ดฆๆˆทๅœ็”จ้€š็Ÿฅ", 1114 1078 "importantWarning": "่ดฆๆˆท่ฟ็งปๆ˜ฏไธ€้กน้‡่ฆๆ“ไฝœใ€‚่ฏท็กฎไฟๆ‚จไฟกไปป็›ฎๆ ‡PDS๏ผŒๅนถไบ†่งฃๆ‚จ็š„ๆ•ฐๆฎๅฐ†่ขซ็งปๅŠจใ€‚ๅฆ‚ๆžœๅ‡บ็Žฐ้—ฎ้ข˜๏ผŒๅฏ่ƒฝ้œ€่ฆๆ‰‹ๅŠจๆขๅคใ€‚", 1115 1079 "learnMore": "ไบ†่งฃๆ›ดๅคš่ฟ็งป้ฃŽ้™ฉ", 1116 - "offlineRestore": "็ฆป็บฟๆขๅค", 1117 - "offlineRestoreDesc": "ๅฝ“ๆ—ง PDS ไธๅฏ็”จๆ—ถไปŽๅค‡ไปฝๆขๅคใ€‚", 1118 - "offlineFeature1": "ไฝฟ็”จ CAR ๆ–‡ไปถๅค‡ไปฝ", 1119 - "offlineFeature2": "ไฝฟ็”จ่ฝฎๆขๅฏ†้’ฅ่ฏๆ˜Žๆ‰€ๆœ‰ๆƒ", 1120 - "offlineFeature3": "็”จไบŽๅทฒๅ…ณ้—ญๆœๅŠกๅ™จ็š„ๆขๅค", 1080 + "comingSoon": "ๅณๅฐ†ๆŽจๅ‡บ", 1121 1081 "oauthCompleting": "ๆญฃๅœจๅฎŒๆˆ่บซไปฝ้ชŒ่ฏ...", 1122 1082 "oauthFailed": "่บซไปฝ้ชŒ่ฏๅคฑ่ดฅ", 1123 1083 "tryAgain": "้‡่ฏ•", ··· 1126 1086 "incomplete": "ๆ‚จๆœ‰ไธ€ไธชๆœชๅฎŒๆˆ็š„่ฟ็งป๏ผš", 1127 1087 "direction": "ๆ–นๅ‘", 1128 1088 "migratingHere": "ๆญฃๅœจ่ฟ็งปๅˆฐๆญคๅค„", 1089 + "migratingAway": "ๆญฃๅœจ่ฟ็งป็ฆปๅผ€", 1129 1090 "from": "ไปŽ", 1130 1091 "to": "ๅˆฐ", 1131 1092 "progress": "่ฟ›ๅบฆ", ··· 1268 1229 "error": { 1269 1230 "title": "่ฟ็งป้”™่ฏฏ", 1270 1231 "desc": "่ฟ็งป่ฟ‡็จ‹ไธญๅ‘็”Ÿ้”™่ฏฏใ€‚", 1271 - "startOver": "้‡ๆ–ฐๅผ€ๅง‹", 1272 - "unknown": "ๅ‘็”Ÿๆœช็Ÿฅ้”™่ฏฏใ€‚" 1232 + "startOver": "้‡ๆ–ฐๅผ€ๅง‹" 1273 1233 }, 1274 1234 "common": { 1275 1235 "back": "่ฟ”ๅ›ž", ··· 1287 1247 "warning3": "่ฟ็งปๅŽๆ‚จ็š„ๆ—ง่ดฆๆˆทๅฐ†่ขซๅœ็”จ" 1288 1248 } 1289 1249 }, 1290 - "offline": { 1250 + "outbound": { 1291 1251 "welcome": { 1292 - "title": "ไปŽๅค‡ไปฝๆขๅค", 1293 - "desc": "ไฝฟ็”จ CAR ๆ–‡ไปถๅค‡ไปฝๅ’Œ่ฝฎๆขๅฏ†้’ฅๆขๅคๆ‚จ็š„่ดฆๆˆทใ€‚ๅฝ“ๆ‚จ็š„ๆ—ง PDS ไธๅฏ็”จๆ—ถไฝฟ็”จๆญคๆ–นๆณ•ใ€‚", 1294 - "warningTitle": "ไฝ•ๆ—ถไฝฟ็”จๆญคๆ–นๆณ•", 1295 - "warningDesc": "ๆญค็ฆป็บฟๆขๅค็”จไบŽ็พ้šพๆขๅค๏ผŒๅฝ“ๆ‚จ็š„ๆ—ง PDS ๅทฒๅ…ณ้—ญใ€ๆ— ๆณ•่ฎฟ้—ฎๆˆ–ๆ‚จ่ขซ้”ๅฎšๆ—ถไฝฟ็”จใ€‚ๅฆ‚ๆžœๆ‚จ็š„ๆ—ง PDS ไป็„ถๅฏ็”จ๏ผŒ่ฏทไฝฟ็”จๆ ‡ๅ‡†่ฟ็งปใ€‚", 1296 - "requirementsTitle": "ๆ‚จ้œ€่ฆ", 1297 - "requirement1": "ๆ‚จ็š„ๅญ˜ๅ‚จๅบ“็š„ CAR ๆ–‡ไปถๅค‡ไปฝ", 1298 - "requirement2": "ๆ‚จ็š„่ฝฎๆขๅฏ†้’ฅ๏ผˆDID ็š„็ง้’ฅ๏ผ‰", 1299 - "requirement3": "ๆ‚จ็š„ DID (did:plc:xxx)", 1300 - "understand": "ๆˆ‘ไบ†่งฃๅนถๅธŒๆœ›็ปง็ปญ" 1301 - }, 1302 - "provideDid": { 1303 - "title": "่พ“ๅ…ฅๆ‚จ็š„ DID", 1304 - "desc": "่พ“ๅ…ฅๆ‚จ่ฆๆขๅค็š„่ดฆๆˆท็š„ DIDใ€‚", 1305 - "label": "ๆ‚จ็š„ DID", 1306 - "hint": "ๆ‚จ็š„ๅŽปไธญๅฟƒๅŒ–ๆ ‡่ฏ†็ฌฆ๏ผˆไพ‹ๅฆ‚ did:plc:abc123๏ผ‰" 1307 - }, 1308 - "uploadCar": { 1309 - "title": "ไธŠไผ  CAR ๆ–‡ไปถ", 1310 - "desc": "ไธŠไผ ๆ‚จ็š„ๅญ˜ๅ‚จๅบ“ๅค‡ไปฝๆ–‡ไปถใ€‚", 1311 - "label": "CAR ๆ–‡ไปถ", 1312 - "hint": "ไปŽๆ‚จ็š„ๅค‡ไปฝไธญ้€‰ๆ‹ฉ .car ๆ–‡ไปถ", 1313 - "reuploadWarningTitle": "้œ€่ฆ CAR ๆ–‡ไปถ", 1314 - "reuploadWarning": "ๆ‚จ็š„ไผš่ฏๅทฒๆขๅค๏ผŒไฝ†ๆ‚จ้œ€่ฆ้‡ๆ–ฐไธŠไผ  CAR ๆ–‡ไปถใ€‚ๅ‡บไบŽๅฎ‰ๅ…จๅŽŸๅ› ๏ผŒๆ–‡ไปถๅ†…ๅฎนไธไผšๅœจไผš่ฏไน‹้—ดไฟๅญ˜ใ€‚" 1252 + "title": "ไปŽๆญคPDS่ฟ็งป็ฆปๅผ€", 1253 + "desc": "ๅฐ†ๆ‚จ็š„่ดฆๆˆท็งป่‡ณๅฆไธ€ไธชไธชไบบๆ•ฐๆฎๆœๅŠกๅ™จใ€‚", 1254 + "warning": "่ฟ็งปๅŽ๏ผŒๆ‚จๅœจๆญคๅค„็š„่ดฆๆˆทๅฐ†่ขซๅœ็”จใ€‚", 1255 + "didWebNotice": "did:web่ฟ็งป้€š็Ÿฅ", 1256 + "didWebNoticeDesc": "ๆ‚จ็š„่ดฆๆˆทไฝฟ็”จdid:webๆ ‡่ฏ†็ฌฆ๏ผˆ{did}๏ผ‰ใ€‚่ฟ็งปๅŽ๏ผŒๆญคPDSๅฐ†็ปง็ปญๆไพ›ๆŒ‡ๅ‘ๆ–ฐPDS็š„DIDๆ–‡ๆกฃใ€‚ๅช่ฆๆญคๆœๅŠกๅ™จๅœจ็บฟ๏ผŒๆ‚จ็š„่บซไปฝๅฐ†็ปง็ปญๆœ‰ๆ•ˆใ€‚", 1257 + "understand": "ๆˆ‘ไบ†่งฃ้ฃŽ้™ฉๅนถๅธŒๆœ›็ปง็ปญ" 1315 1258 }, 1316 - "rotationKey": { 1317 - "title": "ๆไพ›่ฝฎๆขๅฏ†้’ฅ", 1318 - "desc": "่พ“ๅ…ฅๆ‚จ็š„่ฝฎๆขๅฏ†้’ฅไปฅ่ฏๆ˜Žๆญค DID ็š„ๆ‰€ๆœ‰ๆƒใ€‚", 1319 - "securityWarningTitle": "ๅฎ‰ๅ…จ่ญฆๅ‘Š", 1320 - "securityWarning1": "ๆ‚จ็š„่ฝฎๆขๅฏ†้’ฅๆžไธบๆ•ๆ„Ÿ - ่ฏทๅƒๅฏนๅพ…ไธปๅฏ†็ ไธ€ๆ ทๅฏนๅพ…ๅฎƒ", 1321 - "securityWarning2": "ไป…ๅœจๅ—ไฟกไปป็š„่ฎพๅค‡ๅ’Œ็ฝ‘็ปœไธŠ่พ“ๅ…ฅ", 1322 - "securityWarning3": "่ฟ็งปๅฎŒๆˆๅŽๆญคๅฏ†้’ฅไธไผš่ขซๅญ˜ๅ‚จ", 1323 - "label": "่ฝฎๆขๅฏ†้’ฅ", 1324 - "placeholder": "่พ“ๅ…ฅ็ง้’ฅ๏ผˆhexใ€base58 ๆˆ– JWK๏ผ‰", 1325 - "hint": "ไธŽๆ‚จ็š„ DID ๆ–‡ๆกฃไธญ็š„่ฝฎๆขๅฏ†้’ฅไน‹ไธ€ๅฏนๅบ”็š„็ง้’ฅ", 1326 - "valid": "ๅฏ†้’ฅๆœ‰ๆ•ˆๅนถๅŒน้…ๆ‚จ็š„ DID ไธญ็š„่ฝฎๆขๅฏ†้’ฅ", 1327 - "invalid": "ๅฏ†้’ฅไธŽๆ‚จ็š„ DID ๆ–‡ๆกฃไธญ็š„ไปปไฝ•่ฝฎๆขๅฏ†้’ฅ้ƒฝไธๅŒน้…", 1328 - "validating": "้ชŒ่ฏๅฏ†้’ฅ...", 1329 - "validate": "้ชŒ่ฏๅฏ†้’ฅ" 1259 + "targetPds": { 1260 + "title": "้€‰ๆ‹ฉ็›ฎๆ ‡PDS", 1261 + "desc": "่พ“ๅ…ฅๆ‚จ่ฆ่ฟ็งปๅˆฐ็š„PDS็š„URLใ€‚", 1262 + "url": "PDS URL", 1263 + "urlPlaceholder": "https://pds.example.com", 1264 + "validate": "้ชŒ่ฏๅนถ็ปง็ปญ", 1265 + "validating": "้ชŒ่ฏไธญ...", 1266 + "connected": "ๅทฒ่ฟžๆŽฅๅˆฐ {name}", 1267 + "inviteRequired": "้œ€่ฆ้‚€่ฏท็ ", 1268 + "privacyPolicy": "้š็งๆ”ฟ็ญ–", 1269 + "termsOfService": "ๆœๅŠกๆกๆฌพ" 1330 1270 }, 1331 - "chooseHandle": { 1332 - "migratingDid": "ๆขๅค DID" 1271 + "newAccount": { 1272 + "title": "ๆ–ฐ่ดฆๆˆท่ฏฆๆƒ…", 1273 + "desc": "ๅœจๆ–ฐPDSไธŠ่ฎพ็ฝฎๆ‚จ็š„่ดฆๆˆทใ€‚", 1274 + "handle": "็”จๆˆทๅ", 1275 + "availableDomains": "ๅฏ็”จๅŸŸๅ", 1276 + "email": "้‚ฎ็ฎฑ", 1277 + "password": "ๅฏ†็ ", 1278 + "confirmPassword": "็กฎ่ฎคๅฏ†็ ", 1279 + "inviteCode": "้‚€่ฏท็ " 1333 1280 }, 1334 1281 "review": { 1335 - "desc": "ๆฃ€ๆŸฅๆ‚จ็š„็ฆป็บฟๆขๅค่ฏฆๆƒ…ใ€‚", 1336 - "carFile": "CAR ๆ–‡ไปถ", 1337 - "rotationKey": "่ฝฎๆขๅฏ†้’ฅ", 1338 - "warning": "ๅผ€ๅง‹ๆขๅคๅŽ๏ผŒๆ‚จ็š„่บซไปฝๅฐ†ๆ›ดๆ–ฐไธบๆŒ‡ๅ‘ๆญค PDSใ€‚ๆญคๆ“ไฝœๆ— ๆณ•่ฝปๆ˜“ๆ’ค้”€ใ€‚", 1339 - "plcWarningTitle": "ไธๅฏ้€†่ฝฌ็‚น", 1340 - "plcWarning": "ไธ€ๆ—ฆๅผ€ๅง‹๏ผŒๆ‚จ็š„ DID ๆ–‡ๆกฃๅฐ†ๆ›ดๆ–ฐไธบๆŒ‡ๅ‘ๆญค PDSใ€‚ๅฆ‚ๆžœๅ‡บ็Žฐ้—ฎ้ข˜๏ผŒๆ‚จๅฏไปฅไฝฟ็”จ่ฝฎๆขๅฏ†้’ฅๆขๅค๏ผŒไฝ†ๆ‚จๅบ”่ฏฅๅฎŒๆˆ่ฟ็งปไปฅ้ฟๅ…่บซไปฝ็Šถๆ€ๆŸๅใ€‚" 1282 + "title": "ๆฃ€ๆŸฅ่ฟ็งป", 1283 + "desc": "่ฏทๆฃ€ๆŸฅๅนถ็กฎ่ฎคๆ‚จ็š„่ฟ็งป่ฏฆๆƒ…ใ€‚", 1284 + "currentHandle": "ๅฝ“ๅ‰็”จๆˆทๅ", 1285 + "newHandle": "ๆ–ฐ็”จๆˆทๅ", 1286 + "sourcePds": "ๆญคPDS", 1287 + "targetPds": "็›ฎๆ ‡PDS", 1288 + "confirm": "ๆˆ‘็กฎ่ฎค่ฆ่ฟ็งปๆˆ‘็š„่ดฆๆˆท", 1289 + "startMigration": "ๅผ€ๅง‹่ฟ็งป" 1341 1290 }, 1342 1291 "migrating": { 1343 - "title": "ๆขๅค่ดฆๆˆท", 1344 - "desc": "่ฏท็จๅ€™๏ผŒๆญฃๅœจๆขๅคๆ‚จ็š„่ดฆๆˆท...", 1345 - "creating": "ๅˆ›ๅปบ่ดฆๆˆท", 1346 - "importing": "ๅฏผๅ…ฅๅญ˜ๅ‚จๅบ“", 1347 - "plcSigning": "ๆ›ดๆ–ฐ่บซไปฝ", 1348 - "activating": "ๆฟ€ๆดป่ดฆๆˆท" 1292 + "title": "ๆญฃๅœจ่ฟ็งปๆ‚จ็š„่ดฆๆˆท", 1293 + "desc": "่ฏท็จๅ€™๏ผŒๆญฃๅœจ่ฝฌ็งปๆ‚จ็š„ๆ•ฐๆฎ..." 1349 1294 }, 1350 - "success": { 1351 - "desc": "ๆ‚จ็š„่ดฆๆˆทๅทฒๆˆๅŠŸๆขๅคๅˆฐๆญค PDSใ€‚" 1295 + "plcToken": { 1296 + "title": "้ชŒ่ฏๆ‚จ็š„่บซไปฝ", 1297 + "desc": "้ชŒ่ฏ็ ๅทฒๅ‘้€ๅˆฐๆ‚จ็š„้‚ฎ็ฎฑใ€‚" 1352 1298 }, 1353 - "blobs": { 1354 - "title": "่ฟ็งป Blob", 1355 - "desc": "ๆญฃๅœจๅฐ่ฏ•ไปŽๆ‚จ็š„ๆ—ง PDS ๆขๅคๅ›พ็‰‡ๅ’Œๅช’ไฝ“...", 1356 - "migrating": "ๆญฃๅœจ่ฟ็งป blob", 1357 - "failedTitle": "้ƒจๅˆ† blob ๆ— ๆณ•่ฟ็งป", 1358 - "failedDesc": "{count} ไธช blob ๆ— ๆณ•ไปŽๆ‚จ็š„ๆ—ง PDS ่Žทๅ–ใ€‚่ฟ™ๅฏ่ƒฝๆ˜ฏๅ› ไธบๆœๅŠกๅ™จๆ— ๆณ•่ฎฟ้—ฎๆˆ–ๆ–‡ไปถๅทฒ่ขซๅˆ ้™คใ€‚", 1359 - "sourceUnreachableTitle": "ๆบ PDS ๆ— ๆณ•่ฎฟ้—ฎ", 1360 - "sourceUnreachable": "ๆ— ๆณ•่ฟžๆŽฅๅˆฐๆ‚จ็š„ๆ—ง PDS ๆฅ่Žทๅ–ๅช’ไฝ“ๆ–‡ไปถใ€‚ไปŽๅทฒๅ…ณ้—ญ็š„ๆœๅŠกๅ™จ่ฟ็งปๆ—ถ่ฟ™ๅพˆๅธธ่งใ€‚ๆ‚จ็š„ๅธ–ๅญๅฐ†ๆญฃๅธธๅทฅไฝœ๏ผŒไฝ†้ƒจๅˆ†ๅ›พ็‰‡ๅฏ่ƒฝไผšไธขๅคฑใ€‚" 1299 + "finalizing": { 1300 + "title": "ๆญฃๅœจๅฎŒๆˆ่ฟ็งป", 1301 + "desc": "่ฏท็จๅ€™๏ผŒๆญฃๅœจๅฎŒๆˆ่ฟ็งป...", 1302 + "updatingForwarding": "ๆญฃๅœจๆ›ดๆ–ฐDIDๆ–‡ๆกฃ่ฝฌๅ‘..." 1303 + }, 1304 + "success": { 1305 + "title": "่ฟ็งปๅฎŒๆˆ๏ผ", 1306 + "desc": "ๆ‚จ็š„่ดฆๆˆทๅทฒๆˆๅŠŸ่ฟ็งปๅˆฐๆ–ฐPDSใ€‚", 1307 + "newHandle": "ๆ–ฐ็”จๆˆทๅ", 1308 + "newPds": "ๆ–ฐPDS", 1309 + "nextSteps": "ๅŽ็ปญๆญฅ้ชค", 1310 + "nextSteps1": "็™ปๅฝ•ๅˆฐๆ‚จ็š„ๆ–ฐPDS", 1311 + "nextSteps2": "ไฝฟ็”จๆ–ฐๅ‡ญๆฎๆ›ดๆ–ฐๆ‚จ็š„ๅบ”็”จ", 1312 + "nextSteps3": "ๆ‚จ็š„ๅ…ณๆณจ่€…ๅฐ†่‡ชๅŠจ็œ‹ๅˆฐๆ‚จ็š„ๆ–ฐไฝ็ฝฎ", 1313 + "loggingOut": "{seconds}็ง’ๅŽ้€€ๅ‡บ็™ปๅฝ•..." 1361 1314 } 1362 1315 }, 1363 1316 "progress": {
+1 -1
frontend/src/routes/ActAs.svelte
··· 37 37 38 38 try { 39 39 const response = await fetch( 40 - `/xrpc/_delegation.listControlledAccounts`, 40 + `/xrpc/com.tranquil.delegation.listControlledAccounts`, 41 41 { 42 42 headers: { 'Authorization': `Bearer ${auth.session!.accessJwt}` } 43 43 }
+1 -1
frontend/src/routes/Admin.svelte
··· 435 435 <div class="message success">{$_('admin.configSaved')}</div> 436 436 {/if} 437 437 <button type="submit" disabled={serverConfigLoading || !hasConfigChanges()}> 438 - {serverConfigLoading ? $_('common.saving') : $_('admin.saveConfig')} 438 + {serverConfigLoading ? $_('admin.saving') : $_('admin.saveConfig')} 439 439 </button> 440 440 </form> 441 441 </section>
+1 -1
frontend/src/routes/AppPasswords.svelte
··· 155 155 </div> 156 156 </div> 157 157 <button type="submit" disabled={creating || !newPasswordName.trim()}> 158 - {creating ? $_('common.creating') : $_('common.create')} 158 + {creating ? $_('appPasswords.creating') : $_('common.create')} 159 159 </button> 160 160 </form> 161 161 </section>
+1 -1
frontend/src/routes/Comms.svelte
··· 341 341 342 342 <div class="actions"> 343 343 <button type="submit" disabled={saving}> 344 - {saving ? $_('common.saving') : $_('comms.savePreferences')} 344 + {saving ? $_('comms.saving') : $_('comms.savePreferences')} 345 345 </button> 346 346 </div> 347 347 </form>
+7 -7
frontend/src/routes/Controllers.svelte
··· 75 75 async function loadControllers() { 76 76 if (!auth.session) return 77 77 try { 78 - const response = await fetch('/xrpc/_delegation.listControllers', { 78 + const response = await fetch('/xrpc/com.tranquil.delegation.listControllers', { 79 79 headers: { 'Authorization': `Bearer ${auth.session.accessJwt}` } 80 80 }) 81 81 if (response.ok) { ··· 90 90 async function loadControlledAccounts() { 91 91 if (!auth.session) return 92 92 try { 93 - const response = await fetch('/xrpc/_delegation.listControlledAccounts', { 93 + const response = await fetch('/xrpc/com.tranquil.delegation.listControlledAccounts', { 94 94 headers: { 'Authorization': `Bearer ${auth.session.accessJwt}` } 95 95 }) 96 96 if (response.ok) { ··· 104 104 105 105 async function loadScopePresets() { 106 106 try { 107 - const response = await fetch('/xrpc/_delegation.getScopePresets') 107 + const response = await fetch('/xrpc/com.tranquil.delegation.getScopePresets') 108 108 if (response.ok) { 109 109 const data = await response.json() 110 110 scopePresets = data.presets || [] ··· 121 121 success = null 122 122 123 123 try { 124 - const response = await fetch('/xrpc/_delegation.addController', { 124 + const response = await fetch('/xrpc/com.tranquil.delegation.addController', { 125 125 method: 'POST', 126 126 headers: { 127 127 'Authorization': `Bearer ${auth.session.accessJwt}`, ··· 159 159 success = null 160 160 161 161 try { 162 - const response = await fetch('/xrpc/_delegation.removeController', { 162 + const response = await fetch('/xrpc/com.tranquil.delegation.removeController', { 163 163 method: 'POST', 164 164 headers: { 165 165 'Authorization': `Bearer ${auth.session.accessJwt}`, ··· 188 188 success = null 189 189 190 190 try { 191 - const response = await fetch('/xrpc/_delegation.createDelegatedAccount', { 191 + const response = await fetch('/xrpc/com.tranquil.delegation.createDelegatedAccount', { 192 192 method: 'POST', 193 193 headers: { 194 194 'Authorization': `Bearer ${auth.session.accessJwt}`, ··· 407 407 {$_('common.cancel')} 408 408 </button> 409 409 <button onclick={createDelegatedAccount} disabled={creatingDelegated || !newDelegatedHandle.trim()}> 410 - {creatingDelegated ? $_('common.creating') : $_('delegation.createAccount')} 410 + {creatingDelegated ? $_('delegation.creating') : $_('delegation.createAccount')} 411 411 </button> 412 412 </div> 413 413 </div>
-21
frontend/src/routes/Dashboard.svelte
··· 10 10 let switching = $state(false) 11 11 let inviteCodesEnabled = $state(false) 12 12 13 - const isDidWeb = $derived(auth.session?.did?.startsWith('did:web:') ?? false) 14 - 15 13 onMount(async () => { 16 14 try { 17 15 const serverInfo = await api.describeServer() ··· 178 176 <h3>{$_('dashboard.navSecurity')}</h3> 179 177 <p>{$_('dashboard.navSecurityDesc')}</p> 180 178 </a> 181 - <a href="#/settings" class="nav-card"> 182 - <h3>{$_('dashboard.navSettings')}</h3> 183 - <p>{$_('dashboard.navSettingsDesc')}</p> 184 - </a> 185 179 <a href="#/migrate" class="nav-card"> 186 180 <h3>{$_('dashboard.navMigrateAgain')}</h3> 187 181 <p>{$_('dashboard.navMigrateAgainDesc')}</p> ··· 221 215 <h3>{$_('dashboard.navDelegation')}</h3> 222 216 <p>{$_('dashboard.navDelegationDesc')}</p> 223 217 </a> 224 - {#if isDidWeb} 225 - <a href="#/did-document" class="nav-card did-web-card"> 226 - <h3>{$_('dashboard.navDidDocument')}</h3> 227 - <p>{$_('dashboard.navDidDocumentDescActive')}</p> 228 - </a> 229 - {/if} 230 218 <a href="#/migrate" class="nav-card"> 231 219 <h3>{$_('migration.navTitle')}</h3> 232 220 <p>{$_('migration.navDesc')}</p> ··· 515 503 516 504 .nav-card.migrated-card h3 { 517 505 color: var(--info-text, #0369a1); 518 - } 519 - 520 - .nav-card.did-web-card { 521 - border-color: var(--accent); 522 - background: linear-gradient(135deg, var(--bg-card) 0%, var(--accent-muted) 100%); 523 - } 524 - 525 - .nav-card.did-web-card:hover { 526 - box-shadow: 0 2px 12px var(--accent-muted); 527 506 } 528 507 </style>
+1 -1
frontend/src/routes/DelegationAudit.svelte
··· 41 41 42 42 try { 43 43 const response = await fetch( 44 - `/xrpc/_delegation.getAuditLog?limit=${limit}&offset=${offset}`, 44 + `/xrpc/com.tranquil.delegation.getAuditLog?limit=${limit}&offset=${offset}`, 45 45 { 46 46 headers: { 'Authorization': `Bearer ${auth.session.accessJwt}` } 47 47 }
+1 -1
frontend/src/routes/DidDocumentEditor.svelte
··· 230 230 231 231 <div class="actions"> 232 232 <button onclick={handleSave} disabled={saving}> 233 - {saving ? $_('common.saving') : $_('common.save')} 233 + {saving ? $_('didEditor.saving') : $_('didEditor.save')} 234 234 </button> 235 235 </div> 236 236 {/if}
-5
frontend/src/routes/Home.svelte
··· 183 183 <h3>Delegate without sharing passwords</h3> 184 184 <p>Let team members or tools manage your account with specific permission levels. They authenticate with their own credentials, you see everything they do in an audit log.</p> 185 185 </div> 186 - 187 - <div class="feature"> 188 - <h3>Automatic backups</h3> 189 - <p>Your repository is backed up daily to object storage. Download any backup or restore with one click. You own your data, even if the worst happens.</p> 190 - </div> 191 186 </div> 192 187 193 188 <h2>Everything in one place</h2>
+1 -1
frontend/src/routes/InviteCodes.svelte
··· 111 111 {#if auth.session?.isAdmin} 112 112 <section class="create-section"> 113 113 <button onclick={handleCreate} disabled={creating}> 114 - {creating ? $_('common.creating') : $_('inviteCodes.createNew')} 114 + {creating ? $_('inviteCodes.creating') : $_('inviteCodes.createNew')} 115 115 </button> 116 116 </section> 117 117 {/if}
+3 -3
frontend/src/routes/Login.svelte
··· 107 107 </div> 108 108 <div class="actions"> 109 109 <button type="submit" disabled={submitting || !verificationCode.trim()}> 110 - {submitting ? $_('common.verifying') : $_('common.verify')} 110 + {submitting ? $_('verification.verifying') : $_('verification.verifyButton')} 111 111 </button> 112 112 <button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}> 113 - {resendingCode ? $_('common.sending') : $_('common.resendCode')} 113 + {resendingCode ? $_('verification.resending') : $_('verification.resendButton')} 114 114 </button> 115 115 <button type="button" class="tertiary" onclick={backToLogin}> 116 - {$_('common.backToLogin')} 116 + {$_('verification.backToLogin')} 117 117 </button> 118 118 </div> 119 119 </form>
+69 -63
frontend/src/routes/Migration.svelte
··· 1 1 <script lang="ts"> 2 - import { setSession } from '../lib/auth.svelte' 2 + import { getAuthState, logout, setSession } from '../lib/auth.svelte' 3 3 import { navigate } from '../lib/router.svelte' 4 4 import { _ } from '../lib/i18n' 5 5 import { 6 6 createInboundMigrationFlow, 7 - createOfflineInboundMigrationFlow, 7 + createOutboundMigrationFlow, 8 8 hasPendingMigration, 9 - hasPendingOfflineMigration, 10 9 getResumeInfo, 11 - getOfflineResumeInfo, 12 10 clearMigrationState, 13 - clearOfflineState, 14 11 loadMigrationState, 15 12 } from '../lib/migration' 16 13 import InboundWizard from '../components/migration/InboundWizard.svelte' 17 - import OfflineInboundWizard from '../components/migration/OfflineInboundWizard.svelte' 14 + import OutboundWizard from '../components/migration/OutboundWizard.svelte' 15 + 16 + const auth = getAuthState() 18 17 19 - type Direction = 'select' | 'inbound' | 'offline-inbound' 18 + type Direction = 'select' | 'inbound' | 'outbound' 20 19 let direction = $state<Direction>('select') 21 20 let showResumeModal = $state(false) 22 21 let resumeInfo = $state<ReturnType<typeof getResumeInfo>>(null) ··· 24 23 let oauthLoading = $state(false) 25 24 26 25 let inboundFlow = $state<ReturnType<typeof createInboundMigrationFlow> | null>(null) 27 - let offlineFlow = $state<ReturnType<typeof createOfflineInboundMigrationFlow> | null>(null) 26 + let outboundFlow = $state<ReturnType<typeof createOutboundMigrationFlow> | null>(null) 28 27 let oauthCallbackProcessed = $state(false) 29 28 30 29 $effect(() => { ··· 67 66 const urlParams = new URLSearchParams(window.location.search) 68 67 const hasOAuthCallback = urlParams.has('code') || urlParams.has('error') 69 68 70 - if (!hasOAuthCallback) { 71 - if (hasPendingMigration()) { 72 - resumeInfo = getResumeInfo() 73 - if (resumeInfo) { 74 - if (resumeInfo.step === 'success') { 75 - clearMigrationState() 76 - resumeInfo = null 69 + if (!hasOAuthCallback && hasPendingMigration()) { 70 + resumeInfo = getResumeInfo() 71 + if (resumeInfo) { 72 + const stored = loadMigrationState() 73 + if (stored) { 74 + if (stored.direction === 'inbound') { 75 + direction = 'inbound' 76 + inboundFlow = createInboundMigrationFlow() 77 + inboundFlow.resumeFromState(stored) 77 78 } else { 78 - const stored = loadMigrationState() 79 - if (stored && stored.direction === 'inbound') { 80 - direction = 'inbound' 81 - inboundFlow = createInboundMigrationFlow() 82 - inboundFlow.resumeFromState(stored) 83 - } 79 + direction = 'outbound' 80 + outboundFlow = createOutboundMigrationFlow() 84 81 } 85 82 } 86 - } else if (hasPendingOfflineMigration()) { 87 - const offlineInfo = getOfflineResumeInfo() 88 - if (offlineInfo && offlineInfo.step === 'success') { 89 - clearOfflineState() 90 - } else { 91 - direction = 'offline-inbound' 92 - offlineFlow = createOfflineInboundMigrationFlow() 93 - offlineFlow.tryResume() 94 - } 95 83 } 96 84 } 97 85 ··· 100 88 inboundFlow = createInboundMigrationFlow() 101 89 } 102 90 103 - function selectOfflineInbound() { 104 - direction = 'offline-inbound' 105 - offlineFlow = createOfflineInboundMigrationFlow() 91 + function selectOutbound() { 92 + if (!auth.session) { 93 + navigate('/login') 94 + return 95 + } 96 + direction = 'outbound' 97 + outboundFlow = createOutboundMigrationFlow() 98 + outboundFlow.initLocalClient(auth.session.accessJwt, auth.session.did, auth.session.handle) 106 99 } 107 100 108 101 function handleResume() { ··· 115 108 direction = 'inbound' 116 109 inboundFlow = createInboundMigrationFlow() 117 110 inboundFlow.resumeFromState(stored) 111 + } else { 112 + if (!auth.session) { 113 + navigate('/login') 114 + return 115 + } 116 + direction = 'outbound' 117 + outboundFlow = createOutboundMigrationFlow() 118 + outboundFlow.initLocalClient(auth.session.accessJwt, auth.session.did, auth.session.handle) 118 119 } 119 120 } 120 121 ··· 129 130 inboundFlow.reset() 130 131 inboundFlow = null 131 132 } 132 - if (offlineFlow) { 133 - offlineFlow.reset() 134 - offlineFlow = null 133 + if (outboundFlow) { 134 + outboundFlow.reset() 135 + outboundFlow = null 135 136 } 136 137 direction = 'select' 137 138 } ··· 149 150 navigate('/dashboard') 150 151 } 151 152 152 - function handleOfflineComplete() { 153 - const session = offlineFlow?.getLocalSession() 154 - if (session) { 155 - setSession({ 156 - did: session.did, 157 - handle: session.handle, 158 - accessJwt: session.accessJwt, 159 - refreshJwt: '', 160 - }) 161 - } 162 - navigate('/dashboard') 153 + async function handleOutboundComplete() { 154 + await logout() 155 + navigate('/login') 163 156 } 164 157 </script> 165 158 ··· 172 165 <div class="resume-details"> 173 166 <div class="detail-row"> 174 167 <span class="label">{$_('migration.resume.direction')}:</span> 175 - <span class="value">{$_('migration.resume.migratingHere')}</span> 168 + <span class="value">{resumeInfo.direction === 'inbound' ? $_('migration.resume.migratingHere') : $_('migration.resume.migratingAway')}</span> 176 169 </div> 177 170 {#if resumeInfo.sourceHandle} 178 171 <div class="detail-row"> ··· 219 212 220 213 <div class="direction-cards"> 221 214 <button class="direction-card ghost" onclick={selectInbound}> 215 + <div class="card-icon">โ†“</div> 222 216 <h2>{$_('migration.migrateHere')}</h2> 223 217 <p>{$_('migration.migrateHereDesc')}</p> 224 218 <ul class="features"> ··· 228 222 </ul> 229 223 </button> 230 224 231 - <button class="direction-card ghost offline-card" onclick={selectOfflineInbound}> 232 - <h2>{$_('migration.offlineRestore')}</h2> 233 - <p>{$_('migration.offlineRestoreDesc')}</p> 225 + <button class="direction-card ghost" onclick={selectOutbound} disabled> 226 + <div class="card-icon">โ†‘</div> 227 + <h2>{$_('migration.migrateAway')}</h2> 228 + <p>{$_('migration.migrateAwayDesc')}</p> 234 229 <ul class="features"> 235 - <li>{$_('migration.offlineFeature1')}</li> 236 - <li>{$_('migration.offlineFeature2')}</li> 237 - <li>{$_('migration.offlineFeature3')}</li> 230 + <li>{$_('migration.exportRepo')}</li> 231 + <li>{$_('migration.transferToPds')}</li> 232 + <li>{$_('migration.updateIdentity')}</li> 238 233 </ul> 234 + <p class="login-required">{$_('migration.comingSoon')}</p> 239 235 </button> 240 236 </div> 241 237 ··· 267 263 onComplete={handleInboundComplete} 268 264 /> 269 265 270 - {:else if direction === 'offline-inbound' && offlineFlow} 271 - <OfflineInboundWizard 272 - flow={offlineFlow} 266 + {:else if direction === 'outbound' && outboundFlow} 267 + <OutboundWizard 268 + flow={outboundFlow} 273 269 onBack={handleBack} 274 - onComplete={handleOfflineComplete} 270 + onComplete={handleOutboundComplete} 275 271 /> 276 272 {/if} 277 273 </div> ··· 306 302 } 307 303 308 304 .direction-card { 309 - display: flex; 310 - flex-direction: column; 311 - align-items: stretch; 312 305 background: var(--bg-secondary); 313 306 border: 1px solid var(--border); 314 307 border-radius: var(--radius-xl); ··· 329 322 cursor: not-allowed; 330 323 } 331 324 325 + .card-icon { 326 + font-size: var(--text-3xl); 327 + margin-bottom: var(--space-4); 328 + color: var(--accent); 329 + } 330 + 332 331 .direction-card h2 { 333 332 margin: 0 0 var(--space-3) 0; 334 333 font-size: var(--text-xl); ··· 350 349 351 350 .features li { 352 351 margin-bottom: var(--space-2); 352 + } 353 + 354 + .login-required { 355 + color: var(--warning-text); 356 + font-weight: var(--font-medium); 357 + margin-top: var(--space-4); 353 358 } 354 359 355 360 .info-section { ··· 397 402 } 398 403 399 404 .warning-box a { 400 - display: inline; 401 - margin-top: var(--space-2); 405 + display: block; 406 + margin-top: var(--space-3); 407 + color: var(--accent); 402 408 } 403 409 404 410 .modal-overlay {
+1 -1
frontend/src/routes/OAuth2FA.svelte
··· 105 105 {$_('common.cancel')} 106 106 </button> 107 107 <button type="submit" class="submit-btn" disabled={submitting || code.trim().length !== 6}> 108 - {submitting ? $_('common.verifying') : $_('common.verify')} 108 + {submitting ? $_('oauth.twoFactorCode.verifying') : $_('oauth.twoFactorCode.verify')} 109 109 </button> 110 110 </div> 111 111 </form>
+1 -1
frontend/src/routes/OAuthConsent.svelte
··· 171 171 <h1>{$_('oauth.error.title')}</h1> 172 172 <div class="error">{error}</div> 173 173 <button type="button" onclick={() => navigate('/login')}> 174 - {$_('common.backToLogin')} 174 + {$_('verify.backToLogin')} 175 175 </button> 176 176 </div> 177 177 {:else if consentData}
+1 -1
frontend/src/routes/OAuthTotp.svelte
··· 121 121 {$_('common.cancel')} 122 122 </button> 123 123 <button type="submit" class="submit-btn" disabled={submitting || !canSubmit}> 124 - {submitting ? $_('common.verifying') : $_('common.verify')} 124 + {submitting ? $_('oauth.totp.verifying') : $_('oauth.totp.verify')} 125 125 </button> 126 126 </div> 127 127 </form>
+3 -3
frontend/src/routes/Register.svelte
··· 145 145 case 'info': return $_('register.subtitle') 146 146 case 'key-choice': return $_('register.subtitleKeyChoice') 147 147 case 'initial-did-doc': return $_('register.subtitleInitialDidDoc') 148 - case 'creating': return $_('common.creating') 148 + case 'creating': return $_('register.creating') 149 149 case 'verify': return $_('register.subtitleVerify', { values: { channel: channelLabel(flow.info.verificationChannel) } }) 150 150 case 'updated-did-doc': return $_('register.subtitleUpdatedDidDoc') 151 151 case 'activating': return $_('register.subtitleActivating') ··· 375 375 {/if} 376 376 377 377 <button type="submit" disabled={flow.state.submitting}> 378 - {flow.state.submitting ? $_('common.creating') : $_('register.createButton')} 378 + {flow.state.submitting ? $_('register.creating') : $_('register.createButton')} 379 379 </button> 380 380 </form> 381 381 ··· 413 413 /> 414 414 415 415 {:else if flow.state.step === 'creating'} 416 - <p class="loading">{$_('common.creating')}</p> 416 + <p class="loading">{$_('register.creating')}</p> 417 417 418 418 {:else if flow.state.step === 'verify'} 419 419 <VerificationStep {flow} />
+1 -1
frontend/src/routes/RegisterPasskey.svelte
··· 408 408 </div> 409 409 410 410 <button type="submit" disabled={flow.state.submitting}> 411 - {flow.state.submitting ? $_('common.creating') : $_('registerPasskey.continue')} 411 + {flow.state.submitting ? $_('registerPasskey.creating') : $_('registerPasskey.continue')} 412 412 </button> 413 413 </form> 414 414
+2 -2
frontend/src/routes/RepoExplorer.svelte
··· 417 417 </div> 418 418 <div class="actions"> 419 419 <button type="submit" class="primary" disabled={saving || !!jsonError}> 420 - {saving ? $_('common.saving') : $_('repoExplorer.updateRecord')} 420 + {saving ? $_('repoExplorer.saving') : $_('repoExplorer.updateRecord')} 421 421 </button> 422 422 <button type="button" class="danger" onclick={handleDelete} disabled={saving}> 423 423 {$_('common.delete')} ··· 464 464 </div> 465 465 <div class="actions"> 466 466 <button type="submit" class="primary" disabled={saving || !!jsonError || !newCollection.trim()}> 467 - {saving ? $_('common.creating') : $_('repoExplorer.createRecord')} 467 + {saving ? $_('repoExplorer.creating') : $_('repoExplorer.createRecord')} 468 468 </button> 469 469 <button type="button" class="secondary" onclick={goBack}> 470 470 {$_('common.cancel')}
+2 -2
frontend/src/routes/RequestPasskeyRecovery.svelte
··· 36 36 <h1>{$_('requestPasskeyRecovery.successTitle')}</h1> 37 37 <p class="subtitle">{$_('requestPasskeyRecovery.successMessage')}</p> 38 38 <p class="info-text">{$_('requestPasskeyRecovery.successInfo')}</p> 39 - <button onclick={() => navigate('/login')}>{$_('common.backToLogin')}</button> 39 + <button onclick={() => navigate('/login')}>{$_('requestPasskeyRecovery.backToLogin')}</button> 40 40 </div> 41 41 {:else} 42 42 <h1>{$_('requestPasskeyRecovery.title')}</h1> ··· 71 71 {/if} 72 72 73 73 <p class="link-text"> 74 - <a href="#/login">{$_('common.backToLogin')}</a> 74 + <a href="#/login">{$_('requestPasskeyRecovery.backToLogin')}</a> 75 75 </p> 76 76 </div> 77 77
+1 -1
frontend/src/routes/ResetPassword.svelte
··· 141 141 {/if} 142 142 143 143 <p class="link-text"> 144 - <a href="#/login">{$_('common.backToLogin')}</a> 144 + <a href="#/login">{$_('resetPassword.backToLogin')}</a> 145 145 </p> 146 146 </div> 147 147
+3 -341
frontend/src/routes/Settings.svelte
··· 40 40 let deleteToken = $state('') 41 41 let deleteTokenSent = $state(false) 42 42 let exportLoading = $state(false) 43 - let exportBlobsLoading = $state(false) 44 43 let passwordLoading = $state(false) 45 44 let currentPassword = $state('') 46 45 let newPassword = $state('') ··· 174 173 exportLoading = false 175 174 } 176 175 } 177 - async function handleExportBlobs() { 178 - if (!auth.session) return 179 - exportBlobsLoading = true 180 - message = null 181 - try { 182 - const response = await fetch('/xrpc/_backup.exportBlobs', { 183 - headers: { 184 - 'Authorization': `Bearer ${auth.session.accessJwt}` 185 - } 186 - }) 187 - if (!response.ok) { 188 - const err = await response.json().catch(() => ({ message: 'Export failed' })) 189 - throw new Error(err.message || 'Export failed') 190 - } 191 - const blob = await response.blob() 192 - if (blob.size === 0) { 193 - showMessage('success', $_('settings.messages.noBlobsToExport')) 194 - return 195 - } 196 - const url = URL.createObjectURL(blob) 197 - const a = document.createElement('a') 198 - a.href = url 199 - a.download = `${auth.session.handle}-blobs.zip` 200 - document.body.appendChild(a) 201 - a.click() 202 - document.body.removeChild(a) 203 - URL.revokeObjectURL(url) 204 - showMessage('success', $_('settings.messages.blobsExported')) 205 - } catch (e) { 206 - showMessage('error', e instanceof Error ? e.message : $_('settings.messages.exportFailed')) 207 - } finally { 208 - exportBlobsLoading = false 209 - } 210 - } 211 - 212 - interface BackupInfo { 213 - id: string 214 - repoRev: string 215 - repoRootCid: string 216 - blockCount: number 217 - sizeBytes: number 218 - createdAt: string 219 - } 220 - let backups = $state<BackupInfo[]>([]) 221 - let backupEnabled = $state(true) 222 - let backupsLoading = $state(false) 223 - let createBackupLoading = $state(false) 224 - let restoreFile = $state<File | null>(null) 225 - let restoreLoading = $state(false) 226 - 227 - async function loadBackups() { 228 - if (!auth.session) return 229 - backupsLoading = true 230 - try { 231 - const result = await api.listBackups(auth.session.accessJwt) 232 - backups = result.backups 233 - backupEnabled = result.backupEnabled 234 - } catch (e) { 235 - console.error('Failed to load backups:', e) 236 - } finally { 237 - backupsLoading = false 238 - } 239 - } 240 - 241 - onMount(() => { 242 - loadBackups() 243 - }) 244 - 245 - async function handleToggleBackup() { 246 - if (!auth.session) return 247 - const newEnabled = !backupEnabled 248 - backupsLoading = true 249 - try { 250 - await api.setBackupEnabled(auth.session.accessJwt, newEnabled) 251 - backupEnabled = newEnabled 252 - showMessage('success', newEnabled ? $_('settings.backups.enabled') : $_('settings.backups.disabled')) 253 - } catch (e) { 254 - showMessage('error', e instanceof ApiError ? e.message : $_('settings.backups.toggleFailed')) 255 - } finally { 256 - backupsLoading = false 257 - } 258 - } 259 - 260 - async function handleCreateBackup() { 261 - if (!auth.session) return 262 - createBackupLoading = true 263 - message = null 264 - try { 265 - await api.createBackup(auth.session.accessJwt) 266 - await loadBackups() 267 - showMessage('success', $_('settings.backups.created')) 268 - } catch (e) { 269 - showMessage('error', e instanceof ApiError ? e.message : $_('settings.backups.createFailed')) 270 - } finally { 271 - createBackupLoading = false 272 - } 273 - } 274 - 275 - async function handleDownloadBackup(id: string, rev: string) { 276 - if (!auth.session) return 277 - try { 278 - const blob = await api.getBackup(auth.session.accessJwt, id) 279 - const url = URL.createObjectURL(blob) 280 - const a = document.createElement('a') 281 - a.href = url 282 - a.download = `${auth.session.handle}-${rev}.car` 283 - document.body.appendChild(a) 284 - a.click() 285 - document.body.removeChild(a) 286 - URL.revokeObjectURL(url) 287 - } catch (e) { 288 - showMessage('error', e instanceof ApiError ? e.message : $_('settings.backups.downloadFailed')) 289 - } 290 - } 291 - 292 - async function handleDeleteBackup(id: string) { 293 - if (!auth.session) return 294 - try { 295 - await api.deleteBackup(auth.session.accessJwt, id) 296 - await loadBackups() 297 - showMessage('success', $_('settings.backups.deleted')) 298 - } catch (e) { 299 - showMessage('error', e instanceof ApiError ? e.message : $_('settings.backups.deleteFailed')) 300 - } 301 - } 302 - 303 - function handleFileSelect(e: Event) { 304 - const input = e.target as HTMLInputElement 305 - if (input.files && input.files.length > 0) { 306 - restoreFile = input.files[0] 307 - } 308 - } 309 - 310 - async function handleRestore() { 311 - if (!auth.session || !restoreFile) return 312 - restoreLoading = true 313 - message = null 314 - try { 315 - const buffer = await restoreFile.arrayBuffer() 316 - const car = new Uint8Array(buffer) 317 - await api.importRepo(auth.session.accessJwt, car) 318 - showMessage('success', $_('settings.backups.restored')) 319 - restoreFile = null 320 - } catch (e) { 321 - showMessage('error', e instanceof ApiError ? e.message : $_('settings.backups.restoreFailed')) 322 - } finally { 323 - restoreLoading = false 324 - } 325 - } 326 - 327 - function formatBytes(bytes: number): string { 328 - if (bytes < 1024) return `${bytes} B` 329 - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` 330 - return `${(bytes / (1024 * 1024)).toFixed(1)} MB` 331 - } 332 - 333 - function formatDate(iso: string): string { 334 - return new Date(iso).toLocaleDateString(undefined, { 335 - year: 'numeric', 336 - month: 'short', 337 - day: 'numeric', 338 - hour: '2-digit', 339 - minute: '2-digit' 340 - }) 341 - } 342 - 343 176 async function handleChangePassword(e: Event) { 344 177 e.preventDefault() 345 178 if (!auth.session || !currentPassword || !newPassword || !confirmNewPassword) return ··· 490 323 /> 491 324 </div> 492 325 <button type="submit" disabled={handleLoading || !newHandle}> 493 - {handleLoading ? $_('common.verifying') : $_('settings.verifyAndUpdate')} 326 + {handleLoading ? $_('settings.verifying') : $_('settings.verifyAndUpdate')} 494 327 </button> 495 328 </form> 496 329 </div> ··· 561 394 <section> 562 395 <h2>{$_('settings.exportData')}</h2> 563 396 <p class="description">{$_('settings.exportDataDescription')}</p> 564 - <div class="export-buttons"> 565 - <button onclick={handleExportRepo} disabled={exportLoading}> 566 - {exportLoading ? $_('settings.exporting') : $_('settings.downloadRepo')} 567 - </button> 568 - <button onclick={handleExportBlobs} disabled={exportBlobsLoading} class="secondary"> 569 - {exportBlobsLoading ? $_('settings.exporting') : $_('settings.downloadBlobs')} 570 - </button> 571 - </div> 572 - </section> 573 - <section class="backups-section"> 574 - <h2>{$_('settings.backups.title')}</h2> 575 - <p class="description">{$_('settings.backups.description')}</p> 576 - 577 - <label class="checkbox-label"> 578 - <input type="checkbox" checked={backupEnabled} onchange={handleToggleBackup} disabled={backupsLoading} /> 579 - <span>{$_('settings.backups.enableAutomatic')}</span> 580 - </label> 581 - 582 - {#if backupsLoading} 583 - <p class="loading">{$_('common.loading')}</p> 584 - {:else if backups.length > 0} 585 - <ul class="backup-list"> 586 - {#each backups as backup} 587 - <li class="backup-item"> 588 - <div class="backup-info"> 589 - <span class="backup-date">{formatDate(backup.createdAt)}</span> 590 - <span class="backup-size">{formatBytes(backup.sizeBytes)}</span> 591 - <span class="backup-blocks">{backup.blockCount} {$_('settings.backups.blocks')}</span> 592 - </div> 593 - <div class="backup-actions"> 594 - <button class="small" onclick={() => handleDownloadBackup(backup.id, backup.repoRev)}> 595 - {$_('settings.backups.download')} 596 - </button> 597 - <button class="small danger" onclick={() => handleDeleteBackup(backup.id)}> 598 - {$_('settings.backups.delete')} 599 - </button> 600 - </div> 601 - </li> 602 - {/each} 603 - </ul> 604 - {:else} 605 - <p class="empty">{$_('settings.backups.noBackups')}</p> 606 - {/if} 607 - 608 - <button onclick={handleCreateBackup} disabled={createBackupLoading || !backupEnabled}> 609 - {createBackupLoading ? $_('common.creating') : $_('settings.backups.createNow')} 397 + <button onclick={handleExportRepo} disabled={exportLoading}> 398 + {exportLoading ? $_('settings.exporting') : $_('settings.downloadRepo')} 610 399 </button> 611 - </section> 612 - <section class="restore-section"> 613 - <h2>{$_('settings.backups.restoreTitle')}</h2> 614 - <p class="description">{$_('settings.backups.restoreDescription')}</p> 615 - 616 - <div class="field"> 617 - <label for="restore-file">{$_('settings.backups.selectFile')}</label> 618 - <input 619 - id="restore-file" 620 - type="file" 621 - accept=".car" 622 - onchange={handleFileSelect} 623 - disabled={restoreLoading} 624 - /> 625 - </div> 626 - 627 - {#if restoreFile} 628 - <div class="restore-preview"> 629 - <p>{$_('settings.backups.selectedFile')}: {restoreFile.name} ({formatBytes(restoreFile.size)})</p> 630 - <button onclick={handleRestore} disabled={restoreLoading} class="danger"> 631 - {restoreLoading ? $_('settings.backups.restoring') : $_('settings.backups.restore')} 632 - </button> 633 - </div> 634 - {/if} 635 400 </section> 636 401 </div> 637 402 <section class="danger-zone"> ··· 893 658 white-space: nowrap; 894 659 border-left: 1px solid var(--border-color); 895 660 background: var(--bg-card); 896 - } 897 - 898 - .checkbox-label { 899 - display: flex; 900 - align-items: center; 901 - gap: var(--space-2); 902 - cursor: pointer; 903 - margin-bottom: var(--space-4); 904 - } 905 - 906 - .checkbox-label input[type="checkbox"] { 907 - width: 18px; 908 - height: 18px; 909 - cursor: pointer; 910 - } 911 - 912 - .backup-list { 913 - list-style: none; 914 - padding: 0; 915 - margin: 0 0 var(--space-4) 0; 916 - display: flex; 917 - flex-direction: column; 918 - gap: var(--space-2); 919 - } 920 - 921 - .backup-item { 922 - display: flex; 923 - justify-content: space-between; 924 - align-items: center; 925 - padding: var(--space-3); 926 - background: var(--bg-card); 927 - border: 1px solid var(--border-color); 928 - border-radius: var(--radius-md); 929 - gap: var(--space-4); 930 - } 931 - 932 - .backup-info { 933 - display: flex; 934 - gap: var(--space-4); 935 - font-size: var(--text-sm); 936 - flex-wrap: wrap; 937 - } 938 - 939 - .backup-date { 940 - font-weight: 500; 941 - } 942 - 943 - .backup-size, 944 - .backup-blocks { 945 - color: var(--text-secondary); 946 - } 947 - 948 - .backup-actions { 949 - display: flex; 950 - gap: var(--space-2); 951 - flex-shrink: 0; 952 - } 953 - 954 - button.small { 955 - padding: var(--space-1) var(--space-2); 956 - font-size: var(--text-xs); 957 - } 958 - 959 - .empty, 960 - .loading { 961 - color: var(--text-secondary); 962 - font-size: var(--text-sm); 963 - margin-bottom: var(--space-4); 964 - } 965 - 966 - .restore-preview { 967 - background: var(--bg-card); 968 - border: 1px solid var(--border-color); 969 - border-radius: var(--radius-md); 970 - padding: var(--space-4); 971 - margin-top: var(--space-3); 972 - } 973 - 974 - .restore-preview p { 975 - margin: 0 0 var(--space-3) 0; 976 - font-size: var(--text-sm); 977 - } 978 - 979 - .export-buttons { 980 - display: flex; 981 - gap: var(--space-2); 982 - flex-wrap: wrap; 983 - } 984 - 985 - @media (max-width: 640px) { 986 - .backup-item { 987 - flex-direction: column; 988 - align-items: flex-start; 989 - } 990 - 991 - .backup-actions { 992 - width: 100%; 993 - margin-top: var(--space-2); 994 - } 995 - 996 - .backup-actions button { 997 - flex: 1; 998 - } 999 661 } 1000 662 </style>
+8 -8
frontend/src/routes/Verify.svelte
··· 225 225 <div class="verify-page"> 226 226 {#if autoSubmitting} 227 227 <div class="loading-container"> 228 - <h1>{$_('common.verifying')}</h1> 228 + <h1>{$_('verify.verifying')}</h1> 229 229 <p class="subtitle">{$_('verify.pleaseWait')}</p> 230 230 </div> 231 231 {:else if success} ··· 235 235 <p class="subtitle">{$_('verify.emailUpdated')}</p> 236 236 <p class="info-text">{$_('verify.emailUpdatedInfo')}</p> 237 237 <div class="actions"> 238 - <a href="#/settings" class="btn">{$_('common.backToSettings')}</a> 238 + <a href="#/settings" class="btn">{$_('verify.backToSettings')}</a> 239 239 </div> 240 240 {:else if successPurpose === 'migration' || successPurpose === 'signup'} 241 241 <p class="subtitle">{$_('verify.channelVerified', { values: { channel: channelLabel(successChannel || '') } })}</p> ··· 301 301 </form> 302 302 303 303 <p class="link-text"> 304 - <a href="#/settings">{$_('common.backToSettings')}</a> 304 + <a href="#/settings">{$_('verify.backToSettings')}</a> 305 305 </p> 306 306 {/if} 307 307 {:else if mode === 'token'} ··· 347 347 </div> 348 348 349 349 <button type="submit" disabled={submitting || !verificationCode.trim() || !identifier.trim()}> 350 - {submitting ? $_('common.verifying') : $_('common.verify')} 350 + {submitting ? $_('verify.verifying') : $_('verify.verify')} 351 351 </button> 352 352 353 353 <button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode || !identifier.trim()}> 354 - {resendingCode ? $_('common.sending') : $_('common.resendCode')} 354 + {resendingCode ? $_('verify.sending') : $_('verify.resendCode')} 355 355 </button> 356 356 </form> 357 357 358 358 <p class="link-text"> 359 - <a href="#/login">{$_('common.backToLogin')}</a> 359 + <a href="#/login">{$_('verify.backToLogin')}</a> 360 360 </p> 361 361 {:else if pendingVerification} 362 362 <h1>{$_('verify.title')}</h1> ··· 390 390 </div> 391 391 392 392 <button type="submit" disabled={submitting || !verificationCode.trim()}> 393 - {submitting ? $_('common.verifying') : $_('common.verify')} 393 + {submitting ? $_('verify.verifying') : $_('verify.verifyButton')} 394 394 </button> 395 395 396 396 <button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}> 397 - {resendingCode ? $_('common.sending') : $_('common.resendCode')} 397 + {resendingCode ? $_('verify.resending') : $_('verify.resendCode')} 398 398 </button> 399 399 </form> 400 400
+5 -5
frontend/src/styles/base.css
··· 54 54 } 55 55 56 56 a { 57 - color: var(--accent); 58 - text-decoration: underline; 59 - text-underline-offset: 2px; 57 + color: var(--secondary); 58 + text-decoration: none; 59 + transition: color 0.3s ease; 60 60 } 61 61 62 62 a:hover { 63 - color: var(--accent-hover); 63 + color: var(--secondary-hover); 64 + text-decoration: none; 64 65 } 65 66 66 67 ::selection { ··· 371 372 color: var(--text-secondary); 372 373 font-size: var(--text-sm); 373 374 margin-bottom: var(--space-3); 374 - text-decoration: none; 375 375 } 376 376 377 377 .back-link:hover {
-90
frontend/src/styles/migration.css
··· 190 190 191 191 .current-info .value { 192 192 font-weight: var(--font-medium); 193 - word-break: break-all; 194 - } 195 - 196 - .current-info .value.mono { 197 - font-family: var(--font-mono); 198 - font-size: var(--text-sm); 199 193 } 200 194 201 195 .review-card { ··· 274 268 text-align: center; 275 269 color: var(--text-secondary); 276 270 font-size: var(--text-sm); 277 - } 278 - 279 - .blob-progress { 280 - margin: var(--space-4) 0; 281 - } 282 - 283 - .blob-progress-bar { 284 - height: 8px; 285 - background: var(--bg-primary); 286 - border-radius: var(--radius-md); 287 - overflow: hidden; 288 - margin-bottom: var(--space-2); 289 - } 290 - 291 - .blob-progress-fill { 292 - height: 100%; 293 - background: var(--accent); 294 - transition: width var(--transition-slow); 295 - } 296 - 297 - .blob-progress-text { 298 - text-align: center; 299 - color: var(--text-secondary); 300 - font-size: var(--text-sm); 301 - margin: 0; 302 271 } 303 272 304 273 .success-content { ··· 598 567 font-size: var(--text-sm); 599 568 font-style: italic; 600 569 } 601 - 602 - .file-input-container { 603 - display: flex; 604 - flex-direction: column; 605 - gap: var(--space-3); 606 - } 607 - 608 - .file-info { 609 - display: flex; 610 - gap: var(--space-2); 611 - align-items: center; 612 - padding: var(--space-3); 613 - background: var(--bg-primary); 614 - border-radius: var(--radius-md); 615 - } 616 - 617 - .file-name { 618 - font-weight: var(--font-medium); 619 - } 620 - 621 - .file-size { 622 - color: var(--text-secondary); 623 - font-size: var(--text-sm); 624 - } 625 - 626 - .step-content textarea { 627 - width: 100%; 628 - font-family: var(--font-mono); 629 - font-size: var(--text-sm); 630 - padding: var(--space-3); 631 - border: 1px solid var(--border-color); 632 - border-radius: var(--radius-md); 633 - background: var(--bg-input); 634 - color: var(--text-primary); 635 - resize: vertical; 636 - } 637 - 638 - .step-content textarea:focus { 639 - outline: none; 640 - border-color: var(--accent); 641 - } 642 - 643 - .message { 644 - padding: var(--space-4); 645 - border-radius: var(--radius-lg); 646 - margin-bottom: var(--space-4); 647 - } 648 - 649 - .message.success { 650 - background: var(--success-bg); 651 - color: var(--success-text); 652 - border: 1px solid var(--success-border); 653 - } 654 - 655 - .message.error { 656 - background: var(--error-bg); 657 - color: var(--error-text); 658 - border: 1px solid var(--error-border); 659 - }
+35 -35
frontend/src/tests/Comms.test.ts
··· 29 29 beforeEach(() => { 30 30 setupAuthenticatedUser(); 31 31 mockEndpoint( 32 - "_account.getNotificationPrefs", 32 + "com.tranquil.account.getNotificationPrefs", 33 33 () => jsonResponse(mockData.notificationPrefs()), 34 34 ); 35 35 mockEndpoint( ··· 37 37 () => jsonResponse(mockData.describeServer()), 38 38 ); 39 39 mockEndpoint( 40 - "_account.getNotificationHistory", 40 + "com.tranquil.account.getNotificationHistory", 41 41 () => jsonResponse({ notifications: [] }), 42 42 ); 43 43 }); ··· 67 67 () => jsonResponse(mockData.describeServer()), 68 68 ); 69 69 mockEndpoint( 70 - "_account.getNotificationHistory", 70 + "com.tranquil.account.getNotificationHistory", 71 71 () => jsonResponse({ notifications: [] }), 72 72 ); 73 73 }); 74 74 it("shows loading text while fetching preferences", async () => { 75 - mockEndpoint("_account.getNotificationPrefs", async () => { 75 + mockEndpoint("com.tranquil.account.getNotificationPrefs", async () => { 76 76 await new Promise((resolve) => setTimeout(resolve, 100)); 77 77 return jsonResponse(mockData.notificationPrefs()); 78 78 }); ··· 88 88 () => jsonResponse(mockData.describeServer()), 89 89 ); 90 90 mockEndpoint( 91 - "_account.getNotificationHistory", 91 + "com.tranquil.account.getNotificationHistory", 92 92 () => jsonResponse({ notifications: [] }), 93 93 ); 94 94 }); 95 95 it("displays all four channel options", async () => { 96 96 mockEndpoint( 97 - "_account.getNotificationPrefs", 97 + "com.tranquil.account.getNotificationPrefs", 98 98 () => jsonResponse(mockData.notificationPrefs()), 99 99 ); 100 100 render(Comms); ··· 111 111 }); 112 112 it("email channel is always selectable", async () => { 113 113 mockEndpoint( 114 - "_account.getNotificationPrefs", 114 + "com.tranquil.account.getNotificationPrefs", 115 115 () => jsonResponse(mockData.notificationPrefs()), 116 116 ); 117 117 render(Comms); ··· 122 122 }); 123 123 it("discord channel is disabled when not configured", async () => { 124 124 mockEndpoint( 125 - "_account.getNotificationPrefs", 125 + "com.tranquil.account.getNotificationPrefs", 126 126 () => jsonResponse(mockData.notificationPrefs({ discordId: null })), 127 127 ); 128 128 render(Comms); ··· 133 133 }); 134 134 it("discord channel is enabled when configured", async () => { 135 135 mockEndpoint( 136 - "_account.getNotificationPrefs", 136 + "com.tranquil.account.getNotificationPrefs", 137 137 () => 138 138 jsonResponse(mockData.notificationPrefs({ discordId: "123456789" })), 139 139 ); ··· 145 145 }); 146 146 it("shows hint for disabled channels", async () => { 147 147 mockEndpoint( 148 - "_account.getNotificationPrefs", 148 + "com.tranquil.account.getNotificationPrefs", 149 149 () => jsonResponse(mockData.notificationPrefs()), 150 150 ); 151 151 render(Comms); ··· 156 156 }); 157 157 it("selects current preferred channel", async () => { 158 158 mockEndpoint( 159 - "_account.getNotificationPrefs", 159 + "com.tranquil.account.getNotificationPrefs", 160 160 () => 161 161 jsonResponse( 162 162 mockData.notificationPrefs({ preferredChannel: "email" }), ··· 179 179 () => jsonResponse(mockData.describeServer()), 180 180 ); 181 181 mockEndpoint( 182 - "_account.getNotificationHistory", 182 + "com.tranquil.account.getNotificationHistory", 183 183 () => jsonResponse({ notifications: [] }), 184 184 ); 185 185 }); 186 186 it("displays email as readonly with current value", async () => { 187 187 mockEndpoint( 188 - "_account.getNotificationPrefs", 188 + "com.tranquil.account.getNotificationPrefs", 189 189 () => jsonResponse(mockData.notificationPrefs()), 190 190 ); 191 191 render(Comms); ··· 199 199 }); 200 200 it("displays all channel inputs with current values", async () => { 201 201 mockEndpoint( 202 - "_account.getNotificationPrefs", 202 + "com.tranquil.account.getNotificationPrefs", 203 203 () => 204 204 jsonResponse(mockData.notificationPrefs({ 205 205 discordId: "123456789", ··· 231 231 () => jsonResponse(mockData.describeServer()), 232 232 ); 233 233 mockEndpoint( 234 - "_account.getNotificationHistory", 234 + "com.tranquil.account.getNotificationHistory", 235 235 () => jsonResponse({ notifications: [] }), 236 236 ); 237 237 }); 238 238 it("shows Primary badge for email", async () => { 239 239 mockEndpoint( 240 - "_account.getNotificationPrefs", 240 + "com.tranquil.account.getNotificationPrefs", 241 241 () => jsonResponse(mockData.notificationPrefs()), 242 242 ); 243 243 render(Comms); ··· 247 247 }); 248 248 it("shows Verified badge for verified discord", async () => { 249 249 mockEndpoint( 250 - "_account.getNotificationPrefs", 250 + "com.tranquil.account.getNotificationPrefs", 251 251 () => 252 252 jsonResponse(mockData.notificationPrefs({ 253 253 discordId: "123456789", ··· 262 262 }); 263 263 it("shows Not verified badge for unverified discord", async () => { 264 264 mockEndpoint( 265 - "_account.getNotificationPrefs", 265 + "com.tranquil.account.getNotificationPrefs", 266 266 () => 267 267 jsonResponse(mockData.notificationPrefs({ 268 268 discordId: "123456789", ··· 276 276 }); 277 277 it("does not show badge when channel not configured", async () => { 278 278 mockEndpoint( 279 - "_account.getNotificationPrefs", 279 + "com.tranquil.account.getNotificationPrefs", 280 280 () => jsonResponse(mockData.notificationPrefs()), 281 281 ); 282 282 render(Comms); ··· 294 294 () => jsonResponse(mockData.describeServer()), 295 295 ); 296 296 mockEndpoint( 297 - "_account.getNotificationHistory", 297 + "com.tranquil.account.getNotificationHistory", 298 298 () => jsonResponse({ notifications: [] }), 299 299 ); 300 300 }); 301 301 it("calls updateNotificationPrefs with correct data", async () => { 302 302 let capturedBody: Record<string, unknown> | null = null; 303 303 mockEndpoint( 304 - "_account.getNotificationPrefs", 304 + "com.tranquil.account.getNotificationPrefs", 305 305 () => jsonResponse(mockData.notificationPrefs()), 306 306 ); 307 307 mockEndpoint( 308 - "_account.updateNotificationPrefs", 308 + "com.tranquil.account.updateNotificationPrefs", 309 309 (_url, options) => { 310 310 capturedBody = JSON.parse((options?.body as string) || "{}"); 311 311 return jsonResponse({ success: true }); ··· 329 329 }); 330 330 it("shows loading state while saving", async () => { 331 331 mockEndpoint( 332 - "_account.getNotificationPrefs", 332 + "com.tranquil.account.getNotificationPrefs", 333 333 () => jsonResponse(mockData.notificationPrefs()), 334 334 ); 335 - mockEndpoint("_account.updateNotificationPrefs", async () => { 335 + mockEndpoint("com.tranquil.account.updateNotificationPrefs", async () => { 336 336 await new Promise((resolve) => setTimeout(resolve, 100)); 337 337 return jsonResponse({ success: true }); 338 338 }); ··· 350 350 }); 351 351 it("shows success message after saving", async () => { 352 352 mockEndpoint( 353 - "_account.getNotificationPrefs", 353 + "com.tranquil.account.getNotificationPrefs", 354 354 () => jsonResponse(mockData.notificationPrefs()), 355 355 ); 356 356 mockEndpoint( 357 - "_account.updateNotificationPrefs", 357 + "com.tranquil.account.updateNotificationPrefs", 358 358 () => jsonResponse({ success: true }), 359 359 ); 360 360 render(Comms); ··· 372 372 }); 373 373 it("shows error when save fails", async () => { 374 374 mockEndpoint( 375 - "_account.getNotificationPrefs", 375 + "com.tranquil.account.getNotificationPrefs", 376 376 () => jsonResponse(mockData.notificationPrefs()), 377 377 ); 378 378 mockEndpoint( 379 - "_account.updateNotificationPrefs", 379 + "com.tranquil.account.updateNotificationPrefs", 380 380 () => 381 381 errorResponse("InvalidRequest", "Invalid channel configuration", 400), 382 382 ); ··· 400 400 }); 401 401 it("reloads preferences after successful save", async () => { 402 402 let loadCount = 0; 403 - mockEndpoint("_account.getNotificationPrefs", () => { 403 + mockEndpoint("com.tranquil.account.getNotificationPrefs", () => { 404 404 loadCount++; 405 405 return jsonResponse(mockData.notificationPrefs()); 406 406 }); 407 407 mockEndpoint( 408 - "_account.updateNotificationPrefs", 408 + "com.tranquil.account.updateNotificationPrefs", 409 409 () => jsonResponse({ success: true }), 410 410 ); 411 411 render(Comms); ··· 430 430 () => jsonResponse(mockData.describeServer()), 431 431 ); 432 432 mockEndpoint( 433 - "_account.getNotificationHistory", 433 + "com.tranquil.account.getNotificationHistory", 434 434 () => jsonResponse({ notifications: [] }), 435 435 ); 436 436 }); 437 437 it("enables discord channel after entering discord ID", async () => { 438 438 mockEndpoint( 439 - "_account.getNotificationPrefs", 439 + "com.tranquil.account.getNotificationPrefs", 440 440 () => jsonResponse(mockData.notificationPrefs()), 441 441 ); 442 442 render(Comms); ··· 453 453 }); 454 454 it("allows selecting a configured channel", async () => { 455 455 mockEndpoint( 456 - "_account.getNotificationPrefs", 456 + "com.tranquil.account.getNotificationPrefs", 457 457 () => 458 458 jsonResponse(mockData.notificationPrefs({ 459 459 discordId: "123456789", ··· 480 480 () => jsonResponse(mockData.describeServer()), 481 481 ); 482 482 mockEndpoint( 483 - "_account.getNotificationHistory", 483 + "com.tranquil.account.getNotificationHistory", 484 484 () => jsonResponse({ notifications: [] }), 485 485 ); 486 486 }); 487 487 it("shows error when loading preferences fails", async () => { 488 488 mockEndpoint( 489 - "_account.getNotificationPrefs", 489 + "com.tranquil.account.getNotificationPrefs", 490 490 () => errorResponse("InternalError", "Database connection failed", 500), 491 491 ); 492 492 render(Comms);
+2 -2
frontend/src/tests/Settings.test.ts
··· 8 8 mockData, 9 9 mockEndpoint, 10 10 setupAuthenticatedUser, 11 - setupDefaultMocks, 11 + setupFetchMock, 12 12 setupUnauthenticatedUser, 13 13 } from "./mocks"; 14 14 describe("Settings", () => { 15 15 beforeEach(() => { 16 16 clearMocks(); 17 - setupDefaultMocks(); 17 + setupFetchMock(); 18 18 globalThis.confirm = vi.fn(() => true); 19 19 }); 20 20 describe("authentication guard", () => {
-491
frontend/src/tests/migration/offline-flow.test.ts
··· 1 - import { beforeEach, describe, expect, it, vi } from "vitest"; 2 - import { createOfflineInboundMigrationFlow } from "../../lib/migration/offline-flow.svelte"; 3 - 4 - const OFFLINE_STORAGE_KEY = "tranquil_offline_migration_state"; 5 - 6 - describe("migration/offline-flow", () => { 7 - beforeEach(() => { 8 - localStorage.removeItem(OFFLINE_STORAGE_KEY); 9 - vi.restoreAllMocks(); 10 - }); 11 - 12 - describe("createOfflineInboundMigrationFlow", () => { 13 - it("creates flow with initial state", () => { 14 - const flow = createOfflineInboundMigrationFlow(); 15 - 16 - expect(flow.state.direction).toBe("offline-inbound"); 17 - expect(flow.state.step).toBe("welcome"); 18 - expect(flow.state.userDid).toBe(""); 19 - expect(flow.state.carFile).toBeNull(); 20 - expect(flow.state.carFileName).toBe(""); 21 - expect(flow.state.carSizeBytes).toBe(0); 22 - expect(flow.state.rotationKey).toBe(""); 23 - expect(flow.state.rotationKeyDidKey).toBe(""); 24 - expect(flow.state.targetHandle).toBe(""); 25 - expect(flow.state.targetEmail).toBe(""); 26 - expect(flow.state.targetPassword).toBe(""); 27 - expect(flow.state.inviteCode).toBe(""); 28 - expect(flow.state.localAccessToken).toBeNull(); 29 - expect(flow.state.localRefreshToken).toBeNull(); 30 - expect(flow.state.error).toBeNull(); 31 - }); 32 - 33 - it("initializes progress correctly", () => { 34 - const flow = createOfflineInboundMigrationFlow(); 35 - 36 - expect(flow.state.progress.repoExported).toBe(false); 37 - expect(flow.state.progress.repoImported).toBe(false); 38 - expect(flow.state.progress.blobsTotal).toBe(0); 39 - expect(flow.state.progress.blobsMigrated).toBe(0); 40 - expect(flow.state.progress.blobsFailed).toEqual([]); 41 - expect(flow.state.progress.prefsMigrated).toBe(false); 42 - expect(flow.state.progress.plcSigned).toBe(false); 43 - expect(flow.state.progress.activated).toBe(false); 44 - expect(flow.state.progress.deactivated).toBe(false); 45 - expect(flow.state.progress.currentOperation).toBe(""); 46 - }); 47 - }); 48 - 49 - describe("setUserDid", () => { 50 - it("sets the user DID", () => { 51 - const flow = createOfflineInboundMigrationFlow(); 52 - 53 - flow.setUserDid("did:plc:abc123"); 54 - 55 - expect(flow.state.userDid).toBe("did:plc:abc123"); 56 - }); 57 - 58 - it("saves state to localStorage", () => { 59 - const flow = createOfflineInboundMigrationFlow(); 60 - 61 - flow.setUserDid("did:plc:xyz789"); 62 - 63 - const stored = JSON.parse(localStorage.getItem(OFFLINE_STORAGE_KEY)!); 64 - expect(stored.userDid).toBe("did:plc:xyz789"); 65 - }); 66 - }); 67 - 68 - describe("setCarFile", () => { 69 - it("sets CAR file data", () => { 70 - const flow = createOfflineInboundMigrationFlow(); 71 - const carData = new Uint8Array([1, 2, 3, 4, 5]); 72 - 73 - flow.setCarFile(carData, "repo.car"); 74 - 75 - expect(flow.state.carFile).toEqual(carData); 76 - expect(flow.state.carFileName).toBe("repo.car"); 77 - expect(flow.state.carSizeBytes).toBe(5); 78 - }); 79 - 80 - it("saves file metadata to localStorage (not file content)", () => { 81 - const flow = createOfflineInboundMigrationFlow(); 82 - const carData = new Uint8Array([1, 2, 3, 4, 5]); 83 - 84 - flow.setCarFile(carData, "backup.car"); 85 - 86 - const stored = JSON.parse(localStorage.getItem(OFFLINE_STORAGE_KEY)!); 87 - expect(stored.carFileName).toBe("backup.car"); 88 - expect(stored.carSizeBytes).toBe(5); 89 - }); 90 - }); 91 - 92 - describe("setRotationKey", () => { 93 - it("sets the rotation key", () => { 94 - const flow = createOfflineInboundMigrationFlow(); 95 - 96 - flow.setRotationKey("abc123privatekey"); 97 - 98 - expect(flow.state.rotationKey).toBe("abc123privatekey"); 99 - }); 100 - 101 - it("does not save rotation key to localStorage (security)", () => { 102 - const flow = createOfflineInboundMigrationFlow(); 103 - 104 - flow.setRotationKey("supersecretkey"); 105 - 106 - const stored = localStorage.getItem(OFFLINE_STORAGE_KEY); 107 - if (stored) { 108 - const parsed = JSON.parse(stored); 109 - expect(parsed.rotationKey).toBeUndefined(); 110 - } 111 - }); 112 - }); 113 - 114 - describe("setTargetHandle", () => { 115 - it("sets the target handle", () => { 116 - const flow = createOfflineInboundMigrationFlow(); 117 - 118 - flow.setTargetHandle("alice.example.com"); 119 - 120 - expect(flow.state.targetHandle).toBe("alice.example.com"); 121 - }); 122 - 123 - it("saves to localStorage", () => { 124 - const flow = createOfflineInboundMigrationFlow(); 125 - 126 - flow.setTargetHandle("bob.example.com"); 127 - 128 - const stored = JSON.parse(localStorage.getItem(OFFLINE_STORAGE_KEY)!); 129 - expect(stored.targetHandle).toBe("bob.example.com"); 130 - }); 131 - }); 132 - 133 - describe("setTargetEmail", () => { 134 - it("sets the target email", () => { 135 - const flow = createOfflineInboundMigrationFlow(); 136 - 137 - flow.setTargetEmail("alice@example.com"); 138 - 139 - expect(flow.state.targetEmail).toBe("alice@example.com"); 140 - }); 141 - 142 - it("saves to localStorage", () => { 143 - const flow = createOfflineInboundMigrationFlow(); 144 - 145 - flow.setTargetEmail("bob@example.com"); 146 - 147 - const stored = JSON.parse(localStorage.getItem(OFFLINE_STORAGE_KEY)!); 148 - expect(stored.targetEmail).toBe("bob@example.com"); 149 - }); 150 - }); 151 - 152 - describe("setTargetPassword", () => { 153 - it("sets the target password", () => { 154 - const flow = createOfflineInboundMigrationFlow(); 155 - 156 - flow.setTargetPassword("securepassword123"); 157 - 158 - expect(flow.state.targetPassword).toBe("securepassword123"); 159 - }); 160 - 161 - it("does not save password to localStorage (security)", () => { 162 - const flow = createOfflineInboundMigrationFlow(); 163 - flow.setUserDid("did:plc:test"); 164 - 165 - flow.setTargetPassword("mypassword"); 166 - 167 - const stored = localStorage.getItem(OFFLINE_STORAGE_KEY); 168 - if (stored) { 169 - const parsed = JSON.parse(stored); 170 - expect(parsed.targetPassword).toBeUndefined(); 171 - } 172 - }); 173 - }); 174 - 175 - describe("setInviteCode", () => { 176 - it("sets the invite code", () => { 177 - const flow = createOfflineInboundMigrationFlow(); 178 - 179 - flow.setInviteCode("invite-abc123"); 180 - 181 - expect(flow.state.inviteCode).toBe("invite-abc123"); 182 - }); 183 - }); 184 - 185 - describe("setStep", () => { 186 - it("changes the current step", () => { 187 - const flow = createOfflineInboundMigrationFlow(); 188 - 189 - flow.setStep("provide-did"); 190 - 191 - expect(flow.state.step).toBe("provide-did"); 192 - }); 193 - 194 - it("clears error when changing step", () => { 195 - const flow = createOfflineInboundMigrationFlow(); 196 - flow.setError("Previous error"); 197 - 198 - flow.setStep("upload-car"); 199 - 200 - expect(flow.state.error).toBeNull(); 201 - }); 202 - 203 - it("saves step to localStorage", () => { 204 - const flow = createOfflineInboundMigrationFlow(); 205 - 206 - flow.setStep("provide-rotation-key"); 207 - 208 - const stored = JSON.parse(localStorage.getItem(OFFLINE_STORAGE_KEY)!); 209 - expect(stored.step).toBe("provide-rotation-key"); 210 - }); 211 - }); 212 - 213 - describe("setError", () => { 214 - it("sets the error message", () => { 215 - const flow = createOfflineInboundMigrationFlow(); 216 - 217 - flow.setError("Something went wrong"); 218 - 219 - expect(flow.state.error).toBe("Something went wrong"); 220 - }); 221 - 222 - it("saves error to localStorage", () => { 223 - const flow = createOfflineInboundMigrationFlow(); 224 - 225 - flow.setError("Connection failed"); 226 - 227 - const stored = JSON.parse(localStorage.getItem(OFFLINE_STORAGE_KEY)!); 228 - expect(stored.lastError).toBe("Connection failed"); 229 - }); 230 - }); 231 - 232 - describe("setProgress", () => { 233 - it("updates progress fields", () => { 234 - const flow = createOfflineInboundMigrationFlow(); 235 - 236 - flow.setProgress({ 237 - repoImported: true, 238 - currentOperation: "Importing...", 239 - }); 240 - 241 - expect(flow.state.progress.repoImported).toBe(true); 242 - expect(flow.state.progress.currentOperation).toBe("Importing..."); 243 - }); 244 - 245 - it("preserves other progress fields", () => { 246 - const flow = createOfflineInboundMigrationFlow(); 247 - flow.setProgress({ repoExported: true }); 248 - 249 - flow.setProgress({ repoImported: true }); 250 - 251 - expect(flow.state.progress.repoExported).toBe(true); 252 - expect(flow.state.progress.repoImported).toBe(true); 253 - }); 254 - }); 255 - 256 - describe("reset", () => { 257 - it("resets state to initial values", () => { 258 - const flow = createOfflineInboundMigrationFlow(); 259 - flow.setUserDid("did:plc:abc123"); 260 - flow.setTargetHandle("alice.example.com"); 261 - flow.setStep("review"); 262 - 263 - flow.reset(); 264 - 265 - expect(flow.state.step).toBe("welcome"); 266 - expect(flow.state.userDid).toBe(""); 267 - expect(flow.state.targetHandle).toBe(""); 268 - }); 269 - 270 - it("clears localStorage", () => { 271 - const flow = createOfflineInboundMigrationFlow(); 272 - flow.setUserDid("did:plc:abc123"); 273 - expect(localStorage.getItem(OFFLINE_STORAGE_KEY)).not.toBeNull(); 274 - 275 - flow.reset(); 276 - 277 - expect(localStorage.getItem(OFFLINE_STORAGE_KEY)).toBeNull(); 278 - }); 279 - }); 280 - 281 - describe("clearOfflineState", () => { 282 - it("removes state from localStorage", () => { 283 - const flow = createOfflineInboundMigrationFlow(); 284 - flow.setUserDid("did:plc:abc123"); 285 - expect(localStorage.getItem(OFFLINE_STORAGE_KEY)).not.toBeNull(); 286 - 287 - flow.clearOfflineState(); 288 - 289 - expect(localStorage.getItem(OFFLINE_STORAGE_KEY)).toBeNull(); 290 - }); 291 - }); 292 - 293 - describe("tryResume", () => { 294 - it("returns false when no stored state", () => { 295 - const flow = createOfflineInboundMigrationFlow(); 296 - 297 - const result = flow.tryResume(); 298 - 299 - expect(result).toBe(false); 300 - }); 301 - 302 - it("restores state from localStorage", () => { 303 - const storedState = { 304 - version: 1, 305 - step: "choose-handle", 306 - startedAt: new Date().toISOString(), 307 - userDid: "did:plc:restored123", 308 - carFileName: "backup.car", 309 - carSizeBytes: 12345, 310 - rotationKeyDidKey: "did:key:z123abc", 311 - targetHandle: "restored.example.com", 312 - targetEmail: "restored@example.com", 313 - progress: { 314 - accountCreated: true, 315 - repoImported: false, 316 - plcSigned: false, 317 - activated: false, 318 - }, 319 - }; 320 - localStorage.setItem(OFFLINE_STORAGE_KEY, JSON.stringify(storedState)); 321 - 322 - const flow = createOfflineInboundMigrationFlow(); 323 - const result = flow.tryResume(); 324 - 325 - expect(result).toBe(true); 326 - expect(flow.state.step).toBe("choose-handle"); 327 - expect(flow.state.userDid).toBe("did:plc:restored123"); 328 - expect(flow.state.carFileName).toBe("backup.car"); 329 - expect(flow.state.carSizeBytes).toBe(12345); 330 - expect(flow.state.rotationKeyDidKey).toBe("did:key:z123abc"); 331 - expect(flow.state.targetHandle).toBe("restored.example.com"); 332 - expect(flow.state.targetEmail).toBe("restored@example.com"); 333 - expect(flow.state.progress.repoExported).toBe(true); 334 - }); 335 - 336 - it("restores error from stored state", () => { 337 - const storedState = { 338 - version: 1, 339 - step: "error", 340 - startedAt: new Date().toISOString(), 341 - userDid: "did:plc:abc", 342 - carFileName: "", 343 - carSizeBytes: 0, 344 - rotationKeyDidKey: "", 345 - targetHandle: "", 346 - targetEmail: "", 347 - progress: { 348 - accountCreated: false, 349 - repoImported: false, 350 - plcSigned: false, 351 - activated: false, 352 - }, 353 - lastError: "Previous migration failed", 354 - }; 355 - localStorage.setItem(OFFLINE_STORAGE_KEY, JSON.stringify(storedState)); 356 - 357 - const flow = createOfflineInboundMigrationFlow(); 358 - flow.tryResume(); 359 - 360 - expect(flow.state.error).toBe("Previous migration failed"); 361 - }); 362 - 363 - it("returns false and clears for incompatible version", () => { 364 - const storedState = { 365 - version: 999, 366 - step: "review", 367 - userDid: "did:plc:abc", 368 - }; 369 - localStorage.setItem(OFFLINE_STORAGE_KEY, JSON.stringify(storedState)); 370 - 371 - const flow = createOfflineInboundMigrationFlow(); 372 - const result = flow.tryResume(); 373 - 374 - expect(result).toBe(false); 375 - expect(localStorage.getItem(OFFLINE_STORAGE_KEY)).toBeNull(); 376 - }); 377 - 378 - it("returns false and clears for expired state (> 24 hours)", () => { 379 - const expiredState = { 380 - version: 1, 381 - step: "review", 382 - startedAt: new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(), 383 - userDid: "did:plc:expired", 384 - carFileName: "old.car", 385 - carSizeBytes: 100, 386 - rotationKeyDidKey: "", 387 - targetHandle: "old.example.com", 388 - targetEmail: "old@example.com", 389 - progress: { 390 - accountCreated: false, 391 - repoImported: false, 392 - plcSigned: false, 393 - activated: false, 394 - }, 395 - }; 396 - localStorage.setItem(OFFLINE_STORAGE_KEY, JSON.stringify(expiredState)); 397 - 398 - const flow = createOfflineInboundMigrationFlow(); 399 - const result = flow.tryResume(); 400 - 401 - expect(result).toBe(false); 402 - expect(localStorage.getItem(OFFLINE_STORAGE_KEY)).toBeNull(); 403 - }); 404 - 405 - it("returns false and clears for invalid JSON", () => { 406 - localStorage.setItem(OFFLINE_STORAGE_KEY, "not-valid-json"); 407 - 408 - const flow = createOfflineInboundMigrationFlow(); 409 - const result = flow.tryResume(); 410 - 411 - expect(result).toBe(false); 412 - expect(localStorage.getItem(OFFLINE_STORAGE_KEY)).toBeNull(); 413 - }); 414 - 415 - it("accepts state within 24 hours", () => { 416 - const recentState = { 417 - version: 1, 418 - step: "review", 419 - startedAt: new Date(Date.now() - 23 * 60 * 60 * 1000).toISOString(), 420 - userDid: "did:plc:recent", 421 - carFileName: "recent.car", 422 - carSizeBytes: 500, 423 - rotationKeyDidKey: "did:key:zRecent", 424 - targetHandle: "recent.example.com", 425 - targetEmail: "recent@example.com", 426 - progress: { 427 - accountCreated: true, 428 - repoImported: true, 429 - plcSigned: false, 430 - activated: false, 431 - }, 432 - }; 433 - localStorage.setItem(OFFLINE_STORAGE_KEY, JSON.stringify(recentState)); 434 - 435 - const flow = createOfflineInboundMigrationFlow(); 436 - const result = flow.tryResume(); 437 - 438 - expect(result).toBe(true); 439 - expect(flow.state.userDid).toBe("did:plc:recent"); 440 - }); 441 - }); 442 - 443 - describe("loadLocalServerInfo", () => { 444 - function createMockResponse(data: unknown) { 445 - const jsonStr = JSON.stringify(data); 446 - return new Response(jsonStr, { 447 - status: 200, 448 - headers: { "Content-Type": "application/json" }, 449 - }); 450 - } 451 - 452 - it("fetches server description", async () => { 453 - const mockServerInfo = { 454 - did: "did:web:example.com", 455 - availableUserDomains: ["example.com"], 456 - inviteCodeRequired: false, 457 - }; 458 - 459 - globalThis.fetch = vi.fn().mockResolvedValue( 460 - createMockResponse(mockServerInfo), 461 - ); 462 - 463 - const flow = createOfflineInboundMigrationFlow(); 464 - const result = await flow.loadLocalServerInfo(); 465 - 466 - expect(result).toEqual(mockServerInfo); 467 - expect(fetch).toHaveBeenCalledWith( 468 - expect.stringContaining("com.atproto.server.describeServer"), 469 - expect.any(Object), 470 - ); 471 - }); 472 - 473 - it("caches server info", async () => { 474 - const mockServerInfo = { 475 - did: "did:web:example.com", 476 - availableUserDomains: ["example.com"], 477 - inviteCodeRequired: false, 478 - }; 479 - 480 - globalThis.fetch = vi.fn().mockResolvedValue( 481 - createMockResponse(mockServerInfo), 482 - ); 483 - 484 - const flow = createOfflineInboundMigrationFlow(); 485 - await flow.loadLocalServerInfo(); 486 - await flow.loadLocalServerInfo(); 487 - 488 - expect(fetch).toHaveBeenCalledTimes(1); 489 - }); 490 - }); 491 - });
-333
frontend/src/tests/migration/plc-ops.test.ts
··· 1 - import { beforeEach, describe, expect, it, vi } from "vitest"; 2 - import { PlcOps, plcOps } from "../../lib/migration/plc-ops"; 3 - 4 - describe("migration/plc-ops", () => { 5 - beforeEach(() => { 6 - vi.restoreAllMocks(); 7 - }); 8 - 9 - describe("PlcOps class", () => { 10 - it("uses default PLC directory URL", () => { 11 - const ops = new PlcOps(); 12 - expect(ops).toBeDefined(); 13 - }); 14 - 15 - it("accepts custom PLC directory URL", () => { 16 - const ops = new PlcOps("https://custom-plc.example.com"); 17 - expect(ops).toBeDefined(); 18 - }); 19 - }); 20 - 21 - describe("plcOps singleton", () => { 22 - it("exports a singleton instance", () => { 23 - expect(plcOps).toBeInstanceOf(PlcOps); 24 - }); 25 - }); 26 - 27 - describe("getPlcAuditLogs", () => { 28 - it("throws on HTTP error", async () => { 29 - globalThis.fetch = vi.fn().mockResolvedValue({ 30 - ok: false, 31 - status: 404, 32 - }); 33 - 34 - await expect(plcOps.getPlcAuditLogs("did:plc:notfound")).rejects.toThrow( 35 - "Failed to fetch PLC audit logs: 404", 36 - ); 37 - }); 38 - }); 39 - 40 - describe("getLastPlcOpFromPlc", () => { 41 - it("throws when empty array returned", async () => { 42 - globalThis.fetch = vi.fn().mockResolvedValue({ 43 - ok: true, 44 - json: () => Promise.resolve([]), 45 - }); 46 - 47 - await expect( 48 - plcOps.getLastPlcOpFromPlc("did:plc:empty"), 49 - ).rejects.toThrow(); 50 - }); 51 - }); 52 - 53 - describe("createNewSecp256k1Keypair", () => { 54 - it("generates a keypair with private and public keys", async () => { 55 - const result = await plcOps.createNewSecp256k1Keypair(); 56 - 57 - expect(result.privateKey).toBeDefined(); 58 - expect(result.publicKey).toBeDefined(); 59 - expect(result.publicKey.startsWith("did:key:")).toBe(true); 60 - }); 61 - 62 - it("generates different keypairs each time", async () => { 63 - const result1 = await plcOps.createNewSecp256k1Keypair(); 64 - const result2 = await plcOps.createNewSecp256k1Keypair(); 65 - 66 - expect(result1.privateKey).not.toBe(result2.privateKey); 67 - expect(result1.publicKey).not.toBe(result2.publicKey); 68 - }); 69 - }); 70 - 71 - describe("getKeyPair", () => { 72 - it("parses 64-character hex private key", async () => { 73 - const hexKey = "a".repeat(64); 74 - 75 - const result = await plcOps.getKeyPair(hexKey); 76 - 77 - expect(result.type).toBe("private_key"); 78 - expect(result.didPublicKey.startsWith("did:key:")).toBe(true); 79 - expect(result.keypair).toBeDefined(); 80 - }); 81 - 82 - it("handles whitespace in key input", async () => { 83 - const hexKey = " " + "b".repeat(64) + " "; 84 - 85 - const result = await plcOps.getKeyPair(hexKey); 86 - 87 - expect(result.type).toBe("private_key"); 88 - }); 89 - 90 - it("throws for invalid key format", async () => { 91 - await expect(plcOps.getKeyPair("not-a-valid-key")).rejects.toThrow( 92 - "Invalid key format", 93 - ); 94 - }); 95 - 96 - it("throws for hex key with wrong length", async () => { 97 - await expect(plcOps.getKeyPair("abc123")).rejects.toThrow( 98 - "Invalid key format", 99 - ); 100 - }); 101 - }); 102 - 103 - describe("pushPlcOperation", () => { 104 - it("posts operation to PLC directory", async () => { 105 - globalThis.fetch = vi.fn().mockResolvedValue({ 106 - ok: true, 107 - }); 108 - 109 - const operation = { 110 - type: "plc_operation" as const, 111 - prev: "bafyreiabc", 112 - alsoKnownAs: ["at://alice.example.com"], 113 - rotationKeys: ["did:key:z123"], 114 - services: { 115 - atproto_pds: { 116 - type: "AtprotoPersonalDataServer", 117 - endpoint: "https://pds.example.com", 118 - }, 119 - }, 120 - verificationMethods: { 121 - atproto: "did:key:z456", 122 - }, 123 - sig: "test-signature", 124 - }; 125 - 126 - await plcOps.pushPlcOperation("did:plc:abc123", operation); 127 - 128 - expect(fetch).toHaveBeenCalledWith( 129 - "https://plc.directory/did:plc:abc123", 130 - expect.objectContaining({ 131 - method: "POST", 132 - headers: { "Content-Type": "application/json" }, 133 - body: JSON.stringify(operation), 134 - }), 135 - ); 136 - }); 137 - 138 - it("throws with error message from PLC directory", async () => { 139 - globalThis.fetch = vi.fn().mockResolvedValue({ 140 - ok: false, 141 - status: 400, 142 - headers: new Map([["content-type", "application/json"]]), 143 - json: () => Promise.resolve({ message: "Invalid signature" }), 144 - }); 145 - 146 - const operation = { 147 - type: "plc_operation" as const, 148 - prev: "bafyreiabc", 149 - alsoKnownAs: [], 150 - rotationKeys: ["did:key:z123"], 151 - services: {}, 152 - verificationMethods: {}, 153 - sig: "bad-sig", 154 - }; 155 - 156 - await expect( 157 - plcOps.pushPlcOperation("did:plc:abc123", operation), 158 - ).rejects.toThrow("Invalid signature"); 159 - }); 160 - 161 - it("throws generic error when no message in response", async () => { 162 - globalThis.fetch = vi.fn().mockResolvedValue({ 163 - ok: false, 164 - status: 500, 165 - headers: new Map([["content-type", "text/plain"]]), 166 - }); 167 - 168 - const operation = { 169 - type: "plc_operation" as const, 170 - prev: null, 171 - alsoKnownAs: [], 172 - rotationKeys: [], 173 - services: {}, 174 - verificationMethods: {}, 175 - }; 176 - 177 - await expect( 178 - plcOps.pushPlcOperation("did:plc:abc123", operation), 179 - ).rejects.toThrow("PLC directory returned HTTP 500"); 180 - }); 181 - }); 182 - 183 - describe("createServiceAuthToken", () => { 184 - it("creates a valid JWT", async () => { 185 - const { privateKey } = await plcOps.createNewSecp256k1Keypair(); 186 - const keypair = await plcOps.getKeyPair(privateKey); 187 - 188 - const token = await plcOps.createServiceAuthToken( 189 - "did:plc:issuer", 190 - "did:web:audience.example.com", 191 - keypair.keypair, 192 - "com.atproto.server.createAccount", 193 - ); 194 - 195 - expect(token).toBeDefined(); 196 - const parts = token.split("."); 197 - expect(parts).toHaveLength(3); 198 - }); 199 - 200 - it("includes correct header", async () => { 201 - const { privateKey } = await plcOps.createNewSecp256k1Keypair(); 202 - const keypair = await plcOps.getKeyPair(privateKey); 203 - 204 - const token = await plcOps.createServiceAuthToken( 205 - "did:plc:issuer", 206 - "did:web:audience", 207 - keypair.keypair, 208 - "com.atproto.server.createAccount", 209 - ); 210 - 211 - const headerB64 = token.split(".")[0]; 212 - const header = JSON.parse( 213 - atob(headerB64.replace(/-/g, "+").replace(/_/g, "/")), 214 - ); 215 - expect(header.typ).toBe("JWT"); 216 - expect(header.alg).toBe("ES256K"); 217 - }); 218 - 219 - it("includes correct payload claims", async () => { 220 - const { privateKey } = await plcOps.createNewSecp256k1Keypair(); 221 - const keypair = await plcOps.getKeyPair(privateKey); 222 - 223 - const before = Math.floor(Date.now() / 1000); 224 - const token = await plcOps.createServiceAuthToken( 225 - "did:plc:myissuer", 226 - "did:web:myaudience.com", 227 - keypair.keypair, 228 - "com.atproto.sync.getRepo", 229 - ); 230 - const after = Math.floor(Date.now() / 1000); 231 - 232 - const payloadB64 = token.split(".")[1]; 233 - const payload = JSON.parse( 234 - atob(payloadB64.replace(/-/g, "+").replace(/_/g, "/")), 235 - ); 236 - 237 - expect(payload.iss).toBe("did:plc:myissuer"); 238 - expect(payload.aud).toBe("did:web:myaudience.com"); 239 - expect(payload.lxm).toBe("com.atproto.sync.getRepo"); 240 - expect(payload.iat).toBeGreaterThanOrEqual(before); 241 - expect(payload.iat).toBeLessThanOrEqual(after); 242 - expect(payload.exp).toBe(payload.iat + 60); 243 - expect(payload.jti).toBeDefined(); 244 - }); 245 - }); 246 - 247 - describe("signAndPublishNewOp", () => { 248 - it("throws when no rotation keys provided", async () => { 249 - const { privateKey } = await plcOps.createNewSecp256k1Keypair(); 250 - const keypair = await plcOps.getKeyPair(privateKey); 251 - 252 - await expect( 253 - plcOps.signAndPublishNewOp( 254 - "did:plc:test", 255 - keypair.keypair, 256 - ["at://alice.example.com"], 257 - [], 258 - "https://pds.example.com", 259 - "did:key:zVerify", 260 - "bafyreiprev", 261 - ), 262 - ).rejects.toThrow("No rotation keys provided"); 263 - }); 264 - 265 - it("throws when more than 5 unique rotation keys provided", async () => { 266 - const { privateKey } = await plcOps.createNewSecp256k1Keypair(); 267 - const keypair = await plcOps.getKeyPair(privateKey); 268 - 269 - const tooManyKeys = [ 270 - "did:key:z1", 271 - "did:key:z2", 272 - "did:key:z3", 273 - "did:key:z4", 274 - "did:key:z5", 275 - "did:key:z6", 276 - ]; 277 - 278 - await expect( 279 - plcOps.signAndPublishNewOp( 280 - "did:plc:test", 281 - keypair.keypair, 282 - [], 283 - tooManyKeys, 284 - "https://pds.example.com", 285 - "did:key:zVerify", 286 - "bafyreiprev", 287 - ), 288 - ).rejects.toThrow("Maximum 5 rotation keys allowed"); 289 - }); 290 - }); 291 - 292 - describe("signPlcOperationWithCredentials", () => { 293 - it("throws when no rotation keys provided", async () => { 294 - const { privateKey } = await plcOps.createNewSecp256k1Keypair(); 295 - const keypair = await plcOps.getKeyPair(privateKey); 296 - 297 - await expect( 298 - plcOps.signPlcOperationWithCredentials( 299 - "did:plc:test", 300 - keypair.keypair, 301 - { 302 - rotationKeys: [], 303 - alsoKnownAs: [], 304 - verificationMethods: {}, 305 - services: {}, 306 - }, 307 - [], 308 - "bafyreiprev", 309 - ), 310 - ).rejects.toThrow("No rotation keys provided"); 311 - }); 312 - 313 - it("throws when more than 5 rotation keys provided", async () => { 314 - const { privateKey } = await plcOps.createNewSecp256k1Keypair(); 315 - const keypair = await plcOps.getKeyPair(privateKey); 316 - 317 - await expect( 318 - plcOps.signPlcOperationWithCredentials( 319 - "did:plc:test", 320 - keypair.keypair, 321 - { 322 - rotationKeys: ["did:key:z1", "did:key:z2", "did:key:z3"], 323 - alsoKnownAs: [], 324 - verificationMethods: {}, 325 - services: {}, 326 - }, 327 - ["did:key:z4", "did:key:z5", "did:key:z6"], 328 - "bafyreiprev", 329 - ), 330 - ).rejects.toThrow("Maximum 5 rotation keys allowed"); 331 - }); 332 - }); 333 - });
+3 -7
frontend/src/tests/mocks.ts
··· 206 206 () => jsonResponse({ code: "new-invite-" + Date.now() }), 207 207 ); 208 208 mockEndpoint( 209 - "_account.getNotificationPrefs", 209 + "com.tranquil.account.getNotificationPrefs", 210 210 () => jsonResponse(mockData.notificationPrefs()), 211 211 ); 212 212 mockEndpoint( 213 - "_account.updateNotificationPrefs", 213 + "com.tranquil.account.updateNotificationPrefs", 214 214 () => jsonResponse({ success: true }), 215 215 ); 216 216 mockEndpoint( 217 - "_account.getNotificationHistory", 217 + "com.tranquil.account.getNotificationHistory", 218 218 () => jsonResponse({ notifications: [] }), 219 219 ); 220 220 mockEndpoint( ··· 240 240 mockEndpoint( 241 241 "com.atproto.repo.listRecords", 242 242 () => jsonResponse({ records: [] }), 243 - ); 244 - mockEndpoint( 245 - "_backup.listBackups", 246 - () => jsonResponse({ backups: [] }), 247 243 ); 248 244 } 249 245 export function setupAuthenticatedUser(
-15
migrations/20260101_account_backups.sql
··· 1 - ALTER TABLE users ADD COLUMN backup_enabled BOOLEAN NOT NULL DEFAULT TRUE; 2 - 3 - CREATE TABLE account_backups ( 4 - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 5 - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, 6 - storage_key TEXT NOT NULL, 7 - repo_root_cid TEXT NOT NULL, 8 - repo_rev TEXT NOT NULL, 9 - block_count INT NOT NULL, 10 - size_bytes BIGINT NOT NULL, 11 - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 12 - ); 13 - 14 - CREATE INDEX idx_account_backups_user_id ON account_backups(user_id); 15 - CREATE INDEX idx_account_backups_created_at ON account_backups(created_at);
+3 -6
scripts/install-debian.sh
··· 44 44 sudo -u postgres psql -c "DROP DATABASE IF EXISTS pds;" 2>/dev/null || true 45 45 sudo -u postgres psql -c "DROP USER IF EXISTS tranquil_pds;" 2>/dev/null || true 46 46 47 - log_info "Removing minio buckets..." 47 + log_info "Removing minio bucket..." 48 48 if command -v mc &>/dev/null; then 49 49 mc rb local/pds-blobs --force 2>/dev/null || true 50 - mc rb local/pds-backups --force 2>/dev/null || true 51 50 mc alias remove local 2>/dev/null || true 52 51 fi 53 52 systemctl stop minio 2>/dev/null || true ··· 79 78 echo " - PostgreSQL database 'pds' and all data" 80 79 echo " - All Tranquil PDS configuration and credentials" 81 80 echo " - All source code in /opt/tranquil-pds" 82 - echo " - MinIO buckets 'pds-blobs' and 'pds-backups' and all data" 81 + echo " - MinIO bucket 'pds-blobs' and all blobs" 83 82 echo "" 84 83 read -p "Type 'NUKE' to confirm: " CONFIRM_NUKE 85 84 if [[ "$CONFIRM_NUKE" == "NUKE" ]]; then ··· 275 274 mc alias remove local 2>/dev/null || true 276 275 mc alias set local http://localhost:9000 minioadmin "${MINIO_PASSWORD}" --api S3v4 277 276 mc mb local/pds-blobs --ignore-existing 278 - mc mb local/pds-backups --ignore-existing 279 - log_success "minio buckets created" 277 + log_success "minio bucket created" 280 278 281 279 log_info "Installing rust..." 282 280 if [[ -f "$HOME/.cargo/env" ]]; then ··· 384 382 S3_ENDPOINT=http://localhost:9000 385 383 AWS_REGION=us-east-1 386 384 S3_BUCKET=pds-blobs 387 - BACKUP_S3_BUCKET=pds-backups 388 385 AWS_ACCESS_KEY_ID=minioadmin 389 386 AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD} 390 387 VALKEY_URL=redis://localhost:6379
+1 -5
scripts/test-infra.sh
··· 83 83 echo "Waiting for Valkey... ($i/30)" 84 84 sleep 1 85 85 done 86 - echo "Creating MinIO buckets..." 86 + echo "Creating MinIO bucket..." 87 87 $CONTAINER_CMD run --rm --network host \ 88 88 -e MC_HOST_minio="http://minioadmin:minioadmin@127.0.0.1:${MINIO_PORT}" \ 89 89 minio/mc:latest mb minio/test-bucket --ignore-existing >/dev/null 2>&1 || true 90 - $CONTAINER_CMD run --rm --network host \ 91 - -e MC_HOST_minio="http://minioadmin:minioadmin@127.0.0.1:${MINIO_PORT}" \ 92 - minio/mc:latest mb minio/test-backups --ignore-existing >/dev/null 2>&1 || true 93 90 cat > "$INFRA_FILE" << EOF 94 91 export DATABASE_URL="postgres://postgres:postgres@127.0.0.1:${PG_PORT}/postgres" 95 92 export TEST_DB_PORT="${PG_PORT}" 96 93 export S3_ENDPOINT="http://127.0.0.1:${MINIO_PORT}" 97 94 export S3_BUCKET="test-bucket" 98 - export BACKUP_S3_BUCKET="test-backups" 99 95 export AWS_ACCESS_KEY_ID="minioadmin" 100 96 export AWS_SECRET_ACCESS_KEY="minioadmin" 101 97 export AWS_REGION="us-east-1"
-930
src/api/backup.rs
··· 1 - use crate::auth::BearerAuth; 2 - use crate::scheduled::generate_full_backup; 3 - use crate::state::AppState; 4 - use crate::storage::BackupStorage; 5 - use axum::{ 6 - Json, 7 - extract::{Query, State}, 8 - http::StatusCode, 9 - response::{IntoResponse, Response}, 10 - }; 11 - use cid::Cid; 12 - use serde::{Deserialize, Serialize}; 13 - use serde_json::json; 14 - use std::str::FromStr; 15 - use tracing::{error, info, warn}; 16 - 17 - #[derive(Serialize)] 18 - #[serde(rename_all = "camelCase")] 19 - pub struct BackupInfo { 20 - pub id: String, 21 - pub repo_rev: String, 22 - pub repo_root_cid: String, 23 - pub block_count: i32, 24 - pub size_bytes: i64, 25 - pub created_at: String, 26 - } 27 - 28 - #[derive(Serialize)] 29 - #[serde(rename_all = "camelCase")] 30 - pub struct ListBackupsOutput { 31 - pub backups: Vec<BackupInfo>, 32 - pub backup_enabled: bool, 33 - } 34 - 35 - pub async fn list_backups(State(state): State<AppState>, auth: BearerAuth) -> Response { 36 - let user = match sqlx::query!( 37 - "SELECT id, backup_enabled FROM users WHERE did = $1", 38 - auth.0.did 39 - ) 40 - .fetch_optional(&state.db) 41 - .await 42 - { 43 - Ok(Some(u)) => u, 44 - Ok(None) => { 45 - return ( 46 - StatusCode::NOT_FOUND, 47 - Json(json!({"error": "AccountNotFound", "message": "Account not found"})), 48 - ) 49 - .into_response(); 50 - } 51 - Err(e) => { 52 - error!("DB error fetching user: {:?}", e); 53 - return ( 54 - StatusCode::INTERNAL_SERVER_ERROR, 55 - Json(json!({"error": "InternalError", "message": "Database error"})), 56 - ) 57 - .into_response(); 58 - } 59 - }; 60 - 61 - let backups = match sqlx::query!( 62 - r#" 63 - SELECT id, repo_rev, repo_root_cid, block_count, size_bytes, created_at 64 - FROM account_backups 65 - WHERE user_id = $1 66 - ORDER BY created_at DESC 67 - "#, 68 - user.id 69 - ) 70 - .fetch_all(&state.db) 71 - .await 72 - { 73 - Ok(rows) => rows, 74 - Err(e) => { 75 - error!("DB error fetching backups: {:?}", e); 76 - return ( 77 - StatusCode::INTERNAL_SERVER_ERROR, 78 - Json(json!({"error": "InternalError", "message": "Database error"})), 79 - ) 80 - .into_response(); 81 - } 82 - }; 83 - 84 - let backup_list: Vec<BackupInfo> = backups 85 - .into_iter() 86 - .map(|b| BackupInfo { 87 - id: b.id.to_string(), 88 - repo_rev: b.repo_rev, 89 - repo_root_cid: b.repo_root_cid, 90 - block_count: b.block_count, 91 - size_bytes: b.size_bytes, 92 - created_at: b.created_at.to_rfc3339(), 93 - }) 94 - .collect(); 95 - 96 - ( 97 - StatusCode::OK, 98 - Json(ListBackupsOutput { 99 - backups: backup_list, 100 - backup_enabled: user.backup_enabled, 101 - }), 102 - ) 103 - .into_response() 104 - } 105 - 106 - #[derive(Deserialize)] 107 - pub struct GetBackupQuery { 108 - pub id: String, 109 - } 110 - 111 - pub async fn get_backup( 112 - State(state): State<AppState>, 113 - auth: BearerAuth, 114 - Query(query): Query<GetBackupQuery>, 115 - ) -> Response { 116 - let backup_id = match uuid::Uuid::parse_str(&query.id) { 117 - Ok(id) => id, 118 - Err(_) => { 119 - return ( 120 - StatusCode::BAD_REQUEST, 121 - Json(json!({"error": "InvalidRequest", "message": "Invalid backup ID"})), 122 - ) 123 - .into_response(); 124 - } 125 - }; 126 - 127 - let backup = match sqlx::query!( 128 - r#" 129 - SELECT ab.storage_key, ab.repo_rev 130 - FROM account_backups ab 131 - JOIN users u ON u.id = ab.user_id 132 - WHERE ab.id = $1 AND u.did = $2 133 - "#, 134 - backup_id, 135 - auth.0.did 136 - ) 137 - .fetch_optional(&state.db) 138 - .await 139 - { 140 - Ok(Some(b)) => b, 141 - Ok(None) => { 142 - return ( 143 - StatusCode::NOT_FOUND, 144 - Json(json!({"error": "BackupNotFound", "message": "Backup not found"})), 145 - ) 146 - .into_response(); 147 - } 148 - Err(e) => { 149 - error!("DB error fetching backup: {:?}", e); 150 - return ( 151 - StatusCode::INTERNAL_SERVER_ERROR, 152 - Json(json!({"error": "InternalError", "message": "Database error"})), 153 - ) 154 - .into_response(); 155 - } 156 - }; 157 - 158 - let backup_storage = match state.backup_storage.as_ref() { 159 - Some(storage) => storage, 160 - None => { 161 - return ( 162 - StatusCode::SERVICE_UNAVAILABLE, 163 - Json( 164 - json!({"error": "BackupsDisabled", "message": "Backup storage not configured"}), 165 - ), 166 - ) 167 - .into_response(); 168 - } 169 - }; 170 - 171 - let car_bytes = match backup_storage.get_backup(&backup.storage_key).await { 172 - Ok(bytes) => bytes, 173 - Err(e) => { 174 - error!("Failed to fetch backup from storage: {:?}", e); 175 - return ( 176 - StatusCode::INTERNAL_SERVER_ERROR, 177 - Json(json!({"error": "InternalError", "message": "Failed to retrieve backup"})), 178 - ) 179 - .into_response(); 180 - } 181 - }; 182 - 183 - ( 184 - StatusCode::OK, 185 - [ 186 - (axum::http::header::CONTENT_TYPE, "application/vnd.ipld.car"), 187 - ( 188 - axum::http::header::CONTENT_DISPOSITION, 189 - &format!("attachment; filename=\"{}.car\"", backup.repo_rev), 190 - ), 191 - ], 192 - car_bytes, 193 - ) 194 - .into_response() 195 - } 196 - 197 - #[derive(Serialize)] 198 - #[serde(rename_all = "camelCase")] 199 - pub struct CreateBackupOutput { 200 - pub id: String, 201 - pub repo_rev: String, 202 - pub size_bytes: i64, 203 - pub block_count: i32, 204 - } 205 - 206 - pub async fn create_backup(State(state): State<AppState>, auth: BearerAuth) -> Response { 207 - let backup_storage = match state.backup_storage.as_ref() { 208 - Some(storage) => storage, 209 - None => { 210 - return ( 211 - StatusCode::SERVICE_UNAVAILABLE, 212 - Json( 213 - json!({"error": "BackupsDisabled", "message": "Backup storage not configured"}), 214 - ), 215 - ) 216 - .into_response(); 217 - } 218 - }; 219 - 220 - let user = match sqlx::query!( 221 - r#" 222 - SELECT u.id, u.did, u.backup_enabled, u.deactivated_at, r.repo_root_cid, r.repo_rev 223 - FROM users u 224 - JOIN repos r ON r.user_id = u.id 225 - WHERE u.did = $1 226 - "#, 227 - auth.0.did 228 - ) 229 - .fetch_optional(&state.db) 230 - .await 231 - { 232 - Ok(Some(u)) => u, 233 - Ok(None) => { 234 - return ( 235 - StatusCode::NOT_FOUND, 236 - Json(json!({"error": "AccountNotFound", "message": "Account not found"})), 237 - ) 238 - .into_response(); 239 - } 240 - Err(e) => { 241 - error!("DB error fetching user: {:?}", e); 242 - return ( 243 - StatusCode::INTERNAL_SERVER_ERROR, 244 - Json(json!({"error": "InternalError", "message": "Database error"})), 245 - ) 246 - .into_response(); 247 - } 248 - }; 249 - 250 - if user.deactivated_at.is_some() { 251 - return ( 252 - StatusCode::BAD_REQUEST, 253 - Json(json!({"error": "AccountDeactivated", "message": "Account is deactivated"})), 254 - ) 255 - .into_response(); 256 - } 257 - 258 - let repo_rev = match &user.repo_rev { 259 - Some(rev) => rev.clone(), 260 - None => { 261 - return ( 262 - StatusCode::BAD_REQUEST, 263 - Json( 264 - json!({"error": "RepoNotReady", "message": "Repository not ready for backup"}), 265 - ), 266 - ) 267 - .into_response(); 268 - } 269 - }; 270 - 271 - let head_cid = match Cid::from_str(&user.repo_root_cid) { 272 - Ok(c) => c, 273 - Err(_) => { 274 - return ( 275 - StatusCode::INTERNAL_SERVER_ERROR, 276 - Json(json!({"error": "InternalError", "message": "Invalid repo root CID"})), 277 - ) 278 - .into_response(); 279 - } 280 - }; 281 - 282 - let car_bytes = match generate_full_backup(&state.block_store, &head_cid).await { 283 - Ok(bytes) => bytes, 284 - Err(e) => { 285 - error!("Failed to generate CAR: {:?}", e); 286 - return ( 287 - StatusCode::INTERNAL_SERVER_ERROR, 288 - Json(json!({"error": "InternalError", "message": "Failed to generate backup"})), 289 - ) 290 - .into_response(); 291 - } 292 - }; 293 - 294 - let block_count = crate::scheduled::count_car_blocks(&car_bytes); 295 - let size_bytes = car_bytes.len() as i64; 296 - 297 - let storage_key = match backup_storage 298 - .put_backup(&user.did, &repo_rev, &car_bytes) 299 - .await 300 - { 301 - Ok(key) => key, 302 - Err(e) => { 303 - error!("Failed to upload backup: {:?}", e); 304 - return ( 305 - StatusCode::INTERNAL_SERVER_ERROR, 306 - Json(json!({"error": "InternalError", "message": "Failed to store backup"})), 307 - ) 308 - .into_response(); 309 - } 310 - }; 311 - 312 - let backup_id = match sqlx::query_scalar!( 313 - r#" 314 - INSERT INTO account_backups (user_id, storage_key, repo_root_cid, repo_rev, block_count, size_bytes) 315 - VALUES ($1, $2, $3, $4, $5, $6) 316 - RETURNING id 317 - "#, 318 - user.id, 319 - storage_key, 320 - user.repo_root_cid, 321 - repo_rev, 322 - block_count, 323 - size_bytes 324 - ) 325 - .fetch_one(&state.db) 326 - .await 327 - { 328 - Ok(id) => id, 329 - Err(e) => { 330 - error!("DB error inserting backup: {:?}", e); 331 - if let Err(rollback_err) = backup_storage.delete_backup(&storage_key).await { 332 - error!( 333 - storage_key = %storage_key, 334 - error = %rollback_err, 335 - "Failed to rollback orphaned backup from S3" 336 - ); 337 - } 338 - return ( 339 - StatusCode::INTERNAL_SERVER_ERROR, 340 - Json(json!({"error": "InternalError", "message": "Failed to record backup"})), 341 - ) 342 - .into_response(); 343 - } 344 - }; 345 - 346 - info!( 347 - did = %user.did, 348 - rev = %repo_rev, 349 - size_bytes, 350 - "Created manual backup" 351 - ); 352 - 353 - let retention = BackupStorage::retention_count(); 354 - if let Err(e) = cleanup_old_backups(&state.db, backup_storage, user.id, retention).await { 355 - warn!(did = %user.did, error = %e, "Failed to cleanup old backups after manual backup"); 356 - } 357 - 358 - ( 359 - StatusCode::OK, 360 - Json(CreateBackupOutput { 361 - id: backup_id.to_string(), 362 - repo_rev, 363 - size_bytes, 364 - block_count, 365 - }), 366 - ) 367 - .into_response() 368 - } 369 - 370 - async fn cleanup_old_backups( 371 - db: &sqlx::PgPool, 372 - backup_storage: &BackupStorage, 373 - user_id: uuid::Uuid, 374 - retention_count: u32, 375 - ) -> Result<(), String> { 376 - let old_backups = sqlx::query!( 377 - r#" 378 - SELECT id, storage_key 379 - FROM account_backups 380 - WHERE user_id = $1 381 - ORDER BY created_at DESC 382 - OFFSET $2 383 - "#, 384 - user_id, 385 - retention_count as i64 386 - ) 387 - .fetch_all(db) 388 - .await 389 - .map_err(|e| format!("DB error fetching old backups: {}", e))?; 390 - 391 - for backup in old_backups { 392 - if let Err(e) = backup_storage.delete_backup(&backup.storage_key).await { 393 - warn!( 394 - storage_key = %backup.storage_key, 395 - error = %e, 396 - "Failed to delete old backup from storage, skipping DB cleanup to avoid orphan" 397 - ); 398 - continue; 399 - } 400 - 401 - sqlx::query!("DELETE FROM account_backups WHERE id = $1", backup.id) 402 - .execute(db) 403 - .await 404 - .map_err(|e| format!("Failed to delete old backup record: {}", e))?; 405 - } 406 - 407 - Ok(()) 408 - } 409 - 410 - #[derive(Deserialize)] 411 - pub struct DeleteBackupQuery { 412 - pub id: String, 413 - } 414 - 415 - pub async fn delete_backup( 416 - State(state): State<AppState>, 417 - auth: BearerAuth, 418 - Query(query): Query<DeleteBackupQuery>, 419 - ) -> Response { 420 - let backup_id = match uuid::Uuid::parse_str(&query.id) { 421 - Ok(id) => id, 422 - Err(_) => { 423 - return ( 424 - StatusCode::BAD_REQUEST, 425 - Json(json!({"error": "InvalidRequest", "message": "Invalid backup ID"})), 426 - ) 427 - .into_response(); 428 - } 429 - }; 430 - 431 - let backup = match sqlx::query!( 432 - r#" 433 - SELECT ab.id, ab.storage_key, u.deactivated_at 434 - FROM account_backups ab 435 - JOIN users u ON u.id = ab.user_id 436 - WHERE ab.id = $1 AND u.did = $2 437 - "#, 438 - backup_id, 439 - auth.0.did 440 - ) 441 - .fetch_optional(&state.db) 442 - .await 443 - { 444 - Ok(Some(b)) => b, 445 - Ok(None) => { 446 - return ( 447 - StatusCode::NOT_FOUND, 448 - Json(json!({"error": "BackupNotFound", "message": "Backup not found"})), 449 - ) 450 - .into_response(); 451 - } 452 - Err(e) => { 453 - error!("DB error fetching backup: {:?}", e); 454 - return ( 455 - StatusCode::INTERNAL_SERVER_ERROR, 456 - Json(json!({"error": "InternalError", "message": "Database error"})), 457 - ) 458 - .into_response(); 459 - } 460 - }; 461 - 462 - if backup.deactivated_at.is_some() { 463 - return ( 464 - StatusCode::BAD_REQUEST, 465 - Json(json!({"error": "AccountDeactivated", "message": "Account is deactivated"})), 466 - ) 467 - .into_response(); 468 - } 469 - 470 - if let Some(backup_storage) = state.backup_storage.as_ref() 471 - && let Err(e) = backup_storage.delete_backup(&backup.storage_key).await 472 - { 473 - warn!( 474 - storage_key = %backup.storage_key, 475 - error = %e, 476 - "Failed to delete backup from storage (continuing anyway)" 477 - ); 478 - } 479 - 480 - if let Err(e) = sqlx::query!("DELETE FROM account_backups WHERE id = $1", backup.id) 481 - .execute(&state.db) 482 - .await 483 - { 484 - error!("DB error deleting backup: {:?}", e); 485 - return ( 486 - StatusCode::INTERNAL_SERVER_ERROR, 487 - Json(json!({"error": "InternalError", "message": "Failed to delete backup"})), 488 - ) 489 - .into_response(); 490 - } 491 - 492 - info!(did = %auth.0.did, backup_id = %backup_id, "Deleted backup"); 493 - 494 - (StatusCode::OK, Json(json!({}))).into_response() 495 - } 496 - 497 - #[derive(Deserialize)] 498 - #[serde(rename_all = "camelCase")] 499 - pub struct SetBackupEnabledInput { 500 - pub enabled: bool, 501 - } 502 - 503 - pub async fn set_backup_enabled( 504 - State(state): State<AppState>, 505 - auth: BearerAuth, 506 - Json(input): Json<SetBackupEnabledInput>, 507 - ) -> Response { 508 - let user = match sqlx::query!( 509 - "SELECT deactivated_at FROM users WHERE did = $1", 510 - auth.0.did 511 - ) 512 - .fetch_optional(&state.db) 513 - .await 514 - { 515 - Ok(Some(u)) => u, 516 - Ok(None) => { 517 - return ( 518 - StatusCode::NOT_FOUND, 519 - Json(json!({"error": "AccountNotFound", "message": "Account not found"})), 520 - ) 521 - .into_response(); 522 - } 523 - Err(e) => { 524 - error!("DB error fetching user: {:?}", e); 525 - return ( 526 - StatusCode::INTERNAL_SERVER_ERROR, 527 - Json(json!({"error": "InternalError", "message": "Database error"})), 528 - ) 529 - .into_response(); 530 - } 531 - }; 532 - 533 - if user.deactivated_at.is_some() { 534 - return ( 535 - StatusCode::BAD_REQUEST, 536 - Json(json!({"error": "AccountDeactivated", "message": "Account is deactivated"})), 537 - ) 538 - .into_response(); 539 - } 540 - 541 - if let Err(e) = sqlx::query!( 542 - "UPDATE users SET backup_enabled = $1 WHERE did = $2", 543 - input.enabled, 544 - auth.0.did 545 - ) 546 - .execute(&state.db) 547 - .await 548 - { 549 - error!("DB error updating backup_enabled: {:?}", e); 550 - return ( 551 - StatusCode::INTERNAL_SERVER_ERROR, 552 - Json(json!({"error": "InternalError", "message": "Failed to update setting"})), 553 - ) 554 - .into_response(); 555 - } 556 - 557 - info!(did = %auth.0.did, enabled = input.enabled, "Updated backup_enabled setting"); 558 - 559 - (StatusCode::OK, Json(json!({"enabled": input.enabled}))).into_response() 560 - } 561 - 562 - pub async fn export_blobs(State(state): State<AppState>, auth: BearerAuth) -> Response { 563 - let user = match sqlx::query!("SELECT id FROM users WHERE did = $1", auth.0.did) 564 - .fetch_optional(&state.db) 565 - .await 566 - { 567 - Ok(Some(u)) => u, 568 - Ok(None) => { 569 - return ( 570 - StatusCode::NOT_FOUND, 571 - Json(json!({"error": "AccountNotFound", "message": "Account not found"})), 572 - ) 573 - .into_response(); 574 - } 575 - Err(e) => { 576 - error!("DB error fetching user: {:?}", e); 577 - return ( 578 - StatusCode::INTERNAL_SERVER_ERROR, 579 - Json(json!({"error": "InternalError", "message": "Database error"})), 580 - ) 581 - .into_response(); 582 - } 583 - }; 584 - 585 - let blobs = match sqlx::query!( 586 - r#" 587 - SELECT DISTINCT b.cid, b.storage_key, b.mime_type 588 - FROM blobs b 589 - JOIN record_blobs rb ON rb.blob_cid = b.cid 590 - WHERE rb.repo_id = $1 591 - "#, 592 - user.id 593 - ) 594 - .fetch_all(&state.db) 595 - .await 596 - { 597 - Ok(rows) => rows, 598 - Err(e) => { 599 - error!("DB error fetching blobs: {:?}", e); 600 - return ( 601 - StatusCode::INTERNAL_SERVER_ERROR, 602 - Json(json!({"error": "InternalError", "message": "Database error"})), 603 - ) 604 - .into_response(); 605 - } 606 - }; 607 - 608 - if blobs.is_empty() { 609 - return ( 610 - StatusCode::OK, 611 - [ 612 - (axum::http::header::CONTENT_TYPE, "application/zip"), 613 - ( 614 - axum::http::header::CONTENT_DISPOSITION, 615 - "attachment; filename=\"blobs.zip\"", 616 - ), 617 - ], 618 - Vec::<u8>::new(), 619 - ) 620 - .into_response(); 621 - } 622 - 623 - let mut zip_buffer = std::io::Cursor::new(Vec::new()); 624 - { 625 - let mut zip = zip::ZipWriter::new(&mut zip_buffer); 626 - 627 - let options = zip::write::SimpleFileOptions::default() 628 - .compression_method(zip::CompressionMethod::Deflated); 629 - 630 - let mut exported: Vec<serde_json::Value> = Vec::new(); 631 - let mut skipped: Vec<serde_json::Value> = Vec::new(); 632 - 633 - for blob in &blobs { 634 - let blob_data = match state.blob_store.get(&blob.storage_key).await { 635 - Ok(data) => data, 636 - Err(e) => { 637 - warn!(cid = %blob.cid, error = %e, "Failed to fetch blob, skipping"); 638 - skipped.push(json!({ 639 - "cid": blob.cid, 640 - "mimeType": blob.mime_type, 641 - "reason": "fetch_failed" 642 - })); 643 - continue; 644 - } 645 - }; 646 - 647 - let extension = mime_to_extension(&blob.mime_type); 648 - let filename = format!("{}{}", blob.cid, extension); 649 - 650 - if let Err(e) = zip.start_file(&filename, options) { 651 - warn!(filename = %filename, error = %e, "Failed to start zip file entry"); 652 - skipped.push(json!({ 653 - "cid": blob.cid, 654 - "mimeType": blob.mime_type, 655 - "reason": "zip_entry_failed" 656 - })); 657 - continue; 658 - } 659 - 660 - if let Err(e) = std::io::Write::write_all(&mut zip, &blob_data) { 661 - warn!(filename = %filename, error = %e, "Failed to write blob to zip"); 662 - skipped.push(json!({ 663 - "cid": blob.cid, 664 - "mimeType": blob.mime_type, 665 - "reason": "write_failed" 666 - })); 667 - continue; 668 - } 669 - 670 - exported.push(json!({ 671 - "cid": blob.cid, 672 - "filename": filename, 673 - "mimeType": blob.mime_type, 674 - "sizeBytes": blob_data.len() 675 - })); 676 - } 677 - 678 - let manifest = json!({ 679 - "exportedAt": chrono::Utc::now().to_rfc3339(), 680 - "totalBlobs": blobs.len(), 681 - "exportedCount": exported.len(), 682 - "skippedCount": skipped.len(), 683 - "exported": exported, 684 - "skipped": skipped 685 - }); 686 - 687 - if zip.start_file("manifest.json", options).is_ok() { 688 - let _ = std::io::Write::write_all( 689 - &mut zip, 690 - serde_json::to_string_pretty(&manifest) 691 - .unwrap_or_else(|_| "{}".to_string()) 692 - .as_bytes(), 693 - ); 694 - } 695 - 696 - if let Err(e) = zip.finish() { 697 - error!("Failed to finish zip: {:?}", e); 698 - return ( 699 - StatusCode::INTERNAL_SERVER_ERROR, 700 - Json(json!({"error": "InternalError", "message": "Failed to create zip file"})), 701 - ) 702 - .into_response(); 703 - } 704 - } 705 - 706 - let zip_bytes = zip_buffer.into_inner(); 707 - 708 - info!(did = %auth.0.did, blob_count = blobs.len(), size_bytes = zip_bytes.len(), "Exported blobs"); 709 - 710 - ( 711 - StatusCode::OK, 712 - [ 713 - (axum::http::header::CONTENT_TYPE, "application/zip"), 714 - ( 715 - axum::http::header::CONTENT_DISPOSITION, 716 - "attachment; filename=\"blobs.zip\"", 717 - ), 718 - ], 719 - zip_bytes, 720 - ) 721 - .into_response() 722 - } 723 - 724 - fn mime_to_extension(mime_type: &str) -> &'static str { 725 - match mime_type { 726 - "application/font-sfnt" => ".otf", 727 - "application/font-tdpfr" => ".pfr", 728 - "application/font-woff" => ".woff", 729 - "application/gzip" => ".gz", 730 - "application/json" => ".json", 731 - "application/json5" => ".json5", 732 - "application/jsonml+json" => ".jsonml", 733 - "application/octet-stream" => ".bin", 734 - "application/pdf" => ".pdf", 735 - "application/zip" => ".zip", 736 - "audio/aac" => ".aac", 737 - "audio/ac3" => ".ac3", 738 - "audio/aiff" => ".aiff", 739 - "audio/annodex" => ".axa", 740 - "audio/audible" => ".aa", 741 - "audio/basic" => ".au", 742 - "audio/flac" => ".flac", 743 - "audio/m4a" => ".m4a", 744 - "audio/m4b" => ".m4b", 745 - "audio/m4p" => ".m4p", 746 - "audio/mid" => ".mid", 747 - "audio/midi" => ".midi", 748 - "audio/mp4" => ".mp4a", 749 - "audio/mpeg" => ".mp3", 750 - "audio/ogg" => ".ogg", 751 - "audio/s3m" => ".s3m", 752 - "audio/scpls" => ".pls", 753 - "audio/silk" => ".sil", 754 - "audio/vnd.audible.aax" => ".aax", 755 - "audio/vnd.dece.audio" => ".uva", 756 - "audio/vnd.digital-winds" => ".eol", 757 - "audio/vnd.dlna.adts" => ".adt", 758 - "audio/vnd.dra" => ".dra", 759 - "audio/vnd.dts" => ".dts", 760 - "audio/vnd.dts.hd" => ".dtshd", 761 - "audio/vnd.lucent.voice" => ".lvp", 762 - "audio/vnd.ms-playready.media.pya" => ".pya", 763 - "audio/vnd.nuera.ecelp4800" => ".ecelp4800", 764 - "audio/vnd.nuera.ecelp7470" => ".ecelp7470", 765 - "audio/vnd.nuera.ecelp9600" => ".ecelp9600", 766 - "audio/vnd.rip" => ".rip", 767 - "audio/wav" => ".wav", 768 - "audio/webm" => ".weba", 769 - "audio/x-caf" => ".caf", 770 - "audio/x-gsm" => ".gsm", 771 - "audio/x-m4r" => ".m4r", 772 - "audio/x-matroska" => ".mka", 773 - "audio/x-mpegurl" => ".m3u", 774 - "audio/x-ms-wax" => ".wax", 775 - "audio/x-ms-wma" => ".wma", 776 - "audio/x-pn-realaudio" => ".ra", 777 - "audio/x-pn-realaudio-plugin" => ".rpm", 778 - "audio/x-sd2" => ".sd2", 779 - "audio/x-smd" => ".smd", 780 - "audio/xm" => ".xm", 781 - "font/collection" => ".ttc", 782 - "font/ttf" => ".ttf", 783 - "font/woff" => ".woff", 784 - "font/woff2" => ".woff2", 785 - "image/apng" => ".apng", 786 - "image/avif" => ".avif", 787 - "image/avif-sequence" => ".avifs", 788 - "image/bmp" => ".bmp", 789 - "image/cgm" => ".cgm", 790 - "image/cis-cod" => ".cod", 791 - "image/g3fax" => ".g3", 792 - "image/gif" => ".gif", 793 - "image/heic" => ".heic", 794 - "image/heic-sequence" => ".heics", 795 - "image/heif" => ".heif", 796 - "image/heif-sequence" => ".heifs", 797 - "image/ief" => ".ief", 798 - "image/jp2" => ".jp2", 799 - "image/jpeg" => ".jpg", 800 - "image/jpm" => ".jpm", 801 - "image/jpx" => ".jpf", 802 - "image/jxl" => ".jxl", 803 - "image/ktx" => ".ktx", 804 - "image/pict" => ".pct", 805 - "image/png" => ".png", 806 - "image/prs.btif" => ".btif", 807 - "image/qoi" => ".qoi", 808 - "image/sgi" => ".sgi", 809 - "image/svg+xml" => ".svg", 810 - "image/tiff" => ".tiff", 811 - "image/vnd.dece.graphic" => ".uvg", 812 - "image/vnd.djvu" => ".djv", 813 - "image/vnd.fastbidsheet" => ".fbs", 814 - "image/vnd.fpx" => ".fpx", 815 - "image/vnd.fst" => ".fst", 816 - "image/vnd.fujixerox.edmics-mmr" => ".mmr", 817 - "image/vnd.fujixerox.edmics-rlc" => ".rlc", 818 - "image/vnd.ms-modi" => ".mdi", 819 - "image/vnd.ms-photo" => ".wdp", 820 - "image/vnd.net-fpx" => ".npx", 821 - "image/vnd.radiance" => ".hdr", 822 - "image/vnd.rn-realflash" => ".rf", 823 - "image/vnd.wap.wbmp" => ".wbmp", 824 - "image/vnd.xiff" => ".xif", 825 - "image/webp" => ".webp", 826 - "image/x-3ds" => ".3ds", 827 - "image/x-adobe-dng" => ".dng", 828 - "image/x-canon-cr2" => ".cr2", 829 - "image/x-canon-cr3" => ".cr3", 830 - "image/x-canon-crw" => ".crw", 831 - "image/x-cmu-raster" => ".ras", 832 - "image/x-cmx" => ".cmx", 833 - "image/x-epson-erf" => ".erf", 834 - "image/x-freehand" => ".fh", 835 - "image/x-fuji-raf" => ".raf", 836 - "image/x-icon" => ".ico", 837 - "image/x-jg" => ".art", 838 - "image/x-jng" => ".jng", 839 - "image/x-kodak-dcr" => ".dcr", 840 - "image/x-kodak-k25" => ".k25", 841 - "image/x-kodak-kdc" => ".kdc", 842 - "image/x-macpaint" => ".mac", 843 - "image/x-minolta-mrw" => ".mrw", 844 - "image/x-mrsid-image" => ".sid", 845 - "image/x-nikon-nef" => ".nef", 846 - "image/x-nikon-nrw" => ".nrw", 847 - "image/x-olympus-orf" => ".orf", 848 - "image/x-panasonic-rw" => ".raw", 849 - "image/x-panasonic-rw2" => ".rw2", 850 - "image/x-pentax-pef" => ".pef", 851 - "image/x-portable-anymap" => ".pnm", 852 - "image/x-portable-bitmap" => ".pbm", 853 - "image/x-portable-graymap" => ".pgm", 854 - "image/x-portable-pixmap" => ".ppm", 855 - "image/x-qoi" => ".qoi", 856 - "image/x-quicktime" => ".qti", 857 - "image/x-rgb" => ".rgb", 858 - "image/x-sigma-x3f" => ".x3f", 859 - "image/x-sony-arw" => ".arw", 860 - "image/x-sony-sr2" => ".sr2", 861 - "image/x-sony-srf" => ".srf", 862 - "image/x-tga" => ".tga", 863 - "image/x-xbitmap" => ".xbm", 864 - "image/x-xcf" => ".xcf", 865 - "image/x-xpixmap" => ".xpm", 866 - "image/x-xwindowdump" => ".xwd", 867 - "model/gltf+json" => ".gltf", 868 - "model/gltf-binary" => ".glb", 869 - "model/iges" => ".igs", 870 - "model/mesh" => ".msh", 871 - "model/vnd.collada+xml" => ".dae", 872 - "model/vnd.gdl" => ".gdl", 873 - "model/vnd.gtw" => ".gtw", 874 - "model/vnd.vtu" => ".vtu", 875 - "model/vrml" => ".vrml", 876 - "model/x3d+binary" => ".x3db", 877 - "model/x3d+vrml" => ".x3dv", 878 - "model/x3d+xml" => ".x3d", 879 - "text/css" => ".css", 880 - "text/html" => ".html", 881 - "text/plain" => ".txt", 882 - "video/3gpp" => ".3gp", 883 - "video/3gpp2" => ".3g2", 884 - "video/annodex" => ".axv", 885 - "video/divx" => ".divx", 886 - "video/h261" => ".h261", 887 - "video/h263" => ".h263", 888 - "video/h264" => ".h264", 889 - "video/jpeg" => ".jpgv", 890 - "video/jpm" => ".jpgm", 891 - "video/mj2" => ".mj2", 892 - "video/mp4" => ".mp4", 893 - "video/mpeg" => ".mpg", 894 - "video/ogg" => ".ogv", 895 - "video/quicktime" => ".mov", 896 - "video/vnd.dece.hd" => ".uvh", 897 - "video/vnd.dece.mobile" => ".uvm", 898 - "video/vnd.dece.pd" => ".uvp", 899 - "video/vnd.dece.sd" => ".uvs", 900 - "video/vnd.dece.video" => ".uvv", 901 - "video/vnd.dlna.mpeg-tts" => ".ts", 902 - "video/vnd.dvb.file" => ".dvb", 903 - "video/vnd.fvt" => ".fvt", 904 - "video/vnd.mpegurl" => ".m4u", 905 - "video/vnd.ms-playready.media.pyv" => ".pyv", 906 - "video/vnd.uvvu.mp4" => ".uvu", 907 - "video/vnd.vivo" => ".viv", 908 - "video/webm" => ".webm", 909 - "video/x-dv" => ".dv", 910 - "video/x-f4v" => ".f4v", 911 - "video/x-fli" => ".fli", 912 - "video/x-flv" => ".flv", 913 - "video/x-ivf" => ".ivf", 914 - "video/x-la-asf" => ".lsf", 915 - "video/x-m4v" => ".m4v", 916 - "video/x-matroska" => ".mkv", 917 - "video/x-mng" => ".mng", 918 - "video/x-ms-asf" => ".asf", 919 - "video/x-ms-vob" => ".vob", 920 - "video/x-ms-wm" => ".wm", 921 - "video/x-ms-wmp" => ".wmp", 922 - "video/x-ms-wmv" => ".wmv", 923 - "video/x-ms-wmx" => ".wmx", 924 - "video/x-ms-wvx" => ".wvx", 925 - "video/x-msvideo" => ".avi", 926 - "video/x-sgi-movie" => ".movie", 927 - "video/x-smv" => ".smv", 928 - _ => ".bin", 929 - } 930 - }
-1
src/api/mod.rs
··· 1 1 pub mod actor; 2 2 pub mod admin; 3 3 pub mod age_assurance; 4 - pub mod backup; 5 4 pub mod delegation; 6 5 pub mod error; 7 6 pub mod identity;
+7 -26
src/api/notification_prefs.rs
··· 182 182 .into_response(), 183 183 }; 184 184 185 - let sensitive_types = [ 186 - "email_verification", 187 - "password_reset", 188 - "email_update", 189 - "two_factor_code", 190 - "passkey_recovery", 191 - "migration_verification", 192 - "plc_operation", 193 - "channel_verification", 194 - "signup_verification", 195 - ]; 196 - 197 185 let notifications = rows 198 186 .iter() 199 - .map(|row| { 200 - let body = if sensitive_types.contains(&row.comms_type.as_str()) { 201 - "[Code redacted for security]".to_string() 202 - } else { 203 - row.body.clone() 204 - }; 205 - NotificationHistoryEntry { 206 - created_at: row.created_at.to_rfc3339(), 207 - channel: row.channel.clone(), 208 - comms_type: row.comms_type.clone(), 209 - status: row.status.clone(), 210 - subject: row.subject.clone(), 211 - body, 212 - } 187 + .map(|row| NotificationHistoryEntry { 188 + created_at: row.created_at.to_rfc3339(), 189 + channel: row.channel.clone(), 190 + comms_type: row.comms_type.clone(), 191 + status: row.status.clone(), 192 + subject: row.subject.clone(), 193 + body: row.body.clone(), 213 194 }) 214 195 .collect(); 215 196
+1 -1
src/api/repo/blob.rs
··· 312 312 r#" 313 313 SELECT rb.blob_cid, rb.record_uri 314 314 FROM record_blobs rb 315 - LEFT JOIN blobs b ON rb.blob_cid = b.cid 315 + LEFT JOIN blobs b ON rb.blob_cid = b.cid AND b.created_by_user = rb.repo_id 316 316 WHERE rb.repo_id = $1 AND b.cid IS NULL AND rb.blob_cid > $2 317 317 ORDER BY rb.blob_cid 318 318 LIMIT $3
+2 -4
src/api/repo/record/batch.rs
··· 345 345 let rkey = rkey 346 346 .clone() 347 347 .unwrap_or_else(|| Tid::now(LimitedU32::MIN).to_string()); 348 - let record_ipld = crate::util::json_to_ipld(value); 349 348 let mut record_bytes = Vec::new(); 350 - if serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld).is_err() { 349 + if serde_ipld_dagcbor::to_writer(&mut record_bytes, value).is_err() { 351 350 return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"}))).into_response(); 352 351 } 353 352 let record_cid = match tracking_store.put(&record_bytes).await { ··· 410 409 } 411 410 }; 412 411 all_blob_cids.extend(extract_blob_cids(value)); 413 - let record_ipld = crate::util::json_to_ipld(value); 414 412 let mut record_bytes = Vec::new(); 415 - if serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld).is_err() { 413 + if serde_ipld_dagcbor::to_writer(&mut record_bytes, value).is_err() { 416 414 return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"}))).into_response(); 417 415 } 418 416 let record_cid = match tracking_store.put(&record_bytes).await {
+1 -2
src/api/repo/record/utils.rs
··· 382 382 let commit = jacquard_repo::commit::Commit::from_cbor(&commit_bytes) 383 383 .map_err(|e| format!("Failed to parse commit: {:?}", e))?; 384 384 let mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None); 385 - let record_ipld = crate::util::json_to_ipld(record); 386 385 let mut record_bytes = Vec::new(); 387 - serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld) 386 + serde_ipld_dagcbor::to_writer(&mut record_bytes, record) 388 387 .map_err(|e| format!("Failed to serialize record: {:?}", e))?; 389 388 let record_cid = tracking_store 390 389 .put(&record_bytes)
+2 -4
src/api/repo/record/write.rs
··· 297 297 let rkey = input 298 298 .rkey 299 299 .unwrap_or_else(|| Tid::now(LimitedU32::MIN).to_string()); 300 - let record_ipld = crate::util::json_to_ipld(&input.record); 301 300 let mut record_bytes = Vec::new(); 302 - if serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld).is_err() { 301 + if serde_ipld_dagcbor::to_writer(&mut record_bytes, &input.record).is_err() { 303 302 return ( 304 303 StatusCode::BAD_REQUEST, 305 304 Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"})), ··· 551 550 } 552 551 } 553 552 let existing_cid = mst.get(&key).await.ok().flatten(); 554 - let record_ipld = crate::util::json_to_ipld(&input.record); 555 553 let mut record_bytes = Vec::new(); 556 - if serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld).is_err() { 554 + if serde_ipld_dagcbor::to_writer(&mut record_bytes, &input.record).is_err() { 557 555 return ( 558 556 StatusCode::BAD_REQUEST, 559 557 Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"})),
+44 -15
src/api/server/account_status.rs
··· 567 567 #[serde(rename_all = "camelCase")] 568 568 pub struct DeactivateAccountInput { 569 569 pub delete_after: Option<String>, 570 + pub migrating_to: Option<String>, 570 571 } 571 572 572 573 pub async fn deactivate_account( ··· 617 618 618 619 let did = auth_user.did; 619 620 621 + let migrating_to = if let Some(ref url) = input.migrating_to { 622 + let url = url.trim().trim_end_matches('/'); 623 + if url.is_empty() || !did.starts_with("did:web:") { 624 + None 625 + } else { 626 + if !url.starts_with("https://") { 627 + return ApiError::InvalidRequest("migratingTo must start with https://".into()) 628 + .into_response(); 629 + } 630 + Some(url.to_string()) 631 + } 632 + } else { 633 + None 634 + }; 635 + 620 636 let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did) 621 637 .fetch_optional(&state.db) 622 638 .await 623 639 .ok() 624 640 .flatten(); 625 641 626 - let result = sqlx::query!( 627 - "UPDATE users SET deactivated_at = NOW(), delete_after = $2 WHERE did = $1", 628 - did, 629 - delete_after 630 - ) 631 - .execute(&state.db) 632 - .await; 642 + let result = if let Some(ref pds_url) = migrating_to { 643 + sqlx::query!( 644 + "UPDATE users SET deactivated_at = NOW(), delete_after = $2, migrated_to_pds = $3, migrated_at = NOW() WHERE did = $1", 645 + did, 646 + delete_after, 647 + pds_url 648 + ) 649 + .execute(&state.db) 650 + .await 651 + } else { 652 + sqlx::query!( 653 + "UPDATE users SET deactivated_at = NOW(), delete_after = $2 WHERE did = $1", 654 + did, 655 + delete_after 656 + ) 657 + .execute(&state.db) 658 + .await 659 + }; 660 + 661 + let status = if migrating_to.is_some() { 662 + "migrated" 663 + } else { 664 + "deactivated" 665 + }; 633 666 634 667 match result { 635 668 Ok(_) => { 636 669 if let Some(ref h) = handle { 637 670 let _ = state.cache.delete(&format!("handle:{}", h)).await; 638 671 } 639 - if let Err(e) = crate::api::repo::record::sequence_account_event( 640 - &state, 641 - &did, 642 - false, 643 - Some("deactivated"), 644 - ) 645 - .await 672 + if let Err(e) = 673 + crate::api::repo::record::sequence_account_event(&state, &did, false, Some(status)) 674 + .await 646 675 { 647 - warn!("Failed to sequence account deactivated event: {}", e); 676 + warn!("Failed to sequence account {} event: {}", status, e); 648 677 } 649 678 (StatusCode::OK, Json(json!({}))).into_response() 650 679 }
-54
src/api/server/email.rs
··· 476 476 info!("Email updated for user {}", user_id); 477 477 (StatusCode::OK, Json(json!({}))).into_response() 478 478 } 479 - 480 - #[derive(Deserialize)] 481 - pub struct CheckEmailVerifiedInput { 482 - pub identifier: String, 483 - } 484 - 485 - pub async fn check_email_verified( 486 - State(state): State<AppState>, 487 - headers: axum::http::HeaderMap, 488 - Json(input): Json<CheckEmailVerifiedInput>, 489 - ) -> Response { 490 - let client_ip = crate::rate_limit::extract_client_ip(&headers, None); 491 - if !state 492 - .check_rate_limit(RateLimitKind::VerificationCheck, &client_ip) 493 - .await 494 - { 495 - return ( 496 - StatusCode::TOO_MANY_REQUESTS, 497 - Json(json!({ 498 - "error": "RateLimitExceeded", 499 - "message": "Too many requests. Please try again later." 500 - })), 501 - ) 502 - .into_response(); 503 - } 504 - 505 - let user = sqlx::query!( 506 - "SELECT email_verified FROM users WHERE email = $1 OR handle = $1", 507 - input.identifier 508 - ) 509 - .fetch_optional(&state.db) 510 - .await; 511 - 512 - match user { 513 - Ok(Some(row)) => ( 514 - StatusCode::OK, 515 - Json(json!({ "verified": row.email_verified })), 516 - ) 517 - .into_response(), 518 - Ok(None) => ( 519 - StatusCode::NOT_FOUND, 520 - Json(json!({ "error": "AccountNotFound", "message": "Account not found" })), 521 - ) 522 - .into_response(), 523 - Err(e) => { 524 - error!("DB error checking email verified: {:?}", e); 525 - ( 526 - StatusCode::INTERNAL_SERVER_ERROR, 527 - Json(json!({ "error": "InternalError" })), 528 - ) 529 - .into_response() 530 - } 531 - } 532 - }
+241 -6
src/api/server/migration.rs
··· 6 6 http::StatusCode, 7 7 response::{IntoResponse, Response}, 8 8 }; 9 - use chrono::Utc; 9 + use chrono::{DateTime, Utc}; 10 10 use serde::{Deserialize, Serialize}; 11 11 use serde_json::json; 12 12 13 + #[derive(Serialize)] 14 + #[serde(rename_all = "camelCase")] 15 + pub struct GetMigrationStatusOutput { 16 + pub did: String, 17 + pub did_type: String, 18 + pub migrated: bool, 19 + #[serde(skip_serializing_if = "Option::is_none")] 20 + pub migrated_to_pds: Option<String>, 21 + #[serde(skip_serializing_if = "Option::is_none")] 22 + pub migrated_at: Option<DateTime<Utc>>, 23 + } 24 + 25 + pub async fn get_migration_status( 26 + State(state): State<AppState>, 27 + headers: axum::http::HeaderMap, 28 + ) -> Response { 29 + let extracted = match crate::auth::extract_auth_token_from_header( 30 + headers.get("Authorization").and_then(|h| h.to_str().ok()), 31 + ) { 32 + Some(t) => t, 33 + None => return ApiError::AuthenticationRequired.into_response(), 34 + }; 35 + let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); 36 + let http_uri = format!( 37 + "https://{}/xrpc/com.tranquil.account.getMigrationStatus", 38 + std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) 39 + ); 40 + let auth_user = match crate::auth::validate_token_with_dpop( 41 + &state.db, 42 + &extracted.token, 43 + extracted.is_dpop, 44 + dpop_proof, 45 + "GET", 46 + &http_uri, 47 + true, 48 + ) 49 + .await 50 + { 51 + Ok(user) => user, 52 + Err(e) => return ApiError::from(e).into_response(), 53 + }; 54 + let user = match sqlx::query!( 55 + "SELECT did, migrated_to_pds, migrated_at FROM users WHERE did = $1", 56 + auth_user.did 57 + ) 58 + .fetch_optional(&state.db) 59 + .await 60 + { 61 + Ok(Some(row)) => row, 62 + Ok(None) => return ApiError::AccountNotFound.into_response(), 63 + Err(e) => { 64 + tracing::error!("DB error getting migration status: {:?}", e); 65 + return ApiError::InternalError.into_response(); 66 + } 67 + }; 68 + let did_type = if user.did.starts_with("did:plc:") { 69 + "plc" 70 + } else if user.did.starts_with("did:web:") { 71 + "web" 72 + } else { 73 + "unknown" 74 + }; 75 + let migrated = user.migrated_to_pds.is_some(); 76 + ( 77 + StatusCode::OK, 78 + Json(GetMigrationStatusOutput { 79 + did: user.did, 80 + did_type: did_type.to_string(), 81 + migrated, 82 + migrated_to_pds: user.migrated_to_pds, 83 + migrated_at: user.migrated_at, 84 + }), 85 + ) 86 + .into_response() 87 + } 88 + 89 + #[derive(Deserialize)] 90 + #[serde(rename_all = "camelCase")] 91 + pub struct UpdateMigrationForwardingInput { 92 + pub pds_url: String, 93 + } 94 + 95 + #[derive(Serialize)] 96 + #[serde(rename_all = "camelCase")] 97 + pub struct UpdateMigrationForwardingOutput { 98 + pub success: bool, 99 + pub migrated_to_pds: String, 100 + pub migrated_at: DateTime<Utc>, 101 + } 102 + 103 + pub async fn update_migration_forwarding( 104 + State(state): State<AppState>, 105 + headers: axum::http::HeaderMap, 106 + Json(input): Json<UpdateMigrationForwardingInput>, 107 + ) -> Response { 108 + let extracted = match crate::auth::extract_auth_token_from_header( 109 + headers.get("Authorization").and_then(|h| h.to_str().ok()), 110 + ) { 111 + Some(t) => t, 112 + None => return ApiError::AuthenticationRequired.into_response(), 113 + }; 114 + let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); 115 + let http_uri = format!( 116 + "https://{}/xrpc/com.tranquil.account.updateMigrationForwarding", 117 + std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) 118 + ); 119 + let auth_user = match crate::auth::validate_token_with_dpop( 120 + &state.db, 121 + &extracted.token, 122 + extracted.is_dpop, 123 + dpop_proof, 124 + "POST", 125 + &http_uri, 126 + true, 127 + ) 128 + .await 129 + { 130 + Ok(user) => user, 131 + Err(e) => return ApiError::from(e).into_response(), 132 + }; 133 + if !auth_user.did.starts_with("did:web:") { 134 + return ( 135 + StatusCode::BAD_REQUEST, 136 + Json(json!({ 137 + "error": "InvalidRequest", 138 + "message": "Migration forwarding is only available for did:web accounts. did:plc accounts use PLC directory for identity updates." 139 + })), 140 + ) 141 + .into_response(); 142 + } 143 + let pds_url = input.pds_url.trim(); 144 + if pds_url.is_empty() { 145 + return ApiError::InvalidRequest("pds_url is required".into()).into_response(); 146 + } 147 + if !pds_url.starts_with("https://") { 148 + return ApiError::InvalidRequest("pds_url must start with https://".into()).into_response(); 149 + } 150 + let pds_url_clean = pds_url.trim_end_matches('/'); 151 + let now = Utc::now(); 152 + let result = sqlx::query!( 153 + "UPDATE users SET migrated_to_pds = $1, migrated_at = $2 WHERE did = $3", 154 + pds_url_clean, 155 + now, 156 + auth_user.did 157 + ) 158 + .execute(&state.db) 159 + .await; 160 + match result { 161 + Ok(_) => { 162 + tracing::info!( 163 + "Updated migration forwarding for {} to {}", 164 + auth_user.did, 165 + pds_url_clean 166 + ); 167 + ( 168 + StatusCode::OK, 169 + Json(UpdateMigrationForwardingOutput { 170 + success: true, 171 + migrated_to_pds: pds_url_clean.to_string(), 172 + migrated_at: now, 173 + }), 174 + ) 175 + .into_response() 176 + } 177 + Err(e) => { 178 + tracing::error!("DB error updating migration forwarding: {:?}", e); 179 + ApiError::InternalError.into_response() 180 + } 181 + } 182 + } 183 + 184 + pub async fn clear_migration_forwarding( 185 + State(state): State<AppState>, 186 + headers: axum::http::HeaderMap, 187 + ) -> Response { 188 + let extracted = match crate::auth::extract_auth_token_from_header( 189 + headers.get("Authorization").and_then(|h| h.to_str().ok()), 190 + ) { 191 + Some(t) => t, 192 + None => return ApiError::AuthenticationRequired.into_response(), 193 + }; 194 + let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); 195 + let http_uri = format!( 196 + "https://{}/xrpc/com.tranquil.account.clearMigrationForwarding", 197 + std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) 198 + ); 199 + let auth_user = match crate::auth::validate_token_with_dpop( 200 + &state.db, 201 + &extracted.token, 202 + extracted.is_dpop, 203 + dpop_proof, 204 + "POST", 205 + &http_uri, 206 + true, 207 + ) 208 + .await 209 + { 210 + Ok(user) => user, 211 + Err(e) => return ApiError::from(e).into_response(), 212 + }; 213 + if !auth_user.did.starts_with("did:web:") { 214 + return ( 215 + StatusCode::BAD_REQUEST, 216 + Json(json!({ 217 + "error": "InvalidRequest", 218 + "message": "Migration forwarding is only available for did:web accounts" 219 + })), 220 + ) 221 + .into_response(); 222 + } 223 + let result = sqlx::query!( 224 + "UPDATE users SET migrated_to_pds = NULL, migrated_at = NULL WHERE did = $1", 225 + auth_user.did 226 + ) 227 + .execute(&state.db) 228 + .await; 229 + match result { 230 + Ok(_) => { 231 + tracing::info!("Cleared migration forwarding for {}", auth_user.did); 232 + (StatusCode::OK, Json(json!({ "success": true }))).into_response() 233 + } 234 + Err(e) => { 235 + tracing::error!("DB error clearing migration forwarding: {:?}", e); 236 + ApiError::InternalError.into_response() 237 + } 238 + } 239 + } 240 + 13 241 #[derive(Debug, Clone, Serialize, Deserialize)] 14 242 #[serde(rename_all = "camelCase")] 15 243 pub struct VerificationMethod { ··· 47 275 }; 48 276 let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); 49 277 let http_uri = format!( 50 - "https://{}/xrpc/_account.updateDidDocument", 278 + "https://{}/xrpc/com.tranquil.account.updateDidDocument", 51 279 std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) 52 280 ); 53 281 let auth_user = match crate::auth::validate_token_with_dpop( ··· 77 305 } 78 306 79 307 let user = match sqlx::query!( 80 - "SELECT id, handle, deactivated_at FROM users WHERE did = $1", 308 + "SELECT id, migrated_to_pds, handle FROM users WHERE did = $1", 81 309 auth_user.did 82 310 ) 83 311 .fetch_optional(&state.db) ··· 91 319 } 92 320 }; 93 321 94 - if user.deactivated_at.is_some() { 95 - return ApiError::AccountDeactivated.into_response(); 322 + if user.migrated_to_pds.is_none() { 323 + return ( 324 + StatusCode::BAD_REQUEST, 325 + Json(json!({ 326 + "error": "InvalidRequest", 327 + "message": "DID document updates are only available for migrated accounts. Use the migration flow to migrate first." 328 + })), 329 + ) 330 + .into_response(); 96 331 } 97 332 98 333 if let Some(ref methods) = input.verification_methods { ··· 217 452 }; 218 453 let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); 219 454 let http_uri = format!( 220 - "https://{}/xrpc/_account.getDidDocument", 455 + "https://{}/xrpc/com.tranquil.account.getDidDocument", 221 456 std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) 222 457 ); 223 458 let auth_user = match crate::auth::validate_token_with_dpop(
+5 -2
src/api/server/mod.rs
··· 22 22 request_account_delete, 23 23 }; 24 24 pub use app_password::{create_app_password, list_app_passwords, revoke_app_password}; 25 - pub use email::{check_email_verified, confirm_email, request_email_update, update_email}; 25 + pub use email::{confirm_email, request_email_update, update_email}; 26 26 pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes}; 27 27 pub use logo::get_logo; 28 28 pub use meta::{describe_server, health, robots_txt}; 29 - pub use migration::{get_did_document, update_did_document}; 29 + pub use migration::{ 30 + clear_migration_forwarding, get_did_document, get_migration_status, update_did_document, 31 + update_migration_forwarding, 32 + }; 30 33 pub use passkey_account::{ 31 34 complete_passkey_setup, create_passkey_account, recover_passkey_account, 32 35 request_passkey_recovery, start_passkey_registration_for_setup,
+55 -59
src/lib.rs
··· 57 57 get(api::server::get_session), 58 58 ) 59 59 .route( 60 - "/xrpc/_account.listSessions", 60 + "/xrpc/com.tranquil.account.listSessions", 61 61 get(api::server::list_sessions), 62 62 ) 63 63 .route( 64 - "/xrpc/_account.revokeSession", 64 + "/xrpc/com.tranquil.account.revokeSession", 65 65 post(api::server::revoke_session), 66 66 ) 67 67 .route( 68 - "/xrpc/_account.revokeAllSessions", 68 + "/xrpc/com.tranquil.account.revokeAllSessions", 69 69 post(api::server::revoke_all_sessions), 70 70 ) 71 71 .route( ··· 208 208 post(api::server::reset_password), 209 209 ) 210 210 .route( 211 - "/xrpc/_account.changePassword", 211 + "/xrpc/com.tranquil.account.changePassword", 212 212 post(api::server::change_password), 213 213 ) 214 214 .route( 215 - "/xrpc/_account.removePassword", 215 + "/xrpc/com.tranquil.account.removePassword", 216 216 post(api::server::remove_password), 217 217 ) 218 218 .route( 219 - "/xrpc/_account.getPasswordStatus", 219 + "/xrpc/com.tranquil.account.getPasswordStatus", 220 220 get(api::server::get_password_status), 221 221 ) 222 222 .route( 223 - "/xrpc/_account.getReauthStatus", 223 + "/xrpc/com.tranquil.account.getReauthStatus", 224 224 get(api::server::get_reauth_status), 225 225 ) 226 226 .route( 227 - "/xrpc/_account.reauthPassword", 227 + "/xrpc/com.tranquil.account.reauthPassword", 228 228 post(api::server::reauth_password), 229 229 ) 230 - .route("/xrpc/_account.reauthTotp", post(api::server::reauth_totp)) 231 230 .route( 232 - "/xrpc/_account.reauthPasskeyStart", 231 + "/xrpc/com.tranquil.account.reauthTotp", 232 + post(api::server::reauth_totp), 233 + ) 234 + .route( 235 + "/xrpc/com.tranquil.account.reauthPasskeyStart", 233 236 post(api::server::reauth_passkey_start), 234 237 ) 235 238 .route( 236 - "/xrpc/_account.reauthPasskeyFinish", 239 + "/xrpc/com.tranquil.account.reauthPasskeyFinish", 237 240 post(api::server::reauth_passkey_finish), 238 241 ) 239 242 .route( 240 - "/xrpc/_account.getLegacyLoginPreference", 243 + "/xrpc/com.tranquil.account.getLegacyLoginPreference", 241 244 get(api::server::get_legacy_login_preference), 242 245 ) 243 246 .route( 244 - "/xrpc/_account.updateLegacyLoginPreference", 247 + "/xrpc/com.tranquil.account.updateLegacyLoginPreference", 245 248 post(api::server::update_legacy_login_preference), 246 249 ) 247 250 .route( 248 - "/xrpc/_account.updateLocale", 251 + "/xrpc/com.tranquil.account.updateLocale", 249 252 post(api::server::update_locale), 250 253 ) 251 254 .route( 252 - "/xrpc/_account.listTrustedDevices", 255 + "/xrpc/com.tranquil.account.listTrustedDevices", 253 256 get(api::server::list_trusted_devices), 254 257 ) 255 258 .route( 256 - "/xrpc/_account.revokeTrustedDevice", 259 + "/xrpc/com.tranquil.account.revokeTrustedDevice", 257 260 post(api::server::revoke_trusted_device), 258 261 ) 259 262 .route( 260 - "/xrpc/_account.updateTrustedDevice", 263 + "/xrpc/com.tranquil.account.updateTrustedDevice", 261 264 post(api::server::update_trusted_device), 262 265 ) 263 266 .route( 264 - "/xrpc/_account.createPasskeyAccount", 267 + "/xrpc/com.tranquil.account.createPasskeyAccount", 265 268 post(api::server::create_passkey_account), 266 269 ) 267 270 .route( 268 - "/xrpc/_account.startPasskeyRegistrationForSetup", 271 + "/xrpc/com.tranquil.account.startPasskeyRegistrationForSetup", 269 272 post(api::server::start_passkey_registration_for_setup), 270 273 ) 271 274 .route( 272 - "/xrpc/_account.completePasskeySetup", 275 + "/xrpc/com.tranquil.account.completePasskeySetup", 273 276 post(api::server::complete_passkey_setup), 274 277 ) 275 278 .route( 276 - "/xrpc/_account.requestPasskeyRecovery", 279 + "/xrpc/com.tranquil.account.requestPasskeyRecovery", 277 280 post(api::server::request_passkey_recovery), 278 281 ) 279 282 .route( 280 - "/xrpc/_account.recoverPasskeyAccount", 283 + "/xrpc/com.tranquil.account.recoverPasskeyAccount", 281 284 post(api::server::recover_passkey_account), 282 285 ) 283 286 .route( 284 - "/xrpc/_account.updateDidDocument", 287 + "/xrpc/com.tranquil.account.getMigrationStatus", 288 + get(api::server::get_migration_status), 289 + ) 290 + .route( 291 + "/xrpc/com.tranquil.account.updateMigrationForwarding", 292 + post(api::server::update_migration_forwarding), 293 + ) 294 + .route( 295 + "/xrpc/com.tranquil.account.clearMigrationForwarding", 296 + post(api::server::clear_migration_forwarding), 297 + ) 298 + .route( 299 + "/xrpc/com.tranquil.account.updateDidDocument", 285 300 post(api::server::update_did_document), 286 301 ) 287 302 .route( 288 - "/xrpc/_account.getDidDocument", 303 + "/xrpc/com.tranquil.account.getDidDocument", 289 304 get(api::server::get_did_document), 290 305 ) 291 306 .route( 292 307 "/xrpc/com.atproto.server.requestEmailUpdate", 293 308 post(api::server::request_email_update), 294 - ) 295 - .route( 296 - "/xrpc/_checkEmailVerified", 297 - post(api::server::check_email_verified), 298 309 ) 299 310 .route( 300 311 "/xrpc/com.atproto.server.confirmEmail", ··· 421 432 get(api::admin::get_invite_codes), 422 433 ) 423 434 .route( 424 - "/xrpc/_admin.getServerStats", 435 + "/xrpc/com.tranquil.admin.getServerStats", 425 436 get(api::admin::get_server_stats), 426 437 ) 427 438 .route( 428 - "/xrpc/_server.getConfig", 439 + "/xrpc/com.tranquil.server.getConfig", 429 440 get(api::admin::get_server_config), 430 441 ) 431 442 .route( 432 - "/xrpc/_admin.updateServerConfig", 443 + "/xrpc/com.tranquil.admin.updateServerConfig", 433 444 post(api::admin::update_server_config), 434 445 ) 435 446 .route( ··· 564 575 post(api::temp::dereference_scope), 565 576 ) 566 577 .route( 567 - "/xrpc/_account.getNotificationPrefs", 578 + "/xrpc/com.tranquil.account.getNotificationPrefs", 568 579 get(api::notification_prefs::get_notification_prefs), 569 580 ) 570 581 .route( 571 - "/xrpc/_account.updateNotificationPrefs", 582 + "/xrpc/com.tranquil.account.updateNotificationPrefs", 572 583 post(api::notification_prefs::update_notification_prefs), 573 584 ) 574 585 .route( 575 - "/xrpc/_account.getNotificationHistory", 586 + "/xrpc/com.tranquil.account.getNotificationHistory", 576 587 get(api::notification_prefs::get_notification_history), 577 588 ) 578 589 .route( 579 - "/xrpc/_account.confirmChannelVerification", 590 + "/xrpc/com.tranquil.account.confirmChannelVerification", 580 591 post(api::verification::confirm_channel_verification), 581 592 ) 582 593 .route( 583 - "/xrpc/_account.verifyToken", 594 + "/xrpc/com.tranquil.account.verifyToken", 584 595 post(api::server::verify_token), 585 596 ) 586 597 .route( 587 - "/xrpc/_delegation.listControllers", 598 + "/xrpc/com.tranquil.delegation.listControllers", 588 599 get(api::delegation::list_controllers), 589 600 ) 590 601 .route( 591 - "/xrpc/_delegation.addController", 602 + "/xrpc/com.tranquil.delegation.addController", 592 603 post(api::delegation::add_controller), 593 604 ) 594 605 .route( 595 - "/xrpc/_delegation.removeController", 606 + "/xrpc/com.tranquil.delegation.removeController", 596 607 post(api::delegation::remove_controller), 597 608 ) 598 609 .route( 599 - "/xrpc/_delegation.updateControllerScopes", 610 + "/xrpc/com.tranquil.delegation.updateControllerScopes", 600 611 post(api::delegation::update_controller_scopes), 601 612 ) 602 613 .route( 603 - "/xrpc/_delegation.listControlledAccounts", 614 + "/xrpc/com.tranquil.delegation.listControlledAccounts", 604 615 get(api::delegation::list_controlled_accounts), 605 616 ) 606 617 .route( 607 - "/xrpc/_delegation.getAuditLog", 618 + "/xrpc/com.tranquil.delegation.getAuditLog", 608 619 get(api::delegation::get_audit_log), 609 620 ) 610 621 .route( 611 - "/xrpc/_delegation.getScopePresets", 622 + "/xrpc/com.tranquil.delegation.getScopePresets", 612 623 get(api::delegation::get_scope_presets), 613 624 ) 614 625 .route( 615 - "/xrpc/_delegation.createDelegatedAccount", 626 + "/xrpc/com.tranquil.delegation.createDelegatedAccount", 616 627 post(api::delegation::create_delegated_account), 617 628 ) 618 - .route("/xrpc/_backup.listBackups", get(api::backup::list_backups)) 619 - .route("/xrpc/_backup.getBackup", get(api::backup::get_backup)) 620 - .route( 621 - "/xrpc/_backup.createBackup", 622 - post(api::backup::create_backup), 623 - ) 624 - .route( 625 - "/xrpc/_backup.deleteBackup", 626 - post(api::backup::delete_backup), 627 - ) 628 - .route( 629 - "/xrpc/_backup.setEnabled", 630 - post(api::backup::set_backup_enabled), 631 - ) 632 - .route("/xrpc/_backup.exportBlobs", get(api::backup::export_blobs)) 633 629 .route( 634 630 "/xrpc/app.bsky.ageassurance.getState", 635 631 get(api::age_assurance::get_state),
+1 -18
src/main.rs
··· 7 7 use tranquil_pds::crawlers::{Crawlers, start_crawlers_service}; 8 8 use tranquil_pds::scheduled::{ 9 9 backfill_genesis_commit_blocks, backfill_record_blobs, backfill_repo_rev, backfill_user_blocks, 10 - start_backup_tasks, start_scheduled_tasks, 10 + start_scheduled_tasks, 11 11 }; 12 12 use tranquil_pds::state::AppState; 13 13 ··· 83 83 None 84 84 }; 85 85 86 - let backup_handle = if let Some(backup_storage) = state.backup_storage.clone() { 87 - info!("Backup service enabled"); 88 - Some(tokio::spawn(start_backup_tasks( 89 - state.db.clone(), 90 - state.block_store.clone(), 91 - backup_storage, 92 - shutdown_rx.clone(), 93 - ))) 94 - } else { 95 - warn!("Backup service disabled (BACKUP_S3_BUCKET not set or BACKUP_ENABLED=false)"); 96 - None 97 - }; 98 - 99 86 let scheduled_handle = tokio::spawn(start_scheduled_tasks( 100 87 state.db.clone(), 101 88 state.blob_store.clone(), ··· 127 114 comms_handle.await.ok(); 128 115 129 116 if let Some(handle) = crawlers_handle { 130 - handle.await.ok(); 131 - } 132 - 133 - if let Some(handle) = backup_handle { 134 117 handle.await.ok(); 135 118 } 136 119
-4
src/rate_limit.rs
··· 32 32 pub totp_verify: Arc<KeyedRateLimiter>, 33 33 pub handle_update: Arc<KeyedRateLimiter>, 34 34 pub handle_update_daily: Arc<KeyedRateLimiter>, 35 - pub verification_check: Arc<KeyedRateLimiter>, 36 35 } 37 36 38 37 impl Default for RateLimiters { ··· 92 91 .unwrap() 93 92 .allow_burst(NonZeroU32::new(50).unwrap()), 94 93 )), 95 - verification_check: Arc::new(RateLimiter::keyed(Quota::per_minute( 96 - NonZeroU32::new(60).unwrap(), 97 - ))), 98 94 } 99 95 } 100 96
+1 -311
src/scheduled.rs
··· 11 11 use tracing::{debug, error, info, warn}; 12 12 13 13 use crate::repo::PostgresBlockStore; 14 - use crate::storage::{BackupStorage, BlobStorage}; 15 - use crate::sync::car::encode_car_header; 14 + use crate::storage::BlobStorage; 16 15 17 16 pub async fn backfill_genesis_commit_blocks(db: &PgPool, block_store: PostgresBlockStore) { 18 17 let broken_genesis_commits = match sqlx::query!( ··· 564 563 565 564 Ok(()) 566 565 } 567 - 568 - pub async fn start_backup_tasks( 569 - db: PgPool, 570 - block_store: PostgresBlockStore, 571 - backup_storage: Arc<BackupStorage>, 572 - mut shutdown_rx: watch::Receiver<bool>, 573 - ) { 574 - let backup_interval = Duration::from_secs(BackupStorage::interval_secs()); 575 - 576 - info!( 577 - interval_secs = backup_interval.as_secs(), 578 - retention_count = BackupStorage::retention_count(), 579 - "Starting backup service" 580 - ); 581 - 582 - let mut ticker = interval(backup_interval); 583 - ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); 584 - 585 - loop { 586 - tokio::select! { 587 - _ = shutdown_rx.changed() => { 588 - if *shutdown_rx.borrow() { 589 - info!("Backup service shutting down"); 590 - break; 591 - } 592 - } 593 - _ = ticker.tick() => { 594 - if let Err(e) = process_scheduled_backups(&db, &block_store, &backup_storage).await { 595 - error!("Error processing scheduled backups: {}", e); 596 - } 597 - } 598 - } 599 - } 600 - } 601 - 602 - async fn process_scheduled_backups( 603 - db: &PgPool, 604 - block_store: &PostgresBlockStore, 605 - backup_storage: &BackupStorage, 606 - ) -> Result<(), String> { 607 - let backup_interval_secs = BackupStorage::interval_secs() as i64; 608 - let retention_count = BackupStorage::retention_count(); 609 - 610 - let users_needing_backup = sqlx::query!( 611 - r#" 612 - SELECT u.id as user_id, u.did, r.repo_root_cid, r.repo_rev 613 - FROM users u 614 - JOIN repos r ON r.user_id = u.id 615 - WHERE u.backup_enabled = true 616 - AND u.deactivated_at IS NULL 617 - AND ( 618 - NOT EXISTS ( 619 - SELECT 1 FROM account_backups ab WHERE ab.user_id = u.id 620 - ) 621 - OR ( 622 - SELECT MAX(ab.created_at) FROM account_backups ab WHERE ab.user_id = u.id 623 - ) < NOW() - make_interval(secs => $1) 624 - ) 625 - LIMIT 50 626 - "#, 627 - backup_interval_secs as f64 628 - ) 629 - .fetch_all(db) 630 - .await 631 - .map_err(|e| format!("DB error fetching users for backup: {}", e))?; 632 - 633 - if users_needing_backup.is_empty() { 634 - debug!("No accounts need backup"); 635 - return Ok(()); 636 - } 637 - 638 - info!( 639 - count = users_needing_backup.len(), 640 - "Processing scheduled backups" 641 - ); 642 - 643 - for user in users_needing_backup { 644 - let repo_root_cid = user.repo_root_cid.clone(); 645 - 646 - let repo_rev = match &user.repo_rev { 647 - Some(rev) => rev.clone(), 648 - None => { 649 - warn!(did = %user.did, "User has no repo_rev, skipping backup"); 650 - continue; 651 - } 652 - }; 653 - 654 - let head_cid = match Cid::from_str(&repo_root_cid) { 655 - Ok(c) => c, 656 - Err(e) => { 657 - warn!(did = %user.did, error = %e, "Invalid repo_root_cid, skipping backup"); 658 - continue; 659 - } 660 - }; 661 - 662 - let car_result = generate_full_backup(block_store, &head_cid).await; 663 - let car_bytes = match car_result { 664 - Ok(bytes) => bytes, 665 - Err(e) => { 666 - warn!(did = %user.did, error = %e, "Failed to generate CAR for backup"); 667 - continue; 668 - } 669 - }; 670 - 671 - let block_count = count_car_blocks(&car_bytes); 672 - let size_bytes = car_bytes.len() as i64; 673 - 674 - let storage_key = match backup_storage 675 - .put_backup(&user.did, &repo_rev, &car_bytes) 676 - .await 677 - { 678 - Ok(key) => key, 679 - Err(e) => { 680 - warn!(did = %user.did, error = %e, "Failed to upload backup to storage"); 681 - continue; 682 - } 683 - }; 684 - 685 - if let Err(e) = sqlx::query!( 686 - r#" 687 - INSERT INTO account_backups (user_id, storage_key, repo_root_cid, repo_rev, block_count, size_bytes) 688 - VALUES ($1, $2, $3, $4, $5, $6) 689 - "#, 690 - user.user_id, 691 - storage_key, 692 - repo_root_cid, 693 - repo_rev, 694 - block_count, 695 - size_bytes 696 - ) 697 - .execute(db) 698 - .await 699 - { 700 - warn!(did = %user.did, error = %e, "Failed to insert backup record, rolling back S3 upload"); 701 - if let Err(rollback_err) = backup_storage.delete_backup(&storage_key).await { 702 - error!( 703 - did = %user.did, 704 - storage_key = %storage_key, 705 - error = %rollback_err, 706 - "Failed to rollback orphaned backup from S3" 707 - ); 708 - } 709 - continue; 710 - } 711 - 712 - info!( 713 - did = %user.did, 714 - rev = %repo_rev, 715 - size_bytes, 716 - block_count, 717 - "Created backup" 718 - ); 719 - 720 - if let Err(e) = cleanup_old_backups(db, backup_storage, user.user_id, retention_count).await 721 - { 722 - warn!(did = %user.did, error = %e, "Failed to cleanup old backups"); 723 - } 724 - } 725 - 726 - Ok(()) 727 - } 728 - 729 - pub async fn generate_repo_car( 730 - block_store: &PostgresBlockStore, 731 - head_cid: &Cid, 732 - ) -> Result<Vec<u8>, String> { 733 - use jacquard_repo::storage::BlockStore; 734 - use std::io::Write; 735 - 736 - let mut car_bytes = 737 - encode_car_header(head_cid).map_err(|e| format!("Failed to encode CAR header: {}", e))?; 738 - 739 - let mut stack = vec![*head_cid]; 740 - let mut visited = std::collections::HashSet::new(); 741 - 742 - while let Some(cid) = stack.pop() { 743 - if visited.contains(&cid) { 744 - continue; 745 - } 746 - visited.insert(cid); 747 - 748 - if let Ok(Some(block)) = block_store.get(&cid).await { 749 - let cid_bytes = cid.to_bytes(); 750 - let total_len = cid_bytes.len() + block.len(); 751 - let mut writer = Vec::new(); 752 - crate::sync::car::write_varint(&mut writer, total_len as u64) 753 - .expect("Writing to Vec<u8> should never fail"); 754 - writer 755 - .write_all(&cid_bytes) 756 - .expect("Writing to Vec<u8> should never fail"); 757 - writer 758 - .write_all(&block) 759 - .expect("Writing to Vec<u8> should never fail"); 760 - car_bytes.extend_from_slice(&writer); 761 - 762 - if let Ok(value) = serde_ipld_dagcbor::from_slice::<Ipld>(&block) { 763 - extract_links(&value, &mut stack); 764 - } 765 - } 766 - } 767 - 768 - Ok(car_bytes) 769 - } 770 - 771 - pub async fn generate_full_backup( 772 - block_store: &PostgresBlockStore, 773 - head_cid: &Cid, 774 - ) -> Result<Vec<u8>, String> { 775 - generate_repo_car(block_store, head_cid).await 776 - } 777 - 778 - fn extract_links(value: &Ipld, stack: &mut Vec<Cid>) { 779 - match value { 780 - Ipld::Link(cid) => { 781 - stack.push(*cid); 782 - } 783 - Ipld::Map(map) => { 784 - for v in map.values() { 785 - extract_links(v, stack); 786 - } 787 - } 788 - Ipld::List(arr) => { 789 - for v in arr { 790 - extract_links(v, stack); 791 - } 792 - } 793 - _ => {} 794 - } 795 - } 796 - 797 - pub fn count_car_blocks(car_bytes: &[u8]) -> i32 { 798 - let mut count = 0; 799 - let mut pos = 0; 800 - 801 - if let Some((header_len, header_varint_len)) = read_varint(&car_bytes[pos..]) { 802 - pos += header_varint_len + header_len as usize; 803 - } else { 804 - return 0; 805 - } 806 - 807 - while pos < car_bytes.len() { 808 - if let Some((block_len, varint_len)) = read_varint(&car_bytes[pos..]) { 809 - pos += varint_len + block_len as usize; 810 - count += 1; 811 - } else { 812 - break; 813 - } 814 - } 815 - 816 - count 817 - } 818 - 819 - fn read_varint(data: &[u8]) -> Option<(u64, usize)> { 820 - let mut value: u64 = 0; 821 - let mut shift = 0; 822 - let mut pos = 0; 823 - 824 - while pos < data.len() && pos < 10 { 825 - let byte = data[pos]; 826 - value |= ((byte & 0x7f) as u64) << shift; 827 - pos += 1; 828 - if byte & 0x80 == 0 { 829 - return Some((value, pos)); 830 - } 831 - shift += 7; 832 - } 833 - 834 - None 835 - } 836 - 837 - async fn cleanup_old_backups( 838 - db: &PgPool, 839 - backup_storage: &BackupStorage, 840 - user_id: uuid::Uuid, 841 - retention_count: u32, 842 - ) -> Result<(), String> { 843 - let old_backups = sqlx::query!( 844 - r#" 845 - SELECT id, storage_key 846 - FROM account_backups 847 - WHERE user_id = $1 848 - ORDER BY created_at DESC 849 - OFFSET $2 850 - "#, 851 - user_id, 852 - retention_count as i64 853 - ) 854 - .fetch_all(db) 855 - .await 856 - .map_err(|e| format!("DB error fetching old backups: {}", e))?; 857 - 858 - for backup in old_backups { 859 - if let Err(e) = backup_storage.delete_backup(&backup.storage_key).await { 860 - warn!( 861 - storage_key = %backup.storage_key, 862 - error = %e, 863 - "Failed to delete old backup from storage, skipping DB cleanup to avoid orphan" 864 - ); 865 - continue; 866 - } 867 - 868 - sqlx::query!("DELETE FROM account_backups WHERE id = $1", backup.id) 869 - .execute(db) 870 - .await 871 - .map_err(|e| format!("Failed to delete old backup record: {}", e))?; 872 - } 873 - 874 - Ok(()) 875 - }
+1 -8
src/state.rs
··· 4 4 use crate::config::AuthConfig; 5 5 use crate::rate_limit::RateLimiters; 6 6 use crate::repo::PostgresBlockStore; 7 - use crate::storage::{BackupStorage, BlobStorage, S3BlobStorage}; 7 + use crate::storage::{BlobStorage, S3BlobStorage}; 8 8 use crate::sync::firehose::SequencedEvent; 9 9 use sqlx::PgPool; 10 10 use std::error::Error; ··· 16 16 pub db: PgPool, 17 17 pub block_store: PostgresBlockStore, 18 18 pub blob_store: Arc<dyn BlobStorage>, 19 - pub backup_storage: Option<Arc<BackupStorage>>, 20 19 pub firehose_tx: broadcast::Sender<SequencedEvent>, 21 20 pub rate_limiters: Arc<RateLimiters>, 22 21 pub circuit_breakers: Arc<CircuitBreakers>, ··· 40 39 TotpVerify, 41 40 HandleUpdate, 42 41 HandleUpdateDaily, 43 - VerificationCheck, 44 42 } 45 43 46 44 impl RateLimitKind { ··· 60 58 Self::TotpVerify => "totp_verify", 61 59 Self::HandleUpdate => "handle_update", 62 60 Self::HandleUpdateDaily => "handle_update_daily", 63 - Self::VerificationCheck => "verification_check", 64 61 } 65 62 } 66 63 ··· 80 77 Self::TotpVerify => (5, 300_000), 81 78 Self::HandleUpdate => (10, 300_000), 82 79 Self::HandleUpdateDaily => (50, 86_400_000), 83 - Self::VerificationCheck => (60, 60_000), 84 80 } 85 81 } 86 82 } ··· 135 131 136 132 let block_store = PostgresBlockStore::new(db.clone()); 137 133 let blob_store = S3BlobStorage::new().await; 138 - let backup_storage = BackupStorage::new().await.map(Arc::new); 139 134 140 135 let firehose_buffer_size: usize = std::env::var("FIREHOSE_BUFFER_SIZE") 141 136 .ok() ··· 152 147 db, 153 148 block_store, 154 149 blob_store: Arc::new(blob_store), 155 - backup_storage, 156 150 firehose_tx, 157 151 rate_limiters, 158 152 circuit_breakers, ··· 205 199 RateLimitKind::TotpVerify => &self.rate_limiters.totp_verify, 206 200 RateLimitKind::HandleUpdate => &self.rate_limiters.handle_update, 207 201 RateLimitKind::HandleUpdateDaily => &self.rate_limiters.handle_update_daily, 208 - RateLimitKind::VerificationCheck => &self.rate_limiters.verification_check, 209 202 }; 210 203 211 204 let ok = limiter.check_key(&client_ip.to_string()).is_ok();
+16 -119
src/storage/mod.rs
··· 32 32 33 33 impl S3BlobStorage { 34 34 pub async fn new() -> Self { 35 - let bucket = std::env::var("S3_BUCKET").expect("S3_BUCKET must be set"); 36 - let client = create_s3_client().await; 37 - Self { client, bucket } 38 - } 39 - } 40 - 41 - async fn create_s3_client() -> Client { 42 - let region_provider = RegionProviderChain::default_provider().or_else("us-east-1"); 43 - 44 - let config = aws_config::defaults(BehaviorVersion::latest()) 45 - .region(region_provider) 46 - .load() 47 - .await; 48 - 49 - if let Ok(endpoint) = std::env::var("S3_ENDPOINT") { 50 - let s3_config = aws_sdk_s3::config::Builder::from(&config) 51 - .endpoint_url(endpoint) 52 - .force_path_style(true) 53 - .build(); 54 - Client::from_conf(s3_config) 55 - } else { 56 - Client::new(&config) 57 - } 58 - } 59 - 60 - pub struct BackupStorage { 61 - client: Client, 62 - bucket: String, 63 - } 64 - 65 - impl BackupStorage { 66 - pub async fn new() -> Option<Self> { 67 - let backup_enabled = std::env::var("BACKUP_ENABLED") 68 - .map(|v| v != "false" && v != "0") 69 - .unwrap_or(true); 35 + let region_provider = RegionProviderChain::default_provider().or_else("us-east-1"); 70 36 71 - if !backup_enabled { 72 - return None; 73 - } 37 + let config = aws_config::defaults(BehaviorVersion::latest()) 38 + .region(region_provider) 39 + .load() 40 + .await; 74 41 75 - let bucket = std::env::var("BACKUP_S3_BUCKET").ok()?; 76 - let client = create_s3_client().await; 77 - Some(Self { client, bucket }) 78 - } 42 + let bucket = std::env::var("S3_BUCKET").expect("S3_BUCKET must be set"); 79 43 80 - pub fn retention_count() -> u32 { 81 - std::env::var("BACKUP_RETENTION_COUNT") 82 - .ok() 83 - .and_then(|v| v.parse().ok()) 84 - .unwrap_or(7) 85 - } 44 + let client = if let Ok(endpoint) = std::env::var("S3_ENDPOINT") { 45 + let s3_config = aws_sdk_s3::config::Builder::from(&config) 46 + .endpoint_url(endpoint) 47 + .force_path_style(true) 48 + .build(); 49 + Client::from_conf(s3_config) 50 + } else { 51 + Client::new(&config) 52 + }; 86 53 87 - pub fn interval_secs() -> u64 { 88 - std::env::var("BACKUP_INTERVAL_SECS") 89 - .ok() 90 - .and_then(|v| v.parse().ok()) 91 - .unwrap_or(86400) 92 - } 93 - 94 - pub async fn put_backup( 95 - &self, 96 - did: &str, 97 - rev: &str, 98 - data: &[u8], 99 - ) -> Result<String, StorageError> { 100 - let key = format!("{}/{}.car", did, rev); 101 - self.client 102 - .put_object() 103 - .bucket(&self.bucket) 104 - .key(&key) 105 - .body(ByteStream::from(Bytes::copy_from_slice(data))) 106 - .send() 107 - .await 108 - .map_err(|e| { 109 - crate::metrics::record_s3_operation("backup_put", "error"); 110 - StorageError::S3(e.to_string()) 111 - })?; 112 - 113 - crate::metrics::record_s3_operation("backup_put", "success"); 114 - Ok(key) 115 - } 116 - 117 - pub async fn get_backup(&self, storage_key: &str) -> Result<Bytes, StorageError> { 118 - let resp = self 119 - .client 120 - .get_object() 121 - .bucket(&self.bucket) 122 - .key(storage_key) 123 - .send() 124 - .await 125 - .map_err(|e| { 126 - crate::metrics::record_s3_operation("backup_get", "error"); 127 - StorageError::S3(e.to_string()) 128 - })?; 129 - 130 - let data = resp 131 - .body 132 - .collect() 133 - .await 134 - .map_err(|e| { 135 - crate::metrics::record_s3_operation("backup_get", "error"); 136 - StorageError::S3(e.to_string()) 137 - })? 138 - .into_bytes(); 139 - 140 - crate::metrics::record_s3_operation("backup_get", "success"); 141 - Ok(data) 142 - } 143 - 144 - pub async fn delete_backup(&self, storage_key: &str) -> Result<(), StorageError> { 145 - self.client 146 - .delete_object() 147 - .bucket(&self.bucket) 148 - .key(storage_key) 149 - .send() 150 - .await 151 - .map_err(|e| { 152 - crate::metrics::record_s3_operation("backup_delete", "error"); 153 - StorageError::S3(e.to_string()) 154 - })?; 155 - 156 - crate::metrics::record_s3_operation("backup_delete", "success"); 157 - Ok(()) 54 + Self { client, bucket } 158 55 } 159 56 } 160 57
+12 -23
src/sync/import.rs
··· 77 77 Ipld::Map(obj) => { 78 78 if let Some(Ipld::String(type_str)) = obj.get("$type") 79 79 && type_str == "blob" 80 + && let Some(Ipld::Link(link_cid)) = obj.get("ref") 80 81 { 81 - let cid_str = if let Some(Ipld::Link(link_cid)) = obj.get("ref") { 82 - Some(link_cid.to_string()) 83 - } else if let Some(Ipld::Map(ref_obj)) = obj.get("ref") 84 - && let Some(Ipld::String(link)) = ref_obj.get("$link") 85 - { 86 - Some(link.clone()) 87 - } else { 88 - None 89 - }; 90 - 91 - if let Some(cid) = cid_str { 92 - let mime = obj.get("mimeType").and_then(|v| { 93 - if let Ipld::String(s) = v { 94 - Some(s.clone()) 95 - } else { 96 - None 97 - } 98 - }); 99 - return vec![BlobRef { 100 - cid, 101 - mime_type: mime, 102 - }]; 103 - } 82 + let mime = obj.get("mimeType").and_then(|v| { 83 + if let Ipld::String(s) = v { 84 + Some(s.clone()) 85 + } else { 86 + None 87 + } 88 + }); 89 + return vec![BlobRef { 90 + cid: link_cid.to_string(), 91 + mime_type: mime, 92 + }]; 104 93 } 105 94 obj.values() 106 95 .flat_map(|v| find_blob_refs_ipld(v, depth + 1))
-129
src/util.rs
··· 1 1 use axum::http::HeaderMap; 2 - use cid::Cid; 3 - use ipld_core::ipld::Ipld; 4 2 use rand::Rng; 5 - use serde_json::Value as JsonValue; 6 3 use sqlx::PgPool; 7 - use std::collections::BTreeMap; 8 - use std::str::FromStr; 9 4 use std::sync::OnceLock; 10 5 use uuid::Uuid; 11 6 ··· 155 150 format!("{}{}", pds_public_url(), path) 156 151 } 157 152 158 - pub fn json_to_ipld(value: &JsonValue) -> Ipld { 159 - match value { 160 - JsonValue::Null => Ipld::Null, 161 - JsonValue::Bool(b) => Ipld::Bool(*b), 162 - JsonValue::Number(n) => { 163 - if let Some(i) = n.as_i64() { 164 - Ipld::Integer(i as i128) 165 - } else if let Some(f) = n.as_f64() { 166 - Ipld::Float(f) 167 - } else { 168 - Ipld::Null 169 - } 170 - } 171 - JsonValue::String(s) => Ipld::String(s.clone()), 172 - JsonValue::Array(arr) => Ipld::List(arr.iter().map(json_to_ipld).collect()), 173 - JsonValue::Object(obj) => { 174 - if let Some(JsonValue::String(link)) = obj.get("$link") 175 - && obj.len() == 1 176 - && let Ok(cid) = Cid::from_str(link) 177 - { 178 - return Ipld::Link(cid); 179 - } 180 - let map: BTreeMap<String, Ipld> = obj 181 - .iter() 182 - .map(|(k, v)| (k.clone(), json_to_ipld(v))) 183 - .collect(); 184 - Ipld::Map(map) 185 - } 186 - } 187 - } 188 - 189 153 #[cfg(test)] 190 154 mod tests { 191 155 use super::*; ··· 259 223 for part in parts { 260 224 assert_eq!(part.len(), 4); 261 225 } 262 - } 263 - 264 - #[test] 265 - fn test_json_to_ipld_cid_link() { 266 - let json = serde_json::json!({ 267 - "$link": "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku" 268 - }); 269 - let ipld = json_to_ipld(&json); 270 - match ipld { 271 - Ipld::Link(cid) => { 272 - assert_eq!( 273 - cid.to_string(), 274 - "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku" 275 - ); 276 - } 277 - _ => panic!("Expected Ipld::Link, got {:?}", ipld), 278 - } 279 - } 280 - 281 - #[test] 282 - fn test_json_to_ipld_blob_ref() { 283 - let json = serde_json::json!({ 284 - "$type": "blob", 285 - "ref": { 286 - "$link": "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku" 287 - }, 288 - "mimeType": "image/jpeg", 289 - "size": 12345 290 - }); 291 - let ipld = json_to_ipld(&json); 292 - match ipld { 293 - Ipld::Map(map) => { 294 - assert_eq!(map.get("$type"), Some(&Ipld::String("blob".to_string()))); 295 - assert_eq!( 296 - map.get("mimeType"), 297 - Some(&Ipld::String("image/jpeg".to_string())) 298 - ); 299 - assert_eq!(map.get("size"), Some(&Ipld::Integer(12345))); 300 - match map.get("ref") { 301 - Some(Ipld::Link(cid)) => { 302 - assert_eq!( 303 - cid.to_string(), 304 - "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku" 305 - ); 306 - } 307 - _ => panic!("Expected Ipld::Link in ref field, got {:?}", map.get("ref")), 308 - } 309 - } 310 - _ => panic!("Expected Ipld::Map, got {:?}", ipld), 311 - } 312 - } 313 - 314 - #[test] 315 - fn test_json_to_ipld_nested_blob_refs_serializes_correctly() { 316 - let record = serde_json::json!({ 317 - "$type": "app.bsky.feed.post", 318 - "text": "Hello world", 319 - "embed": { 320 - "$type": "app.bsky.embed.images", 321 - "images": [ 322 - { 323 - "alt": "Test image", 324 - "image": { 325 - "$type": "blob", 326 - "ref": { 327 - "$link": "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku" 328 - }, 329 - "mimeType": "image/jpeg", 330 - "size": 12345 331 - } 332 - } 333 - ] 334 - } 335 - }); 336 - let ipld = json_to_ipld(&record); 337 - let cbor_bytes = serde_ipld_dagcbor::to_vec(&ipld).expect("CBOR serialization failed"); 338 - assert!(!cbor_bytes.is_empty()); 339 - let parsed: Ipld = 340 - serde_ipld_dagcbor::from_slice(&cbor_bytes).expect("CBOR deserialization failed"); 341 - if let Ipld::Map(map) = &parsed 342 - && let Some(Ipld::Map(embed)) = map.get("embed") 343 - && let Some(Ipld::List(images)) = embed.get("images") 344 - && let Some(Ipld::Map(img)) = images.first() 345 - && let Some(Ipld::Map(blob)) = img.get("image") 346 - && let Some(Ipld::Link(cid)) = blob.get("ref") 347 - { 348 - assert_eq!( 349 - cid.to_string(), 350 - "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku" 351 - ); 352 - return; 353 - } 354 - panic!("Failed to find CID link in parsed CBOR"); 355 226 } 356 227 }
+40 -10
tests/account_notifications.rs
··· 27 27 } 28 28 29 29 let resp = client 30 - .get(format!("{}/xrpc/_account.getNotificationHistory", base)) 30 + .get(format!( 31 + "{}/xrpc/com.tranquil.account.getNotificationHistory", 32 + base 33 + )) 31 34 .header("Authorization", format!("Bearer {}", token)) 32 35 .send() 33 36 .await ··· 53 56 "discordId": "123456789" 54 57 }); 55 58 let resp = client 56 - .post(format!("{}/xrpc/_account.updateNotificationPrefs", base)) 59 + .post(format!( 60 + "{}/xrpc/com.tranquil.account.updateNotificationPrefs", 61 + base 62 + )) 57 63 .header("Authorization", format!("Bearer {}", token)) 58 64 .json(&prefs) 59 65 .send() ··· 95 101 "code": code 96 102 }); 97 103 let resp = client 98 - .post(format!("{}/xrpc/_account.confirmChannelVerification", base)) 104 + .post(format!( 105 + "{}/xrpc/com.tranquil.account.confirmChannelVerification", 106 + base 107 + )) 99 108 .header("Authorization", format!("Bearer {}", token)) 100 109 .json(&input) 101 110 .send() ··· 104 113 assert_eq!(resp.status(), 200); 105 114 106 115 let resp = client 107 - .get(format!("{}/xrpc/_account.getNotificationPrefs", base)) 116 + .get(format!( 117 + "{}/xrpc/com.tranquil.account.getNotificationPrefs", 118 + base 119 + )) 108 120 .header("Authorization", format!("Bearer {}", token)) 109 121 .send() 110 122 .await ··· 124 136 "telegramUsername": "testuser" 125 137 }); 126 138 let resp = client 127 - .post(format!("{}/xrpc/_account.updateNotificationPrefs", base)) 139 + .post(format!( 140 + "{}/xrpc/com.tranquil.account.updateNotificationPrefs", 141 + base 142 + )) 128 143 .header("Authorization", format!("Bearer {}", token)) 129 144 .json(&prefs) 130 145 .send() ··· 138 153 "code": "XXXX-XXXX-XXXX-XXXX" 139 154 }); 140 155 let resp = client 141 - .post(format!("{}/xrpc/_account.confirmChannelVerification", base)) 156 + .post(format!( 157 + "{}/xrpc/com.tranquil.account.confirmChannelVerification", 158 + base 159 + )) 142 160 .header("Authorization", format!("Bearer {}", token)) 143 161 .json(&input) 144 162 .send() ··· 163 181 "code": "XXXX-XXXX-XXXX-XXXX" 164 182 }); 165 183 let resp = client 166 - .post(format!("{}/xrpc/_account.confirmChannelVerification", base)) 184 + .post(format!( 185 + "{}/xrpc/com.tranquil.account.confirmChannelVerification", 186 + base 187 + )) 167 188 .header("Authorization", format!("Bearer {}", token)) 168 189 .json(&input) 169 190 .send() ··· 188 209 "email": unique_email 189 210 }); 190 211 let resp = client 191 - .post(format!("{}/xrpc/_account.updateNotificationPrefs", base)) 212 + .post(format!( 213 + "{}/xrpc/com.tranquil.account.updateNotificationPrefs", 214 + base 215 + )) 192 216 .header("Authorization", format!("Bearer {}", token)) 193 217 .json(&prefs) 194 218 .send() ··· 239 263 "code": code 240 264 }); 241 265 let resp = client 242 - .post(format!("{}/xrpc/_account.confirmChannelVerification", base)) 266 + .post(format!( 267 + "{}/xrpc/com.tranquil.account.confirmChannelVerification", 268 + base 269 + )) 243 270 .header("Authorization", format!("Bearer {}", token)) 244 271 .json(&input) 245 272 .send() ··· 248 275 assert_eq!(resp.status(), 200); 249 276 250 277 let resp = client 251 - .get(format!("{}/xrpc/_account.getNotificationPrefs", base)) 278 + .get(format!( 279 + "{}/xrpc/com.tranquil.account.getNotificationPrefs", 280 + base 281 + )) 252 282 .header("Authorization", format!("Bearer {}", token)) 253 283 .send() 254 284 .await
+2 -2
tests/admin_stats.rs
··· 11 11 let (_, _) = create_admin_account_and_login(&client).await; 12 12 13 13 let resp = client 14 - .get(format!("{}/xrpc/_admin.getServerStats", base)) 14 + .get(format!("{}/xrpc/com.tranquil.admin.getServerStats", base)) 15 15 .header("Authorization", format!("Bearer {}", token1)) 16 16 .send() 17 17 .await ··· 33 33 let client = client(); 34 34 let base = base_url().await; 35 35 let resp = client 36 - .get(format!("{}/xrpc/_admin.getServerStats", base)) 36 + .get(format!("{}/xrpc/com.tranquil.admin.getServerStats", base)) 37 37 .send() 38 38 .await 39 39 .unwrap();
-325
tests/backup.rs
··· 1 - mod common; 2 - mod helpers; 3 - 4 - use common::*; 5 - use reqwest::{StatusCode, header}; 6 - use serde_json::{Value, json}; 7 - 8 - #[tokio::test] 9 - async fn test_list_backups_empty() { 10 - let client = client(); 11 - let (token, _did) = create_account_and_login(&client).await; 12 - 13 - let res = client 14 - .get(format!("{}/xrpc/_backup.listBackups", base_url().await)) 15 - .bearer_auth(&token) 16 - .send() 17 - .await 18 - .expect("listBackups request failed"); 19 - 20 - assert_eq!(res.status(), StatusCode::OK); 21 - let body: Value = res.json().await.expect("Invalid JSON"); 22 - assert!(body["backups"].is_array()); 23 - assert_eq!(body["backups"].as_array().unwrap().len(), 0); 24 - assert!(body["backupEnabled"].as_bool().unwrap_or(false)); 25 - } 26 - 27 - #[tokio::test] 28 - async fn test_create_and_list_backup() { 29 - let client = client(); 30 - let (token, _did) = create_account_and_login(&client).await; 31 - 32 - let create_res = client 33 - .post(format!("{}/xrpc/_backup.createBackup", base_url().await)) 34 - .bearer_auth(&token) 35 - .send() 36 - .await 37 - .expect("createBackup request failed"); 38 - 39 - assert_eq!(create_res.status(), StatusCode::OK, "createBackup failed"); 40 - let create_body: Value = create_res.json().await.expect("Invalid JSON"); 41 - assert!(create_body["id"].is_string()); 42 - assert!(create_body["repoRev"].is_string()); 43 - assert!(create_body["sizeBytes"].is_i64()); 44 - assert!(create_body["blockCount"].is_i64()); 45 - 46 - let list_res = client 47 - .get(format!("{}/xrpc/_backup.listBackups", base_url().await)) 48 - .bearer_auth(&token) 49 - .send() 50 - .await 51 - .expect("listBackups request failed"); 52 - 53 - assert_eq!(list_res.status(), StatusCode::OK); 54 - let list_body: Value = list_res.json().await.expect("Invalid JSON"); 55 - let backups = list_body["backups"].as_array().unwrap(); 56 - assert!(backups.len() >= 1); 57 - } 58 - 59 - #[tokio::test] 60 - async fn test_download_backup() { 61 - let client = client(); 62 - let (token, _did) = create_account_and_login(&client).await; 63 - 64 - let create_res = client 65 - .post(format!("{}/xrpc/_backup.createBackup", base_url().await)) 66 - .bearer_auth(&token) 67 - .send() 68 - .await 69 - .expect("createBackup request failed"); 70 - 71 - assert_eq!(create_res.status(), StatusCode::OK); 72 - let create_body: Value = create_res.json().await.expect("Invalid JSON"); 73 - let backup_id = create_body["id"].as_str().unwrap(); 74 - 75 - let get_res = client 76 - .get(format!( 77 - "{}/xrpc/_backup.getBackup?id={}", 78 - base_url().await, 79 - backup_id 80 - )) 81 - .bearer_auth(&token) 82 - .send() 83 - .await 84 - .expect("getBackup request failed"); 85 - 86 - assert_eq!(get_res.status(), StatusCode::OK); 87 - let content_type = get_res.headers().get(header::CONTENT_TYPE).unwrap(); 88 - assert_eq!(content_type, "application/vnd.ipld.car"); 89 - 90 - let bytes = get_res.bytes().await.expect("Failed to read body"); 91 - assert!(bytes.len() > 100, "CAR file should have content"); 92 - assert_eq!( 93 - bytes[1], 0xa2, 94 - "CAR file should have valid header structure" 95 - ); 96 - } 97 - 98 - #[tokio::test] 99 - async fn test_delete_backup() { 100 - let client = client(); 101 - let (token, _did) = create_account_and_login(&client).await; 102 - 103 - let create_res = client 104 - .post(format!("{}/xrpc/_backup.createBackup", base_url().await)) 105 - .bearer_auth(&token) 106 - .send() 107 - .await 108 - .expect("createBackup request failed"); 109 - 110 - assert_eq!(create_res.status(), StatusCode::OK); 111 - let create_body: Value = create_res.json().await.expect("Invalid JSON"); 112 - let backup_id = create_body["id"].as_str().unwrap(); 113 - 114 - let delete_res = client 115 - .post(format!( 116 - "{}/xrpc/_backup.deleteBackup?id={}", 117 - base_url().await, 118 - backup_id 119 - )) 120 - .bearer_auth(&token) 121 - .send() 122 - .await 123 - .expect("deleteBackup request failed"); 124 - 125 - assert_eq!(delete_res.status(), StatusCode::OK); 126 - 127 - let get_res = client 128 - .get(format!( 129 - "{}/xrpc/_backup.getBackup?id={}", 130 - base_url().await, 131 - backup_id 132 - )) 133 - .bearer_auth(&token) 134 - .send() 135 - .await 136 - .expect("getBackup request failed"); 137 - 138 - assert_eq!(get_res.status(), StatusCode::NOT_FOUND); 139 - } 140 - 141 - #[tokio::test] 142 - async fn test_toggle_backup_enabled() { 143 - let client = client(); 144 - let (token, _did) = create_account_and_login(&client).await; 145 - 146 - let list_res = client 147 - .get(format!("{}/xrpc/_backup.listBackups", base_url().await)) 148 - .bearer_auth(&token) 149 - .send() 150 - .await 151 - .expect("listBackups request failed"); 152 - 153 - assert_eq!(list_res.status(), StatusCode::OK); 154 - let list_body: Value = list_res.json().await.expect("Invalid JSON"); 155 - assert!(list_body["backupEnabled"].as_bool().unwrap()); 156 - 157 - let disable_res = client 158 - .post(format!("{}/xrpc/_backup.setEnabled", base_url().await)) 159 - .bearer_auth(&token) 160 - .json(&json!({"enabled": false})) 161 - .send() 162 - .await 163 - .expect("setEnabled request failed"); 164 - 165 - assert_eq!(disable_res.status(), StatusCode::OK); 166 - let disable_body: Value = disable_res.json().await.expect("Invalid JSON"); 167 - assert!(!disable_body["enabled"].as_bool().unwrap()); 168 - 169 - let list_res2 = client 170 - .get(format!("{}/xrpc/_backup.listBackups", base_url().await)) 171 - .bearer_auth(&token) 172 - .send() 173 - .await 174 - .expect("listBackups request failed"); 175 - 176 - let list_body2: Value = list_res2.json().await.expect("Invalid JSON"); 177 - assert!(!list_body2["backupEnabled"].as_bool().unwrap()); 178 - 179 - let enable_res = client 180 - .post(format!("{}/xrpc/_backup.setEnabled", base_url().await)) 181 - .bearer_auth(&token) 182 - .json(&json!({"enabled": true})) 183 - .send() 184 - .await 185 - .expect("setEnabled request failed"); 186 - 187 - assert_eq!(enable_res.status(), StatusCode::OK); 188 - } 189 - 190 - #[tokio::test] 191 - async fn test_backup_includes_blobs() { 192 - let client = client(); 193 - let (token, did) = create_account_and_login(&client).await; 194 - 195 - let blob_data = b"Hello, this is test blob data for backup testing!"; 196 - let upload_res = client 197 - .post(format!( 198 - "{}/xrpc/com.atproto.repo.uploadBlob", 199 - base_url().await 200 - )) 201 - .header(header::CONTENT_TYPE, "text/plain") 202 - .bearer_auth(&token) 203 - .body(blob_data.to_vec()) 204 - .send() 205 - .await 206 - .expect("uploadBlob request failed"); 207 - 208 - assert_eq!(upload_res.status(), StatusCode::OK); 209 - let upload_body: Value = upload_res.json().await.expect("Invalid JSON"); 210 - let blob = &upload_body["blob"]; 211 - 212 - let record = json!({ 213 - "$type": "app.bsky.feed.post", 214 - "text": "Test post with blob", 215 - "createdAt": chrono::Utc::now().to_rfc3339(), 216 - "embed": { 217 - "$type": "app.bsky.embed.images", 218 - "images": [{ 219 - "alt": "test image", 220 - "image": blob 221 - }] 222 - } 223 - }); 224 - 225 - let create_record_res = client 226 - .post(format!( 227 - "{}/xrpc/com.atproto.repo.createRecord", 228 - base_url().await 229 - )) 230 - .bearer_auth(&token) 231 - .json(&json!({ 232 - "repo": did, 233 - "collection": "app.bsky.feed.post", 234 - "record": record 235 - })) 236 - .send() 237 - .await 238 - .expect("createRecord request failed"); 239 - 240 - assert_eq!(create_record_res.status(), StatusCode::OK); 241 - 242 - let create_backup_res = client 243 - .post(format!("{}/xrpc/_backup.createBackup", base_url().await)) 244 - .bearer_auth(&token) 245 - .send() 246 - .await 247 - .expect("createBackup request failed"); 248 - 249 - assert_eq!(create_backup_res.status(), StatusCode::OK); 250 - let backup_body: Value = create_backup_res.json().await.expect("Invalid JSON"); 251 - let backup_id = backup_body["id"].as_str().unwrap(); 252 - 253 - let get_backup_res = client 254 - .get(format!( 255 - "{}/xrpc/_backup.getBackup?id={}", 256 - base_url().await, 257 - backup_id 258 - )) 259 - .bearer_auth(&token) 260 - .send() 261 - .await 262 - .expect("getBackup request failed"); 263 - 264 - assert_eq!(get_backup_res.status(), StatusCode::OK); 265 - let car_bytes = get_backup_res.bytes().await.expect("Failed to read body"); 266 - 267 - let blob_cid = blob["ref"]["$link"].as_str().unwrap(); 268 - let blob_found = String::from_utf8_lossy(&car_bytes).contains("Hello, this is test blob data"); 269 - assert!( 270 - blob_found || car_bytes.len() > 500, 271 - "Backup should contain blob data (cid: {})", 272 - blob_cid 273 - ); 274 - } 275 - 276 - #[tokio::test] 277 - async fn test_backup_unauthorized() { 278 - let client = client(); 279 - 280 - let res = client 281 - .get(format!("{}/xrpc/_backup.listBackups", base_url().await)) 282 - .send() 283 - .await 284 - .expect("listBackups request failed"); 285 - 286 - assert_eq!(res.status(), StatusCode::UNAUTHORIZED); 287 - } 288 - 289 - #[tokio::test] 290 - async fn test_get_nonexistent_backup() { 291 - let client = client(); 292 - let (token, _did) = create_account_and_login(&client).await; 293 - 294 - let fake_id = uuid::Uuid::new_v4(); 295 - let res = client 296 - .get(format!( 297 - "{}/xrpc/_backup.getBackup?id={}", 298 - base_url().await, 299 - fake_id 300 - )) 301 - .bearer_auth(&token) 302 - .send() 303 - .await 304 - .expect("getBackup request failed"); 305 - 306 - assert_eq!(res.status(), StatusCode::NOT_FOUND); 307 - } 308 - 309 - #[tokio::test] 310 - async fn test_backup_invalid_id() { 311 - let client = client(); 312 - let (token, _did) = create_account_and_login(&client).await; 313 - 314 - let res = client 315 - .get(format!( 316 - "{}/xrpc/_backup.getBackup?id=not-a-uuid", 317 - base_url().await 318 - )) 319 - .bearer_auth(&token) 320 - .send() 321 - .await 322 - .expect("getBackup request failed"); 323 - 324 - assert_eq!(res.status(), StatusCode::BAD_REQUEST); 325 - }
+24 -6
tests/change_password.rs
··· 32 32 let did = create_body["did"].as_str().unwrap(); 33 33 let jwt = verify_new_account(&client, did).await; 34 34 let change_res = client 35 - .post(format!("{}/xrpc/_account.changePassword", base_url().await)) 35 + .post(format!( 36 + "{}/xrpc/com.tranquil.account.changePassword", 37 + base_url().await 38 + )) 36 39 .bearer_auth(&jwt) 37 40 .json(&json!({ 38 41 "currentPassword": old_password, ··· 83 86 let client = client(); 84 87 let (_, jwt) = setup_new_user("change-pw-wrong").await; 85 88 let res = client 86 - .post(format!("{}/xrpc/_account.changePassword", base_url().await)) 89 + .post(format!( 90 + "{}/xrpc/com.tranquil.account.changePassword", 91 + base_url().await 92 + )) 87 93 .bearer_auth(&jwt) 88 94 .json(&json!({ 89 95 "currentPassword": "Wrongpass999!", ··· 123 129 let did = create_body["did"].as_str().unwrap(); 124 130 let jwt = verify_new_account(&client, did).await; 125 131 let res = client 126 - .post(format!("{}/xrpc/_account.changePassword", base_url().await)) 132 + .post(format!( 133 + "{}/xrpc/com.tranquil.account.changePassword", 134 + base_url().await 135 + )) 127 136 .bearer_auth(&jwt) 128 137 .json(&json!({ 129 138 "currentPassword": password, ··· 142 151 let client = client(); 143 152 let (_, jwt) = setup_new_user("change-pw-empty").await; 144 153 let res = client 145 - .post(format!("{}/xrpc/_account.changePassword", base_url().await)) 154 + .post(format!( 155 + "{}/xrpc/com.tranquil.account.changePassword", 156 + base_url().await 157 + )) 146 158 .bearer_auth(&jwt) 147 159 .json(&json!({ 148 160 "currentPassword": "", ··· 159 171 let client = client(); 160 172 let (_, jwt) = setup_new_user("change-pw-emptynew").await; 161 173 let res = client 162 - .post(format!("{}/xrpc/_account.changePassword", base_url().await)) 174 + .post(format!( 175 + "{}/xrpc/com.tranquil.account.changePassword", 176 + base_url().await 177 + )) 163 178 .bearer_auth(&jwt) 164 179 .json(&json!({ 165 180 "currentPassword": "E2epass123!", ··· 175 190 async fn test_change_password_requires_auth() { 176 191 let client = client(); 177 192 let res = client 178 - .post(format!("{}/xrpc/_account.changePassword", base_url().await)) 193 + .post(format!( 194 + "{}/xrpc/com.tranquil.account.changePassword", 195 + base_url().await 196 + )) 179 197 .json(&json!({ 180 198 "currentPassword": "Oldpass123!", 181 199 "newPassword": "Newpass123!"
+247 -28
tests/did_web.rs
··· 547 547 } 548 548 549 549 #[tokio::test] 550 - async fn test_did_web_can_edit_did_document() { 550 + async fn test_deactivate_with_migrating_to() { 551 551 let client = client(); 552 552 let base = base_url().await; 553 - let handle = format!("doc{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 553 + let handle = format!("mig{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 554 554 let payload = json!({ 555 555 "handle": handle, 556 556 "email": format!("{}@example.com", handle), ··· 567 567 let body: Value = res.json().await.expect("Response was not JSON"); 568 568 let did = body["did"].as_str().expect("No DID").to_string(); 569 569 let jwt = verify_new_account(&client, &did).await; 570 + let target_pds = "https://pds2.example.com"; 570 571 let res = client 571 - .get(format!("{}/xrpc/_account.getDidDocument", base)) 572 + .post(format!( 573 + "{}/xrpc/com.atproto.server.deactivateAccount", 574 + base 575 + )) 572 576 .bearer_auth(&jwt) 577 + .json(&json!({ "migratingTo": target_pds })) 578 + .send() 579 + .await 580 + .expect("Failed to send request"); 581 + assert_eq!(res.status(), StatusCode::OK); 582 + let pool = get_test_db_pool().await; 583 + let row = sqlx::query!( 584 + r#"SELECT migrated_to_pds, deactivated_at FROM users WHERE did = $1"#, 585 + &did 586 + ) 587 + .fetch_one(pool) 588 + .await 589 + .expect("Failed to query user"); 590 + assert_eq!( 591 + row.migrated_to_pds.as_deref(), 592 + Some(target_pds), 593 + "migrated_to_pds should be set to target PDS" 594 + ); 595 + assert!( 596 + row.deactivated_at.is_some(), 597 + "deactivated_at should be set for migrated account" 598 + ); 599 + } 600 + 601 + #[tokio::test] 602 + async fn test_migrated_account_blocked_from_repo_ops() { 603 + let client = client(); 604 + let base = base_url().await; 605 + let handle = format!("blk{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 606 + let payload = json!({ 607 + "handle": handle, 608 + "email": format!("{}@example.com", handle), 609 + "password": "Testpass123!", 610 + "didType": "web" 611 + }); 612 + let res = client 613 + .post(format!("{}/xrpc/com.atproto.server.createAccount", base)) 614 + .json(&payload) 573 615 .send() 574 616 .await 575 617 .expect("Failed to send request"); 576 618 assert_eq!(res.status(), StatusCode::OK); 577 619 let body: Value = res.json().await.expect("Response was not JSON"); 620 + let did = body["did"].as_str().expect("No DID").to_string(); 621 + let jwt = verify_new_account(&client, &did).await; 622 + let res = client 623 + .post(format!("{}/xrpc/com.atproto.repo.createRecord", base)) 624 + .bearer_auth(&jwt) 625 + .json(&json!({ 626 + "repo": did, 627 + "collection": "app.bsky.feed.post", 628 + "record": { 629 + "$type": "app.bsky.feed.post", 630 + "text": "Pre-migration post", 631 + "createdAt": chrono::Utc::now().to_rfc3339() 632 + } 633 + })) 634 + .send() 635 + .await 636 + .expect("Failed to send request"); 637 + assert_eq!(res.status(), StatusCode::OK); 638 + let res = client 639 + .post(format!( 640 + "{}/xrpc/com.atproto.server.deactivateAccount", 641 + base 642 + )) 643 + .bearer_auth(&jwt) 644 + .json(&json!({ "migratingTo": "https://pds2.example.com" })) 645 + .send() 646 + .await 647 + .expect("Failed to send request"); 648 + assert_eq!(res.status(), StatusCode::OK); 649 + let res = client 650 + .post(format!("{}/xrpc/com.atproto.repo.createRecord", base)) 651 + .bearer_auth(&jwt) 652 + .json(&json!({ 653 + "repo": did, 654 + "collection": "app.bsky.feed.post", 655 + "record": { 656 + "$type": "app.bsky.feed.post", 657 + "text": "Post-migration post - should fail", 658 + "createdAt": chrono::Utc::now().to_rfc3339() 659 + } 660 + })) 661 + .send() 662 + .await 663 + .expect("Failed to send request"); 578 664 assert!( 579 - body["didDocument"].is_object(), 580 - "Should return DID document" 665 + res.status().is_client_error(), 666 + "createRecord should fail for migrated account: {}", 667 + res.status() 668 + ); 669 + let res = client 670 + .post(format!("{}/xrpc/com.atproto.repo.putRecord", base)) 671 + .bearer_auth(&jwt) 672 + .json(&json!({ 673 + "repo": did, 674 + "collection": "app.bsky.actor.profile", 675 + "rkey": "self", 676 + "record": { 677 + "$type": "app.bsky.actor.profile", 678 + "displayName": "Test" 679 + } 680 + })) 681 + .send() 682 + .await 683 + .expect("Failed to send request"); 684 + assert!( 685 + res.status().is_client_error(), 686 + "putRecord should fail for migrated account: {}", 687 + res.status() 581 688 ); 582 - assert_eq!( 583 - body["didDocument"]["id"], did, 584 - "DID document should have correct id" 689 + let res = client 690 + .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", base)) 691 + .bearer_auth(&jwt) 692 + .json(&json!({ 693 + "repo": did, 694 + "collection": "app.bsky.feed.post", 695 + "rkey": "test123" 696 + })) 697 + .send() 698 + .await 699 + .expect("Failed to send request"); 700 + assert!( 701 + res.status().is_client_error(), 702 + "deleteRecord should fail for migrated account: {}", 703 + res.status() 585 704 ); 586 705 let res = client 587 - .post(format!("{}/xrpc/_account.updateDidDocument", base)) 706 + .post(format!("{}/xrpc/com.atproto.repo.applyWrites", base)) 588 707 .bearer_auth(&jwt) 589 708 .json(&json!({ 590 - "alsoKnownAs": ["at://custom.handle.test"] 709 + "repo": did, 710 + "writes": [{ 711 + "$type": "com.atproto.repo.applyWrites#create", 712 + "collection": "app.bsky.feed.post", 713 + "value": { 714 + "$type": "app.bsky.feed.post", 715 + "text": "Batch post", 716 + "createdAt": chrono::Utc::now().to_rfc3339() 717 + } 718 + }] 591 719 })) 592 720 .send() 593 721 .await 594 722 .expect("Failed to send request"); 595 - assert_eq!( 596 - res.status(), 597 - StatusCode::OK, 598 - "Non-migrated did:web user should be able to update DID document" 723 + assert!( 724 + res.status().is_client_error(), 725 + "applyWrites should fail for migrated account: {}", 726 + res.status() 727 + ); 728 + let res = client 729 + .post(format!("{}/xrpc/com.atproto.repo.uploadBlob", base)) 730 + .bearer_auth(&jwt) 731 + .header("Content-Type", "text/plain") 732 + .body("test blob content") 733 + .send() 734 + .await 735 + .expect("Failed to send request"); 736 + assert!( 737 + res.status().is_client_error(), 738 + "uploadBlob should fail for migrated account: {}", 739 + res.status() 599 740 ); 741 + } 742 + 743 + #[tokio::test] 744 + async fn test_migrated_session_status() { 745 + let client = client(); 746 + let base = base_url().await; 747 + let handle = format!("ses{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 748 + let payload = json!({ 749 + "handle": handle, 750 + "email": format!("{}@example.com", handle), 751 + "password": "Testpass123!", 752 + "didType": "web" 753 + }); 754 + let res = client 755 + .post(format!("{}/xrpc/com.atproto.server.createAccount", base)) 756 + .json(&payload) 757 + .send() 758 + .await 759 + .expect("Failed to send request"); 760 + assert_eq!(res.status(), StatusCode::OK); 600 761 let body: Value = res.json().await.expect("Response was not JSON"); 601 - assert!(body["success"].as_bool().unwrap_or(false)); 602 - let also_known_as = body["didDocument"]["alsoKnownAs"] 603 - .as_array() 604 - .expect("alsoKnownAs should be array"); 762 + let did = body["did"].as_str().expect("No DID").to_string(); 763 + let jwt = verify_new_account(&client, &did).await; 764 + let res = client 765 + .get(format!("{}/xrpc/com.atproto.server.getSession", base)) 766 + .bearer_auth(&jwt) 767 + .send() 768 + .await 769 + .expect("Failed to send request"); 770 + assert_eq!(res.status(), StatusCode::OK); 771 + let body: Value = res.json().await.expect("Response was not JSON"); 772 + assert_eq!(body["active"], true); 605 773 assert!( 606 - also_known_as 607 - .iter() 608 - .any(|v| v.as_str() == Some("at://custom.handle.test")), 609 - "alsoKnownAs should contain custom entry" 774 + body["status"].is_null() || body["status"] == "active", 775 + "Status should be null or 'active' for normal accounts" 776 + ); 777 + let target_pds = "https://pds3.example.com"; 778 + let res = client 779 + .post(format!( 780 + "{}/xrpc/com.atproto.server.deactivateAccount", 781 + base 782 + )) 783 + .bearer_auth(&jwt) 784 + .json(&json!({ "migratingTo": target_pds })) 785 + .send() 786 + .await 787 + .expect("Failed to send request"); 788 + assert_eq!(res.status(), StatusCode::OK); 789 + let res = client 790 + .get(format!("{}/xrpc/com.atproto.server.getSession", base)) 791 + .bearer_auth(&jwt) 792 + .send() 793 + .await 794 + .expect("Failed to send request"); 795 + assert_eq!(res.status(), StatusCode::OK); 796 + let body: Value = res.json().await.expect("Response was not JSON"); 797 + assert_eq!( 798 + body["active"], false, 799 + "Migrated account should not be active" 800 + ); 801 + assert_eq!( 802 + body["status"], "migrated", 803 + "Status should be 'migrated' after migration" 804 + ); 805 + assert_eq!( 806 + body["migratedToPds"], target_pds, 807 + "migratedToPds should be set to target PDS" 610 808 ); 611 809 } 612 810 613 811 #[tokio::test] 614 - async fn test_deactivate_account_basic() { 812 + async fn test_migrating_to_ignored_for_did_plc() { 615 813 let client = client(); 616 814 let base = base_url().await; 617 - let handle = format!("dea{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 815 + let handle = format!("plc{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 618 816 let payload = json!({ 619 817 "handle": handle, 620 818 "email": format!("{}@example.com", handle), 621 819 "password": "Testpass123!", 622 - "didType": "web" 820 + "didType": "plc" 623 821 }); 624 822 let res = client 625 823 .post(format!("{}/xrpc/com.atproto.server.createAccount", base)) ··· 630 828 assert_eq!(res.status(), StatusCode::OK); 631 829 let body: Value = res.json().await.expect("Response was not JSON"); 632 830 let did = body["did"].as_str().expect("No DID").to_string(); 831 + assert!(did.starts_with("did:plc:"), "Should be did:plc account"); 633 832 let jwt = verify_new_account(&client, &did).await; 634 833 let res = client 635 834 .post(format!( ··· 637 836 base 638 837 )) 639 838 .bearer_auth(&jwt) 640 - .json(&json!({})) 839 + .json(&json!({ "migratingTo": "https://pds2.example.com" })) 641 840 .send() 642 841 .await 643 842 .expect("Failed to send request"); 644 843 assert_eq!(res.status(), StatusCode::OK); 844 + let pool = get_test_db_pool().await; 845 + let row = sqlx::query!( 846 + r#"SELECT migrated_to_pds, deactivated_at FROM users WHERE did = $1"#, 847 + &did 848 + ) 849 + .fetch_one(pool) 850 + .await 851 + .expect("Failed to query user"); 852 + assert!( 853 + row.migrated_to_pds.is_none(), 854 + "migrated_to_pds should NOT be set for did:plc accounts" 855 + ); 856 + assert!( 857 + row.deactivated_at.is_some(), 858 + "deactivated_at should still be set" 859 + ); 645 860 let res = client 646 861 .get(format!("{}/xrpc/com.atproto.server.getSession", base)) 647 862 .bearer_auth(&jwt) ··· 650 865 .expect("Failed to send request"); 651 866 assert_eq!(res.status(), StatusCode::OK); 652 867 let body: Value = res.json().await.expect("Response was not JSON"); 653 - assert_eq!(body["active"], false, "Account should be deactivated"); 868 + assert_eq!(body["active"], false); 654 869 assert_eq!( 655 870 body["status"], "deactivated", 656 - "Status should be 'deactivated'" 871 + "Status should be 'deactivated' not 'migrated' for did:plc" 872 + ); 873 + assert!( 874 + body["migratedToPds"].is_null(), 875 + "migratedToPds should not be set for did:plc accounts" 657 876 ); 658 877 }
+1
tests/oauth.rs
··· 1 1 mod common; 2 2 mod helpers; 3 3 use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; 4 + use chrono::Utc; 4 5 use common::{base_url, client, get_test_db_pool}; 5 6 use helpers::verify_new_account; 6 7 use reqwest::{StatusCode, redirect};
+4 -1
tests/oauth_security.rs
··· 1116 1116 1117 1117 let delegated_handle = format!("dg{}", suffix); 1118 1118 let delegated_res = http_client 1119 - .post(format!("{}/xrpc/_delegation.createDelegatedAccount", url)) 1119 + .post(format!( 1120 + "{}/xrpc/com.tranquil.delegation.createDelegatedAccount", 1121 + url 1122 + )) 1120 1123 .bearer_auth(controller_jwt) 1121 1124 .json(&json!({ 1122 1125 "handle": delegated_handle,
+36 -9
tests/session_management.rs
··· 10 10 let client = client(); 11 11 let (did, jwt) = setup_new_user("list-sessions").await; 12 12 let res = client 13 - .get(format!("{}/xrpc/_account.listSessions", base_url().await)) 13 + .get(format!( 14 + "{}/xrpc/com.tranquil.account.listSessions", 15 + base_url().await 16 + )) 14 17 .bearer_auth(&jwt) 15 18 .send() 16 19 .await ··· 80 83 let login_body: Value = login_res.json().await.unwrap(); 81 84 let jwt2 = login_body["accessJwt"].as_str().unwrap(); 82 85 let list_res = client 83 - .get(format!("{}/xrpc/_account.listSessions", base_url().await)) 86 + .get(format!( 87 + "{}/xrpc/com.tranquil.account.listSessions", 88 + base_url().await 89 + )) 84 90 .bearer_auth(jwt2) 85 91 .send() 86 92 .await ··· 100 106 async fn test_list_sessions_requires_auth() { 101 107 let client = client(); 102 108 let res = client 103 - .get(format!("{}/xrpc/_account.listSessions", base_url().await)) 109 + .get(format!( 110 + "{}/xrpc/com.tranquil.account.listSessions", 111 + base_url().await 112 + )) 104 113 .send() 105 114 .await 106 115 .expect("Failed to send request"); ··· 149 158 let login_body: Value = login_res.json().await.unwrap(); 150 159 let jwt2 = login_body["accessJwt"].as_str().unwrap(); 151 160 let list_res = client 152 - .get(format!("{}/xrpc/_account.listSessions", base_url().await)) 161 + .get(format!( 162 + "{}/xrpc/com.tranquil.account.listSessions", 163 + base_url().await 164 + )) 153 165 .bearer_auth(jwt2) 154 166 .send() 155 167 .await ··· 165 177 ); 166 178 let session_id = other_session.unwrap()["id"].as_str().unwrap(); 167 179 let revoke_res = client 168 - .post(format!("{}/xrpc/_account.revokeSession", base_url().await)) 180 + .post(format!( 181 + "{}/xrpc/com.tranquil.account.revokeSession", 182 + base_url().await 183 + )) 169 184 .bearer_auth(jwt2) 170 185 .json(&json!({"sessionId": session_id})) 171 186 .send() ··· 173 188 .expect("Failed to revoke session"); 174 189 assert_eq!(revoke_res.status(), StatusCode::OK); 175 190 let list_after_res = client 176 - .get(format!("{}/xrpc/_account.listSessions", base_url().await)) 191 + .get(format!( 192 + "{}/xrpc/com.tranquil.account.listSessions", 193 + base_url().await 194 + )) 177 195 .bearer_auth(jwt2) 178 196 .send() 179 197 .await ··· 195 213 let client = client(); 196 214 let (_, jwt) = setup_new_user("revoke-invalid").await; 197 215 let res = client 198 - .post(format!("{}/xrpc/_account.revokeSession", base_url().await)) 216 + .post(format!( 217 + "{}/xrpc/com.tranquil.account.revokeSession", 218 + base_url().await 219 + )) 199 220 .bearer_auth(&jwt) 200 221 .json(&json!({"sessionId": "not-a-number"})) 201 222 .send() ··· 209 230 let client = client(); 210 231 let (_, jwt) = setup_new_user("revoke-notfound").await; 211 232 let res = client 212 - .post(format!("{}/xrpc/_account.revokeSession", base_url().await)) 233 + .post(format!( 234 + "{}/xrpc/com.tranquil.account.revokeSession", 235 + base_url().await 236 + )) 213 237 .bearer_auth(&jwt) 214 238 .json(&json!({"sessionId": "jwt:999999999"})) 215 239 .send() ··· 222 246 async fn test_revoke_session_requires_auth() { 223 247 let client = client(); 224 248 let res = client 225 - .post(format!("{}/xrpc/_account.revokeSession", base_url().await)) 249 + .post(format!( 250 + "{}/xrpc/com.tranquil.account.revokeSession", 251 + base_url().await 252 + )) 226 253 .json(&json!({"sessionId": "1"})) 227 254 .send() 228 255 .await

History

4 rounds 12 comments
sign up or login to add to the discussion
5 commits
expand
40568e20
try to fix
149ed8e3
feat: Implement Pushed Authorization Requests (PAR) for OAuth migration and proxy missing repo records to AppView.
ced673cf
feat: Proxy record reads to AppView for local misses and improve frontend type safety.
19e88430
feat: Proxy getRecord requests to the user's PDS after DID resolution instead of a central AppView endpoint.
1694091e
Backups, adversarial migrations
expand 0 comments
closed without merging
6 commits
expand
4d1b4c33
fix hardcoded host and port
44cd7259
try to fix
5dc814ee
feat: Implement Pushed Authorization Requests (PAR) for OAuth migration and proxy missing repo records to AppView.
fc8641aa
feat: Proxy record reads to AppView for local misses and improve frontend type safety.
2f966608
feat: Proxy getRecord requests to the user's PDS after DID resolution instead of a central AppView endpoint.
085621dc
Backups, adversarial migrations
expand 1 comment
5 commits
expand
4d1b4c33
fix hardcoded host and port
44cd7259
try to fix
5dc814ee
feat: Implement Pushed Authorization Requests (PAR) for OAuth migration and proxy missing repo records to AppView.
fc8641aa
feat: Proxy record reads to AppView for local misses and improve frontend type safety.
2f966608
feat: Proxy getRecord requests to the user's PDS after DID resolution instead of a central AppView endpoint.
expand 5 comments

there, i fixed it

Would you mind rebasing or something? :p i can't see nothin in there (I know it's my fault for pushing massive changes while your PR is open but whatchagonnadoabouttit

i cant resubmit

it wont let me..

yeah it sucks

4 commits
expand
4d1b4c33
fix hardcoded host and port
44cd7259
try to fix
5dc814ee
feat: Implement Pushed Authorization Requests (PAR) for OAuth migration and proxy missing repo records to AppView.
fc8641aa
feat: Proxy record reads to AppView for local misses and improve frontend type safety.
expand 6 comments

tangled is so funny why did it include the commit already merged here

i dont know how to fix that merge conflict tangled is the best git platform oat

what is this mystical BSKY_APPVIEW_ENDPOINT about :p what if im running tranquil and I wanna support every bsky-esque appview equally?? zeppelin may return in spirit

it's so i can add a backdoor to the ccp

i would reset to the commit before the port/host hardoded thing fix i did and then merge this one as it includes it

like lewis mentioned i dont quite get why the dedicated proxying to a configured bsky appview is needed here? it makes tranquil less generic, less spec compliant and i dont see what functionality it offers over the spec compliant proxying thats already there? (and that modern social-app has luckily been updated to use as it should)