Our Personal Data Server from scratch!

migration improvements

+247 -213
-61
.sqlx/query-29520eea3a5f2fe13fabc503808ca19247adeb9095dd6766e148f9d8eaa6d589.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT did, handle, email, created_at, email_verified, deactivated_at, invites_disabled\n FROM users\n WHERE did > $1\n AND ($2::text IS NULL OR email ILIKE $2)\n AND ($3::text IS NULL OR handle ILIKE $3)\n ORDER BY did ASC\n LIMIT $4", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "did", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "handle", 14 - "type_info": "Text" 15 - }, 16 - { 17 - "ordinal": 2, 18 - "name": "email", 19 - "type_info": "Text" 20 - }, 21 - { 22 - "ordinal": 3, 23 - "name": "created_at", 24 - "type_info": "Timestamptz" 25 - }, 26 - { 27 - "ordinal": 4, 28 - "name": "email_verified", 29 - "type_info": "Bool" 30 - }, 31 - { 32 - "ordinal": 5, 33 - "name": "deactivated_at", 34 - "type_info": "Timestamptz" 35 - }, 36 - { 37 - "ordinal": 6, 38 - "name": "invites_disabled", 39 - "type_info": "Bool" 40 - } 41 - ], 42 - "parameters": { 43 - "Left": [ 44 - "Text", 45 - "Text", 46 - "Text", 47 - "Int8" 48 - ] 49 - }, 50 - "nullable": [ 51 - false, 52 - false, 53 - true, 54 - false, 55 - false, 56 - true, 57 - true 58 - ] 59 - }, 60 - "hash": "29520eea3a5f2fe13fabc503808ca19247adeb9095dd6766e148f9d8eaa6d589" 61 - }
-22
.sqlx/query-40d42ed61a77074b298539e492d8fb6493174a7c49324e6f4f20b68bc30e95f4.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT account_type::text = 'delegated' as \"is_delegated!\" FROM users WHERE did = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "is_delegated!", 9 - "type_info": "Bool" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Text" 15 - ] 16 - }, 17 - "nullable": [ 18 - null 19 - ] 20 - }, 21 - "hash": "40d42ed61a77074b298539e492d8fb6493174a7c49324e6f4f20b68bc30e95f4" 22 - }
+1
crates/tranquil-db-traits/src/user.rs
··· 905 905 pub struct MigrationReactivationInput { 906 906 pub did: Did, 907 907 pub new_handle: Handle, 908 + pub new_email: Option<String>, 908 909 } 909 910 910 911 #[derive(Debug, Clone)]
+12 -2
crates/tranquil-db/src/postgres/user.rs
··· 2699 2699 return Err(tranquil_db_traits::MigrationReactivationError::NotDeactivated); 2700 2700 } 2701 2701 2702 - let update_result: Result<_, sqlx::Error> = 2702 + let update_result: Result<_, sqlx::Error> = if let Some(ref new_email) = input.new_email { 2703 + sqlx::query( 2704 + "UPDATE users SET handle = $1, email = $2, email_verified = false WHERE id = $3", 2705 + ) 2706 + .bind(input.new_handle.as_str()) 2707 + .bind(new_email) 2708 + .bind(account_id) 2709 + .execute(&mut *tx) 2710 + .await 2711 + } else { 2703 2712 sqlx::query("UPDATE users SET handle = $1 WHERE id = $2") 2704 2713 .bind(input.new_handle.as_str()) 2705 2714 .bind(account_id) 2706 2715 .execute(&mut *tx) 2707 - .await; 2716 + .await 2717 + }; 2708 2718 2709 2719 if let Err(e) = update_result { 2710 2720 if let Some(db_err) = e.as_database_error()
+25 -1
crates/tranquil-pds/src/api/identity/account.rs
··· 410 410 let reactivate_input = tranquil_db_traits::MigrationReactivationInput { 411 411 did: Did::new_unchecked(&did), 412 412 new_handle: Handle::new_unchecked(&handle), 413 + new_email: email.clone(), 413 414 }; 414 415 match state 415 416 .user_repo ··· 477 478 error!("Error creating session: {:?}", e); 478 479 return ApiError::InternalError(None).into_response(); 479 480 } 481 + let hostname = 482 + std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 483 + let verification_required = if let Some(ref user_email) = email { 484 + let token = 485 + crate::auth::verification_token::generate_migration_token(&did, user_email); 486 + let formatted_token = 487 + crate::auth::verification_token::format_token_for_display(&token); 488 + if let Err(e) = crate::comms::comms_repo::enqueue_migration_verification( 489 + state.user_repo.as_ref(), 490 + state.infra_repo.as_ref(), 491 + reactivated.user_id, 492 + user_email, 493 + &formatted_token, 494 + &hostname, 495 + ) 496 + .await 497 + { 498 + warn!("Failed to enqueue migration verification email: {:?}", e); 499 + } 500 + true 501 + } else { 502 + false 503 + }; 480 504 return ( 481 505 axum::http::StatusCode::OK, 482 506 Json(CreateAccountOutput { ··· 485 509 did_doc: state.did_resolver.resolve_did_document(&did).await, 486 510 access_jwt: access_meta.token, 487 511 refresh_jwt: refresh_meta.token, 488 - verification_required: false, 512 + verification_required, 489 513 verification_channel: "email".to_string(), 490 514 }), 491 515 )
+2
crates/tranquil-pds/src/api/identity/plc/submit.rs
··· 151 151 } 152 152 } 153 153 let _ = state.cache.delete(&format!("handle:{}", user.handle)).await; 154 + let _ = state.cache.delete(&format!("plc:doc:{}", did)).await; 155 + let _ = state.cache.delete(&format!("plc:data:{}", did)).await; 154 156 if state.did_resolver.refresh_did(did).await.is_none() { 155 157 warn!(did = %did, "Failed to refresh DID cache after PLC update"); 156 158 }
+5
crates/tranquil-pds/src/api/server/account_status.rs
··· 425 425 if let Some(ref h) = handle { 426 426 let _ = state.cache.delete(&format!("handle:{}", h)).await; 427 427 } 428 + let _ = state.cache.delete(&format!("plc:doc:{}", did)).await; 429 + let _ = state.cache.delete(&format!("plc:data:{}", did)).await; 430 + if state.did_resolver.refresh_did(did.as_str()).await.is_none() { 431 + warn!("[MIGRATION] activateAccount: Failed to refresh DID cache for {}", did); 432 + } 428 433 info!( 429 434 "[MIGRATION] activateAccount: Sequencing account event (active=true) for did={}", 430 435 did
-3
crates/tranquil-pds/src/test_new_file.rs
··· 1 - pub fn test_function() -> &'static str { 2 - "NEW_FILE_TEST_67890" 3 - }
+145 -114
frontend/src/lib/migration/blob-migration.ts
··· 8 8 sourceUnreachable: boolean; 9 9 } 10 10 11 + const MAX_RETRIES = 3; 12 + const RETRY_DELAYS = [1000, 2000, 4000]; 13 + 14 + const sleep = (ms: number): Promise<void> => 15 + new Promise((resolve) => setTimeout(resolve, ms)); 16 + 17 + const safeProgress = ( 18 + onProgress: (update: Partial<MigrationProgress>) => void, 19 + update: Partial<MigrationProgress>, 20 + ): void => { 21 + try { 22 + onProgress(update); 23 + } catch (e) { 24 + console.warn("[blob-migration] Progress callback failed:", e); 25 + } 26 + }; 27 + 28 + interface MigrateBlobResult { 29 + cid: string; 30 + success: boolean; 31 + error?: string; 32 + } 33 + 34 + const migrateSingleBlob = async ( 35 + cid: string, 36 + userDid: string, 37 + sourceClient: AtprotoClient, 38 + localClient: AtprotoClient, 39 + attempt = 0, 40 + ): Promise<MigrateBlobResult> => { 41 + try { 42 + console.log( 43 + `[blob-migration] Fetching blob ${cid} from source (attempt ${attempt + 1})`, 44 + ); 45 + const { data: blobData, contentType } = await sourceClient 46 + .getBlobWithContentType(userDid, cid); 47 + console.log( 48 + `[blob-migration] Got blob ${cid}, size: ${blobData.byteLength}, type: ${contentType}`, 49 + ); 50 + 51 + console.log(`[blob-migration] Uploading blob ${cid} to local PDS...`); 52 + const uploadResult = await localClient.uploadBlob(blobData, contentType); 53 + console.log( 54 + `[blob-migration] Upload response for ${cid}:`, 55 + JSON.stringify(uploadResult), 56 + ); 57 + 58 + return { cid, success: true }; 59 + } catch (e) { 60 + const errorMessage = (e as Error).message || String(e); 61 + console.error( 62 + `[blob-migration] Failed to migrate blob ${cid} (attempt ${attempt + 1}):`, 63 + errorMessage, 64 + ); 65 + 66 + const isRetryable = attempt < MAX_RETRIES - 1 && 67 + !errorMessage.includes("404") && 68 + !errorMessage.includes("not found") && 69 + !errorMessage.includes("BlobNotFound"); 70 + 71 + if (isRetryable) { 72 + const delay = RETRY_DELAYS[attempt] ?? 4000; 73 + console.log(`[blob-migration] Retrying ${cid} in ${delay}ms...`); 74 + await sleep(delay); 75 + return migrateSingleBlob( 76 + cid, 77 + userDid, 78 + sourceClient, 79 + localClient, 80 + attempt + 1, 81 + ); 82 + } 83 + 84 + return { cid, success: false, error: errorMessage }; 85 + } 86 + }; 87 + 88 + const collectMissingBlobs = async ( 89 + localClient: AtprotoClient, 90 + ): Promise<string[]> => { 91 + const allBlobs: string[] = []; 92 + let cursor: string | undefined; 93 + 94 + do { 95 + const { blobs, cursor: nextCursor } = await localClient.listMissingBlobs( 96 + cursor, 97 + 500, 98 + ); 99 + console.log( 100 + `[blob-migration] listMissingBlobs returned ${blobs.length} blobs, cursor: ${nextCursor}`, 101 + ); 102 + allBlobs.push(...blobs.map((blob) => blob.cid)); 103 + cursor = nextCursor; 104 + } while (cursor); 105 + 106 + return allBlobs; 107 + }; 108 + 11 109 export async function migrateBlobs( 12 110 localClient: AtprotoClient, 13 111 sourceClient: AtprotoClient | null, 14 112 userDid: string, 15 113 onProgress: (update: Partial<MigrationProgress>) => void, 16 114 ): Promise<BlobMigrationResult> { 17 - const missingBlobs: string[] = []; 18 - let cursor: string | undefined; 19 - 20 115 console.log("[blob-migration] Starting blob migration for", userDid); 21 116 console.log( 22 117 "[blob-migration] Source client:", 23 118 sourceClient ? `available (baseUrl: ${sourceClient.getBaseUrl()})` : "NOT AVAILABLE", 24 119 ); 25 - console.log( 26 - "[blob-migration] Local client baseUrl:", 27 - localClient.getBaseUrl(), 28 - ); 120 + console.log("[blob-migration] Local client baseUrl:", localClient.getBaseUrl()); 29 121 console.log( 30 122 "[blob-migration] Local client has access token:", 31 123 localClient.getAccessToken() ? "yes" : "NO", 32 124 ); 33 125 34 - onProgress({ currentOperation: "Checking for missing blobs..." }); 126 + safeProgress(onProgress, { currentOperation: "Checking for missing blobs..." }); 35 127 36 - do { 37 - const { blobs, cursor: nextCursor } = await localClient.listMissingBlobs( 38 - cursor, 39 - 100, 40 - ); 41 - console.log( 42 - "[blob-migration] listMissingBlobs returned", 43 - blobs.length, 44 - "blobs, cursor:", 45 - nextCursor, 46 - ); 47 - missingBlobs.push(...blobs.map((blob) => blob.cid)); 48 - cursor = nextCursor; 49 - } while (cursor); 128 + const missingBlobs = await collectMissingBlobs(localClient); 50 129 51 130 console.log("[blob-migration] Total missing blobs:", missingBlobs.length); 52 - onProgress({ blobsTotal: missingBlobs.length }); 131 + safeProgress(onProgress, { blobsTotal: missingBlobs.length }); 53 132 54 133 if (missingBlobs.length === 0) { 55 134 console.log("[blob-migration] No blobs to migrate"); 56 - onProgress({ currentOperation: "No blobs to migrate" }); 135 + safeProgress(onProgress, { currentOperation: "No blobs to migrate" }); 57 136 return { migrated: 0, failed: [], total: 0, sourceUnreachable: false }; 58 137 } 59 138 60 139 if (!sourceClient) { 61 - console.warn( 62 - "[blob-migration] No source client available, cannot fetch blobs", 63 - ); 64 - onProgress({ 140 + console.warn("[blob-migration] No source client available, cannot fetch blobs"); 141 + safeProgress(onProgress, { 65 142 currentOperation: 66 143 `${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.`, 67 144 }); ··· 73 150 }; 74 151 } 75 152 76 - onProgress({ currentOperation: `Migrating ${missingBlobs.length} blobs...` }); 153 + safeProgress(onProgress, { 154 + currentOperation: `Migrating ${missingBlobs.length} blobs...`, 155 + }); 77 156 78 - let migrated = 0; 79 - const failed: string[] = []; 80 - let sourceUnreachable = false; 157 + const results = await missingBlobs.reduce< 158 + Promise<{ migrated: number; failed: string[] }> 159 + >( 160 + async (accPromise, cid, index) => { 161 + const acc = await accPromise; 81 162 82 - for (const cid of missingBlobs) { 83 - if (sourceUnreachable) { 84 - failed.push(cid); 85 - continue; 86 - } 87 - 88 - try { 89 - onProgress({ 90 - currentOperation: `Migrating blob ${ 91 - migrated + 1 92 - }/${missingBlobs.length}...`, 163 + safeProgress(onProgress, { 164 + currentOperation: `Migrating blob ${index + 1}/${missingBlobs.length}...`, 165 + blobsMigrated: acc.migrated, 93 166 }); 94 167 95 - console.log("[blob-migration] Fetching blob", cid, "from source"); 96 - const { data: blobData, contentType } = await sourceClient 97 - .getBlobWithContentType(userDid, cid); 98 - console.log( 99 - "[blob-migration] Got blob", 168 + const result = await migrateSingleBlob( 100 169 cid, 101 - "size:", 102 - blobData.byteLength, 103 - "contentType:", 104 - contentType, 105 - ); 106 - console.log("[blob-migration] Uploading blob", cid, "to local PDS..."); 107 - const uploadResult = await localClient.uploadBlob(blobData, contentType); 108 - console.log( 109 - "[blob-migration] Upload response for", 110 - cid, 111 - ":", 112 - JSON.stringify(uploadResult), 170 + userDid, 171 + sourceClient, 172 + localClient, 113 173 ); 114 - migrated++; 115 - onProgress({ blobsMigrated: migrated }); 116 - } catch (e) { 117 - const errorMessage = (e as Error).message || String(e); 118 - console.error( 119 - "[blob-migration] Failed to migrate blob", 120 - cid, 121 - ":", 122 - errorMessage, 123 - ); 174 + 175 + return result.success 176 + ? { migrated: acc.migrated + 1, failed: acc.failed } 177 + : { migrated: acc.migrated, failed: [...acc.failed, cid] }; 178 + }, 179 + Promise.resolve({ migrated: 0, failed: [] as string[] }), 180 + ); 181 + 182 + const { migrated, failed } = results; 183 + 184 + safeProgress(onProgress, { blobsMigrated: migrated }); 124 185 125 - const isNetworkError = errorMessage.includes("fetch") || 126 - errorMessage.includes("network") || 127 - errorMessage.includes("CORS") || 128 - errorMessage.includes("Failed to fetch") || 129 - errorMessage.includes("NetworkError") || 130 - errorMessage.includes("blocked by CORS"); 186 + const statusMessage = migrated === missingBlobs.length 187 + ? `All ${migrated} blobs migrated successfully` 188 + : migrated > 0 189 + ? `${migrated}/${missingBlobs.length} blobs migrated. ${failed.length} failed.` 190 + : `Could not migrate blobs (${failed.length} missing)`; 131 191 132 - if (isNetworkError) { 133 - sourceUnreachable = true; 134 - console.warn( 135 - "[blob-migration] Source appears unreachable (likely CORS or network issue), skipping remaining blobs", 136 - ); 137 - const remaining = missingBlobs.length - migrated - 1; 138 - if (migrated > 0) { 139 - onProgress({ 140 - currentOperation: 141 - `Source PDS unreachable (browser security restriction). ${migrated} media files migrated successfully. ${ 142 - remaining + 1 143 - } could not be fetched - these may need to be re-uploaded.`, 144 - }); 145 - } else { 146 - onProgress({ 147 - currentOperation: 148 - `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.`, 149 - }); 150 - } 151 - } 152 - failed.push(cid); 153 - } 154 - } 192 + safeProgress(onProgress, { currentOperation: statusMessage }); 155 193 156 - if (migrated === missingBlobs.length) { 157 - onProgress({ 158 - currentOperation: `All ${migrated} blobs migrated successfully`, 159 - }); 160 - } else if (migrated > 0) { 161 - onProgress({ 162 - currentOperation: 163 - `${migrated}/${missingBlobs.length} blobs migrated. ${failed.length} failed.`, 164 - }); 165 - } else { 166 - onProgress({ 167 - currentOperation: `Could not migrate blobs (${failed.length} missing)`, 168 - }); 169 - } 194 + console.log(`[blob-migration] Complete: ${migrated} migrated, ${failed.length} failed`); 195 + failed.length > 0 && console.log("[blob-migration] Failed CIDs:", failed); 170 196 171 - return { migrated, failed, total: missingBlobs.length, sourceUnreachable }; 197 + return { 198 + migrated, 199 + failed, 200 + total: missingBlobs.length, 201 + sourceUnreachable: false, 202 + }; 172 203 }
+16 -4
frontend/src/lib/migration/flow.svelte.ts
··· 77 77 authMethod: "password", 78 78 passkeySetupToken: null, 79 79 oauthCodeVerifier: null, 80 + localAccessToken: null, 80 81 generatedAppPassword: null, 81 82 generatedAppPasswordName: null, 82 83 }); ··· 276 277 277 278 if (postEmailSteps.includes(targetStep)) { 278 279 localClient = createLocalClient(); 280 + if (state.localAccessToken) { 281 + localClient.setAccessToken(state.localAccessToken); 282 + } 279 283 if (state.authMethod === "passkey" && state.passkeySetupToken) { 280 284 setStep("passkey-setup"); 281 285 migrationLog( ··· 289 293 } 290 294 } else if (targetStep === "email-verify") { 291 295 localClient = createLocalClient(); 296 + if (state.localAccessToken) { 297 + localClient.setAccessToken(state.localAccessToken); 298 + } 292 299 setStep("email-verify"); 293 300 migrationLog("handleOAuthCallback: Resuming at email-verify"); 294 301 } else { ··· 389 396 state.passkeySetupToken = passkeySetup.setupToken; 390 397 if (passkeySetup.accessJwt) { 391 398 localClient.setAccessToken(passkeySetup.accessJwt); 399 + state.localAccessToken = passkeySetup.accessJwt; 392 400 } 393 401 } else { 394 402 const accountParams = { ··· 408 416 did: session.did, 409 417 }); 410 418 localClient.setAccessToken(session.accessJwt); 419 + state.localAccessToken = session.accessJwt; 411 420 } 412 421 413 422 setProgress({ currentOperation: "Exporting repository..." }); ··· 599 608 return true; 600 609 } 601 610 602 - await localClient.loginDeactivated( 603 - state.targetEmail, 604 - state.targetPassword, 605 - ); 611 + if (!localClient.getAccessToken()) { 612 + await localClient.loginDeactivated( 613 + state.targetEmail, 614 + state.targetPassword, 615 + ); 616 + } 606 617 607 618 if (!sourceClient) { 608 619 setStep("source-handle"); ··· 916 927 state.targetHandle = stored.targetHandle; 917 928 state.targetEmail = stored.targetEmail; 918 929 state.authMethod = stored.authMethod ?? "password"; 930 + state.localAccessToken = stored.localAccessToken ?? null; 919 931 state.progress = { 920 932 ...createInitialProgress(), 921 933 ...stored.progress,
+8 -6
frontend/src/lib/migration/offline-flow.svelte.ts
··· 497 497 const { verified } = await api.checkEmailVerified(state.targetEmail); 498 498 if (!verified) return false; 499 499 500 - const session = await api.createSession( 501 - state.targetEmail, 502 - state.targetPassword, 503 - ); 504 - state.localAccessToken = session.accessJwt; 505 - state.localRefreshToken = session.refreshJwt; 500 + if (!state.localAccessToken) { 501 + const session = await api.createSession( 502 + state.targetEmail, 503 + state.targetPassword, 504 + ); 505 + state.localAccessToken = session.accessJwt; 506 + state.localRefreshToken = session.refreshJwt; 507 + } 506 508 saveOfflineState(state); 507 509 508 510 setStep("plc-signing");
+1
frontend/src/lib/migration/storage.ts
··· 22 22 targetEmail: state.targetEmail, 23 23 authMethod: state.authMethod, 24 24 passkeySetupToken: state.passkeySetupToken ?? undefined, 25 + localAccessToken: state.localAccessToken ?? undefined, 25 26 progress: { 26 27 repoExported: state.progress.repoExported, 27 28 repoImported: state.progress.repoImported,
+2
frontend/src/lib/migration/types.ts
··· 69 69 authMethod: AuthMethod; 70 70 passkeySetupToken: string | null; 71 71 oauthCodeVerifier: string | null; 72 + localAccessToken: string | null; 72 73 generatedAppPassword: string | null; 73 74 generatedAppPasswordName: string | null; 74 75 needsReauth?: boolean; ··· 117 118 targetEmail: string; 118 119 authMethod?: AuthMethod; 119 120 passkeySetupToken?: string; 121 + localAccessToken?: string; 120 122 progress: { 121 123 repoExported: boolean; 122 124 repoImported: boolean;
+6
frontend/src/locales/fi.json
··· 1065 1065 "accessLevel": "Käyttöoikeustaso", 1066 1066 "adding": "Lisätään...", 1067 1067 "addControllerButton": "+ Lisää hallinnoija", 1068 + "addControllerWarningTitle": "Tärkeää: Tämä muuttaa kirjautumistapasi", 1069 + "addControllerWarningText": "Hallinnoijan lisääminen tarkoittaa, että vain hallinnoijatili voi kirjautua tähän tiliin OAuth:n kautta. Et voi enää kirjautua suoraan omilla tunnuksillasi kolmannen osapuolen sovellusten tai verkkokäyttöliittymän kautta.", 1070 + "addControllerWarningBullet1": "Hallinnoija voi toimia puolestasi myöntämilläsi oikeuksilla", 1071 + "addControllerWarningBullet2": "Sinun täytyy ensin kirjautua hallinnoijana ja sitten vaihtaa tähän tiliin", 1072 + "addControllerWarningBullet3": "Voit poistaa hallinnoijan myöhemmin palauttaaksesi suoran kirjautumisoikeuden", 1073 + "addControllerConfirm": "Ymmärrän, etten voi enää kirjautua suoraan", 1068 1074 "controllerAdded": "Hallinnoija lisätty", 1069 1075 "controllerRemoved": "Hallinnoija poistettu", 1070 1076 "failedToAddController": "Hallinnoijan lisääminen epäonnistui",
+6
frontend/src/locales/ja.json
··· 1078 1078 "adding": "追加中...", 1079 1079 "accessLevel": "アクセスレベル", 1080 1080 "addControllerButton": "+ コントローラーを追加", 1081 + "addControllerWarningTitle": "重要: ログイン方法が変わります", 1082 + "addControllerWarningText": "コントローラーを追加すると、OAuth経由でこのアカウントにログインできるのはコントローラーアカウントのみになります。サードパーティアプリやWebインターフェースから自分の認証情報で直接ログインすることはできなくなります。", 1083 + "addControllerWarningBullet1": "コントローラーは付与した権限であなたの代わりに操作できるようになります", 1084 + "addControllerWarningBullet2": "まずコントローラーとしてログインし、その後このアカウントに切り替える必要があります", 1085 + "addControllerWarningBullet3": "後でコントローラーを削除すれば、直接ログインできるようになります", 1086 + "addControllerConfirm": "直接ログインできなくなることを理解しました", 1081 1087 "auditLogDesc": "すべての委任アクティビティを表示", 1082 1088 "cannotAddControllers": "他のアカウントを管理しているため、コントローラーを追加できません。アカウントはコントローラーを持つか、他のアカウントを管理するかのいずれかのみ可能です。", 1083 1089 "cannotControlAccounts": "このアカウントにはコントローラーがいるため、他のアカウントを管理できません。アカウントはコントローラーを持つか、他のアカウントを管理するかのいずれかのみ可能です。",
+6
frontend/src/locales/ko.json
··· 1078 1078 "adding": "추가 중...", 1079 1079 "accessLevel": "액세스 수준", 1080 1080 "addControllerButton": "+ 컨트롤러 추가", 1081 + "addControllerWarningTitle": "중요: 로그인 방식이 변경됩니다", 1082 + "addControllerWarningText": "컨트롤러를 추가하면 OAuth를 통해 이 계정에 로그인할 수 있는 것은 컨트롤러 계정뿐입니다. 서드파티 앱이나 웹 인터페이스에서 본인 자격 증명으로 직접 로그인할 수 없게 됩니다.", 1083 + "addControllerWarningBullet1": "컨트롤러는 부여한 권한으로 귀하를 대신하여 작업할 수 있습니다", 1084 + "addControllerWarningBullet2": "먼저 컨트롤러로 로그인한 후 이 계정으로 전환해야 합니다", 1085 + "addControllerWarningBullet3": "나중에 컨트롤러를 제거하면 직접 로그인 권한을 복구할 수 있습니다", 1086 + "addControllerConfirm": "직접 로그인할 수 없게 되는 것을 이해합니다", 1081 1087 "auditLogDesc": "모든 위임 활동 보기", 1082 1088 "cannotAddControllers": "다른 계정을 관리하고 있어 컨트롤러를 추가할 수 없습니다. 계정은 컨트롤러를 가지거나 다른 계정을 관리할 수 있지만 둘 다는 불가능합니다.", 1083 1089 "cannotControlAccounts": "이 계정에 컨트롤러가 있어 다른 계정을 관리할 수 없습니다. 계정은 컨트롤러를 가지거나 다른 계정을 관리할 수 있지만 둘 다는 불가능합니다.",
+6
frontend/src/locales/sv.json
··· 1078 1078 "adding": "Lägger till...", 1079 1079 "accessLevel": "Åtkomstnivå", 1080 1080 "addControllerButton": "+ Lägg till kontrollant", 1081 + "addControllerWarningTitle": "Viktigt: Detta ändrar hur du loggar in", 1082 + "addControllerWarningText": "Att lägga till en kontrollant innebär att endast kontrollantskontot kommer att kunna logga in på detta konto via OAuth. Du kommer inte längre att kunna logga in direkt med dina egna uppgifter via tredjepartsappar eller webbgränssnittet.", 1083 + "addControllerWarningBullet1": "Kontrollanten kommer att kunna agera för dig med de behörigheter du beviljar", 1084 + "addControllerWarningBullet2": "Du måste först logga in som kontrollant och sedan byta till detta konto", 1085 + "addControllerWarningBullet3": "Du kan ta bort kontrollanten senare för att återfå direkt inloggning", 1086 + "addControllerConfirm": "Jag förstår att jag inte längre kommer att kunna logga in direkt", 1081 1087 "auditLogDesc": "Visa all delegeringsaktivitet", 1082 1088 "cannotAddControllers": "Du kan inte lägga till kontrollanter eftersom detta konto kontrollerar andra konton. Ett konto kan antingen ha kontrollanter eller kontrollera andra konton, men inte båda.", 1083 1089 "cannotControlAccounts": "Du kan inte kontrollera andra konton eftersom detta konto har kontrollanter. Ett konto kan antingen ha kontrollanter eller kontrollera andra konton, men inte båda.",
+6
frontend/src/locales/zh.json
··· 1079 1079 "adding": "添加中...", 1080 1080 "accessLevel": "访问级别", 1081 1081 "addControllerButton": "+ 添加控制者", 1082 + "addControllerWarningTitle": "重要提示:这将改变您的登录方式", 1083 + "addControllerWarningText": "添加控制者意味着只有控制者账户才能通过 OAuth 登录此账户。您将无法再使用自己的凭据通过第三方应用或网页界面直接登录。", 1084 + "addControllerWarningBullet1": "控制者将能够以您授予的权限代表您进行操作", 1085 + "addControllerWarningBullet2": "您需要先以控制者身份登录,然后切换到此账户", 1086 + "addControllerWarningBullet3": "您可以稍后移除控制者以恢复直接登录权限", 1087 + "addControllerConfirm": "我理解我将无法再直接登录", 1082 1088 "auditLogDesc": "查看所有委托活动", 1083 1089 "cannotAddControllers": "因为此账户正在控制其他账户,所以无法添加控制者。账户只能拥有控制者或控制其他账户,不能同时两者兼备。", 1084 1090 "cannotControlAccounts": "因为此账户有控制者,所以无法控制其他账户。账户只能拥有控制者或控制其他账户,不能同时两者兼备。",