diff --git a/frontend/deno.json b/frontend/deno.json index 2d7690d..642eca7 100644 --- a/frontend/deno.json +++ b/frontend/deno.json @@ -9,5 +9,13 @@ "test:ui": "deno run -A npm:vitest --ui", "test:coverage": "deno run -A npm:vitest run --coverage" }, - "nodeModulesDir": "auto" + "nodeModulesDir": "auto", + "lint": { + "rules": { + "exclude": [ + "require-await", + "prefer-const" + ] + } + } } diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 2d3d629..6119e60 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -36,6 +36,11 @@ import DidDocumentEditor from './routes/DidDocumentEditor.svelte' import Home from './routes/Home.svelte' + if (window.location.pathname === '/migrate') { + const newUrl = `${window.location.origin}/${window.location.search}#/migrate` + window.location.replace(newUrl) + } + initI18n() const auth = getAuthState() @@ -43,6 +48,9 @@ let oauthCallbackPending = $state(hasOAuthCallback()) function hasOAuthCallback(): boolean { + if (window.location.hash === '#/migrate') { + return false + } const params = new URLSearchParams(window.location.search) return !!(params.get('code') && params.get('state')) } diff --git a/frontend/src/components/ReauthModal.svelte b/frontend/src/components/ReauthModal.svelte index 09c31e5..8d09e2d 100644 --- a/frontend/src/components/ReauthModal.svelte +++ b/frontend/src/components/ReauthModal.svelte @@ -170,12 +170,12 @@ diff --git a/frontend/src/components/migration/InboundWizard.svelte b/frontend/src/components/migration/InboundWizard.svelte index 95cb853..3117490 100644 --- a/frontend/src/components/migration/InboundWizard.svelte +++ b/frontend/src/components/migration/InboundWizard.svelte @@ -1,33 +1,60 @@ -
+
- {#each steps as stepName, i} + {#each steps as _, i}
{i < getCurrentStepIndex() ? '✓' : i + 1}
- {stepName}
{#if i < steps.length - 1}
{/if} {/each}
+
+ {steps[getCurrentStepIndex()]} · Step {getCurrentStepIndex() + 1} of {steps.length} +
{#if flow.state.error}
{flow.state.error}
@@ -238,116 +323,86 @@ {#if flow.state.step === 'welcome'}
-

Migrate Your Account Here

-

This wizard will help you move your AT Protocol account from another PDS to this one.

+

{$_('migration.inbound.welcome.title')}

+

{$_('migration.inbound.welcome.desc')}

-

What will happen:

+

{$_('migration.inbound.common.whatWillHappen')}

    -
  1. Log in to your current PDS
  2. -
  3. Choose your new handle on this server
  4. -
  5. Your repository and blobs will be transferred
  6. -
  7. Verify the migration via email
  8. -
  9. Your identity will be updated to point here
  10. +
  11. {$_('migration.inbound.common.step1')}
  12. +
  13. {$_('migration.inbound.common.step2')}
  14. +
  15. {$_('migration.inbound.common.step3')}
  16. +
  17. {$_('migration.inbound.common.step4')}
  18. +
  19. {$_('migration.inbound.common.step5')}
- Before you proceed: + {$_('migration.inbound.common.beforeProceed')}
    -
  • You need access to the email registered with your current account
  • -
  • Large accounts may take several minutes to transfer
  • -
  • Your old account will be deactivated after migration
  • +
  • {$_('migration.inbound.common.warning1')}
  • +
  • {$_('migration.inbound.common.warning2')}
  • +
  • {$_('migration.inbound.common.warning3')}
- - +
- {:else if flow.state.step === 'source-login'} + {:else if flow.state.step === 'source-handle'}
-

{isResumedMigration ? 'Resume Migration' : 'Log In to Your Current PDS'}

-

{isResumedMigration ? 'Enter your credentials to continue the migration.' : 'Enter your credentials for the account you want to migrate.'}

- - {#if isResumedMigration} -
-

Your migration was interrupted. Log in to both accounts to resume.

-

Migrating: {flow.state.sourceHandle} → {flow.state.targetHandle}

+

{isResuming ? $_('migration.inbound.sourceAuth.titleResume') : $_('migration.inbound.sourceAuth.title')}

+

{isResuming ? $_('migration.inbound.sourceAuth.descResume') : $_('migration.inbound.sourceAuth.desc')}

+ + {#if isResuming && resumeInfo} +
+

{$_('migration.inbound.sourceAuth.resumeTitle')}

+
+
+ {$_('migration.inbound.sourceAuth.resumeFrom')}: + @{resumeInfo.sourceHandle} +
+
+ {$_('migration.inbound.sourceAuth.resumeTo')}: + @{resumeInfo.targetHandle} +
+
+ {$_('migration.inbound.sourceAuth.resumeProgress')}: + {resumeInfo.progressSummary} +
+
+

{$_('migration.inbound.sourceAuth.resumeOAuthNote')}

{/if} -
+
- + -

Your current handle on your existing PDS

-
- -
- - -

Your account password (not an app password)

+

{$_('migration.inbound.sourceAuth.handleHint')}

- {#if flow.state.requires2FA} -
- - -

Check your email for the verification code

-
- {/if} - - {#if isResumedMigration} -
- -
- - -

The password you set for your account on this PDS

-
- {/if} -
- - +
@@ -355,16 +410,16 @@ {:else if flow.state.step === 'choose-handle'}
-

Choose Your New Handle

-

Select a handle for your account on this PDS.

+

{$_('migration.inbound.chooseHandle.title')}

+

{$_('migration.inbound.chooseHandle.desc')}

- Migrating from: + {$_('migration.inbound.chooseHandle.migratingFrom')}: {flow.state.sourceHandle}
- +
{#if checkingHandle} -

Checking availability...

+

{$_('migration.inbound.chooseHandle.checkingAvailability')}

{:else if handleAvailable === true} -

Handle is available!

+

{$_('migration.inbound.chooseHandle.handleAvailable')}

{:else if handleAvailable === false} -

Handle is already taken

+

{$_('migration.inbound.chooseHandle.handleTaken')}

{:else} -

You can also use your own domain by entering the full handle (e.g., alice.mydomain.com)

+

{$_('migration.inbound.chooseHandle.handleHint')}

{/if}
- +
- - flow.updateField('targetPassword', (e.target as HTMLInputElement).value)} - required - minlength="8" - /> -

At least 8 characters

+ +
+ + +
+ {#if selectedAuthMethod === 'password'} +
+ + flow.updateField('targetPassword', (e.target as HTMLInputElement).value)} + required + minlength="8" + /> +

{$_('migration.inbound.chooseHandle.passwordHint')}

+
+ {:else} +
+

{$_('migration.inbound.chooseHandle.passkeyInfo')}

+
+ {/if} + {#if serverInfo?.inviteCodeRequired}
- + - +
{:else if flow.state.step === 'review'}
-

Review Migration

-

Please confirm the details of your migration.

+

{$_('migration.inbound.review.title')}

+

{$_('migration.inbound.review.desc')}

- Current Handle: + {$_('migration.inbound.review.currentHandle')}: {flow.state.sourceHandle}
- New Handle: + {$_('migration.inbound.review.newHandle')}: {flow.state.targetHandle}
- DID: + {$_('migration.inbound.review.did')}: {flow.state.sourceDid}
- From PDS: + {$_('migration.inbound.review.sourcePds')}: {flow.state.sourcePdsUrl}
- To PDS: + {$_('migration.inbound.review.targetPds')}: {window.location.origin}
- Email: + {$_('migration.inbound.review.email')}: {flow.state.targetEmail}
+
+ {$_('migration.inbound.review.authentication')}: + {flow.state.authMethod === 'passkey' ? $_('migration.inbound.review.authPasskey') : $_('migration.inbound.review.authPassword')} +
- Final confirmation: After you click "Start Migration", your repository and data will begin - transferring. This process cannot be easily undone. + {$_('migration.inbound.review.warning')}
- +
{:else if flow.state.step === 'migrating'}
-

Migration in Progress

-

Please wait while your account is being transferred...

+

{$_('migration.inbound.migrating.title')}

+

{$_('migration.inbound.migrating.desc')}

{flow.state.progress.repoExported ? '✓' : '○'} - Export repository + {$_('migration.inbound.migrating.exportRepo')}
{flow.state.progress.repoImported ? '✓' : '○'} - Import repository + {$_('migration.inbound.migrating.importRepo')}
{flow.state.progress.blobsMigrated === flow.state.progress.blobsTotal && flow.state.progress.blobsTotal > 0 ? '✓' : '○'} - Migrate blobs ({flow.state.progress.blobsMigrated}/{flow.state.progress.blobsTotal}) + {$_('migration.inbound.migrating.migrateBlobs')} ({flow.state.progress.blobsMigrated}/{flow.state.progress.blobsTotal})
{flow.state.progress.prefsMigrated ? '✓' : '○'} - Migrate preferences + {$_('migration.inbound.migrating.migratePrefs')}
@@ -525,6 +619,68 @@

{flow.state.progress.currentOperation}

+ {:else if flow.state.step === 'passkey-setup'} +
+

{$_('migration.inbound.passkeySetup.title')}

+

{$_('migration.inbound.passkeySetup.desc')}

+ + {#if flow.state.error} +
+ {flow.state.error} +
+ {/if} + +
+ + +

{$_('migration.inbound.passkeySetup.nameHint')}

+
+ +
+

{$_('migration.inbound.passkeySetup.instructions')}

+ +
+
+ + {:else if flow.state.step === 'app-password'} +
+

{$_('migration.inbound.appPassword.title')}

+

{$_('migration.inbound.appPassword.desc')}

+ +
+ {$_('migration.inbound.appPassword.warning')} +
+ +
+
+ {$_('migration.inbound.appPassword.label')}: {flow.state.generatedAppPasswordName} +
+ {flow.state.generatedAppPassword} + +
+ + + +
+ +
+
+ {:else if flow.state.step === 'email-verify'}

{$_('migration.inbound.emailVerify.title')}

@@ -537,7 +693,7 @@
{#if flow.state.error} -
+
{flow.state.error}
{/if} @@ -569,23 +725,20 @@ {:else if flow.state.step === 'plc-token'}
-

Verify Migration

-

A verification code has been sent to the email registered with your old account.

+

{$_('migration.inbound.plcToken.title')}

+

{$_('migration.inbound.plcToken.desc')}

-

- This code confirms you have access to the account and authorizes updating your identity - to point to this PDS. -

+

{$_('migration.inbound.plcToken.info')}

- + flow.updateField('plcToken', (e.target as HTMLInputElement).value)} disabled={loading} @@ -595,10 +748,10 @@
@@ -653,7 +806,7 @@
- + @@ -662,21 +815,21 @@ {:else if flow.state.step === 'finalizing'}
-

Finalizing Migration

-

Please wait while we complete the migration...

+

{$_('migration.inbound.finalizing.title')}

+

{$_('migration.inbound.finalizing.desc')}

{flow.state.progress.plcSigned ? '✓' : '○'} - Sign identity update + {$_('migration.inbound.finalizing.signingPlc')}
{flow.state.progress.activated ? '✓' : '○'} - Activate new account + {$_('migration.inbound.finalizing.activating')}
{flow.state.progress.deactivated ? '✓' : '○'} - Deactivate old account + {$_('migration.inbound.finalizing.deactivating')}
@@ -686,435 +839,107 @@ {:else if flow.state.step === 'success'}
✓
-

Migration Complete!

-

Your account has been successfully migrated to this PDS.

+

{$_('migration.inbound.success.title')}

+

{$_('migration.inbound.success.desc')}

- Your new handle: + {$_('migration.inbound.success.yourNewHandle')}: {flow.state.targetHandle}
- DID: + {$_('migration.inbound.success.did')}: {flow.state.sourceDid}
{#if flow.state.progress.blobsFailed.length > 0} -
- Note: {flow.state.progress.blobsFailed.length} blobs could not be migrated. - These may be images or other media that are no longer available. +
+ {$_('migration.inbound.success.blobsWarning', { values: { count: flow.state.progress.blobsFailed.length } })}
{/if} -

Redirecting to dashboard...

+

{$_('migration.inbound.success.redirecting')}

{:else if flow.state.step === 'error'}
-

Migration Error

-

An error occurred during migration.

+

{$_('migration.inbound.error.title')}

+

{$_('migration.inbound.error.desc')}

-
- {flow.state.error} +
+ {flow.state.error || 'An unknown error occurred. Please check the browser console for details.'}
- +
{/if}
diff --git a/frontend/src/components/migration/OutboundWizard.svelte b/frontend/src/components/migration/OutboundWizard.svelte index 6e6df48..bb591fa 100644 --- a/frontend/src/components/migration/OutboundWizard.svelte +++ b/frontend/src/components/migration/OutboundWizard.svelte @@ -2,6 +2,7 @@ import type { OutboundMigrationFlow } from '../../lib/migration' import type { ServerDescription } from '../../lib/migration/types' import { getAuthState, logout } from '../../lib/auth.svelte' + import '../../styles/migration.css' interface Props { flow: OutboundMigrationFlow @@ -119,7 +120,7 @@ } -
+
{#if flow.state.step !== 'welcome'}
{#each steps as stepName, i} @@ -135,7 +136,7 @@ {/if} {#if flow.state.error} -
{flow.state.error}
+
{flow.state.error}
{/if} {#if flow.state.step === 'welcome'} @@ -149,7 +150,7 @@
{#if isDidWeb()} -
+
did:web Migration Notice

Your account uses a did:web identifier ({auth.session?.did}). After migrating, this PDS will @@ -161,7 +162,7 @@

{/if} -
+

What will happen:

  1. Choose your new PDS
  2. @@ -173,7 +174,7 @@
-
+
Before you proceed:
  • You need access to the email registered with this account
  • @@ -202,7 +203,7 @@

    Enter the URL of the PDS you want to migrate to.

    -
    +
    -

    The server address of your new PDS (e.g., bsky.social, pds.example.com)

    +

    The server address of your new PDS (e.g., bsky.social, pds.example.com)

    @@ -264,7 +265,7 @@ {flow.state.targetPdsUrl}
    -
    +
    {/if}
    -

    You can also use your own domain by entering the full handle (e.g., alice.mydomain.com)

    +

    You can also use your own domain by entering the full handle (e.g., alice.mydomain.com)

    -
    +
    -
    +
    -

    At least 8 characters. This will be your password on the new PDS.

    +

    At least 8 characters. This will be your password on the new PDS.

    {#if flow.state.targetServerInfo?.inviteCodeRequired} -
    +
    flow.updateField('inviteCode', (e.target as HTMLInputElement).value)} required /> -

    Required by this PDS to create an account

    +

    Required by this PDS to create an account

    {/if} @@ -368,7 +369,7 @@
    -
    +
    This action cannot be easily undone!

    After migration completes, your account on this PDS will be deactivated. @@ -430,7 +431,7 @@

    Verify Migration

    A verification code has been sent to your email ({auth.session?.email}).

    -
    +

    This code confirms you have access to the account and authorizes updating your identity to point to the new PDS. @@ -438,7 +439,7 @@

    -
    +
    {#if flow.state.progress.blobsFailed.length > 0} -
    +
    Note: {flow.state.progress.blobsFailed.length} blobs could not be migrated. These may be images or other media that are no longer available.
    @@ -530,7 +531,7 @@

    Migration Error

    An error occurred during migration.

    -
    +
    {flow.state.error}
    @@ -542,451 +543,4 @@
    diff --git a/frontend/src/lib/auth.svelte.ts b/frontend/src/lib/auth.svelte.ts index b5e5fd5..79ba0a5 100644 --- a/frontend/src/lib/auth.svelte.ts +++ b/frontend/src/lib/auth.svelte.ts @@ -444,11 +444,15 @@ export function _testSetState( state.savedAccounts = newState.savedAccounts ?? []; } -export function _testReset() { +export function _testResetState() { state.session = null; state.loading = true; state.error = null; state.savedAccounts = []; +} + +export function _testReset() { + _testResetState(); localStorage.removeItem(STORAGE_KEY); localStorage.removeItem(ACCOUNTS_KEY); } diff --git a/frontend/src/lib/crypto.ts b/frontend/src/lib/crypto.ts index ba55830..3f2a9ec 100644 --- a/frontend/src/lib/crypto.ts +++ b/frontend/src/lib/crypto.ts @@ -10,7 +10,7 @@ export interface Keypair { publicKeyDidKey: string; } -export async function generateKeypair(): Promise { +export function generateKeypair(): Keypair { const privateKey = secp.utils.randomPrivateKey(); const publicKey = secp.getPublicKey(privateKey, true); diff --git a/frontend/src/lib/migration/atproto-client.ts b/frontend/src/lib/migration/atproto-client.ts index e2f2104..0a86812 100644 --- a/frontend/src/lib/migration/atproto-client.ts +++ b/frontend/src/lib/migration/atproto-client.ts @@ -1,14 +1,19 @@ import type { AccountStatus, BlobRef, + CompletePasskeySetupResponse, CreateAccountParams, + CreatePasskeyAccountParams, DidCredentials, DidDocument, - MigrationError, + OAuthServerMetadata, + OAuthTokenResponse, + PasskeyAccountSetup, PlcOperation, Preferences, ServerDescription, Session, + StartPasskeyRegistrationResponse, } from "./types"; function apiLog( @@ -28,6 +33,8 @@ function apiLog( export class AtprotoClient { private baseUrl: string; private accessToken: string | null = null; + private dpopKeyPair: DPoPKeyPair | null = null; + private dpopNonce: string | null = null; constructor(pdsUrl: string) { this.baseUrl = pdsUrl.replace(/\/$/, ""); @@ -41,6 +48,10 @@ export class AtprotoClient { return this.accessToken; } + setDPoPKeyPair(keyPair: DPoPKeyPair | null) { + this.dpopKeyPair = keyPair; + } + private async xrpc( method: string, options?: { @@ -67,35 +78,60 @@ export class AtprotoClient { url += `?${searchParams}`; } - const headers: Record = {}; - const token = authToken ?? this.accessToken; - if (token) { - headers["Authorization"] = `Bearer ${token}`; - } + const makeRequest = async (nonce?: string): Promise => { + const headers: Record = {}; + const token = authToken ?? this.accessToken; + if (token) { + if (this.dpopKeyPair) { + headers["Authorization"] = `DPoP ${token}`; + const tokenHash = await computeAccessTokenHash(token); + const dpopProof = await createDPoPProof( + this.dpopKeyPair, + httpMethod, + url.split("?")[0], + nonce, + tokenHash, + ); + headers["DPoP"] = dpopProof; + } else { + headers["Authorization"] = `Bearer ${token}`; + } + } - let requestBody: BodyInit | undefined; - if (rawBody) { - headers["Content-Type"] = contentType ?? "application/octet-stream"; - requestBody = rawBody; - } else if (body) { - headers["Content-Type"] = "application/json"; - requestBody = JSON.stringify(body); - } else if (httpMethod === "POST") { - headers["Content-Type"] = "application/json"; - } + let requestBody: BodyInit | undefined; + if (rawBody) { + headers["Content-Type"] = contentType ?? "application/octet-stream"; + requestBody = rawBody; + } else if (body) { + headers["Content-Type"] = "application/json"; + requestBody = JSON.stringify(body); + } else if (httpMethod === "POST") { + headers["Content-Type"] = "application/json"; + } - const res = await fetch(url, { - method: httpMethod, - headers, - body: requestBody, - }); + return fetch(url, { + method: httpMethod, + headers, + body: requestBody, + }); + }; + + let res = await makeRequest(this.dpopNonce ?? undefined); + + if (!res.ok && this.dpopKeyPair) { + const dpopNonce = res.headers.get("DPoP-Nonce"); + if (dpopNonce && dpopNonce !== this.dpopNonce) { + this.dpopNonce = dpopNonce; + res = await makeRequest(dpopNonce); + } + } if (!res.ok) { const err = await res.json().catch(() => ({ error: "Unknown", message: res.statusText, })); - const error = new Error(err.message) as Error & { + const error = new Error(err.message || err.error || res.statusText) as Error & { status: number; error: string; }; @@ -104,6 +140,11 @@ export class AtprotoClient { throw error; } + const newNonce = res.headers.get("DPoP-Nonce"); + if (newNonce) { + this.dpopNonce = newNonce; + } + const responseContentType = res.headers.get("content-type") ?? ""; if (responseContentType.includes("application/json")) { return res.json(); @@ -231,7 +272,7 @@ export class AtprotoClient { error: "Unknown", message: res.statusText, })); - const error = new Error(err.message) as Error & { + const error = new Error(err.message || err.error || res.statusText) as Error & { status: number; error: string; }; @@ -436,6 +477,270 @@ export class AtprotoClient { httpMethod: "POST", }); } + + async createPasskeyAccount( + params: CreatePasskeyAccountParams, + serviceToken?: string, + ): Promise { + const headers: Record = { + "Content-Type": "application/json", + }; + if (serviceToken) { + headers["Authorization"] = `Bearer ${serviceToken}`; + } + + const res = await fetch( + `${this.baseUrl}/xrpc/com.tranquil.account.createPasskeyAccount`, + { + method: "POST", + headers, + body: JSON.stringify(params), + }, + ); + + if (!res.ok) { + const err = await res.json().catch(() => ({ + error: "Unknown", + message: res.statusText, + })); + const error = new Error(err.message || err.error || res.statusText) as Error & { + status: number; + error: string; + }; + error.status = res.status; + error.error = err.error; + throw error; + } + + return res.json(); + } + + async startPasskeyRegistrationForSetup( + did: string, + setupToken: string, + friendlyName?: string, + ): Promise { + return this.xrpc("com.tranquil.account.startPasskeyRegistrationForSetup", { + httpMethod: "POST", + body: { did, setupToken, friendlyName }, + }); + } + + async completePasskeySetup( + did: string, + setupToken: string, + passkeyCredential: unknown, + passkeyFriendlyName?: string, + ): Promise { + return this.xrpc("com.tranquil.account.completePasskeySetup", { + httpMethod: "POST", + body: { did, setupToken, passkeyCredential, passkeyFriendlyName }, + }); + } +} + +export async function getOAuthServerMetadata( + pdsUrl: string, +): Promise { + try { + const directUrl = `${pdsUrl}/.well-known/oauth-authorization-server`; + const directRes = await fetch(directUrl); + if (directRes.ok) { + return directRes.json(); + } + + const protectedResourceUrl = `${pdsUrl}/.well-known/oauth-protected-resource`; + const protectedRes = await fetch(protectedResourceUrl); + if (!protectedRes.ok) { + return null; + } + + const protectedMetadata = await protectedRes.json(); + const authServers = protectedMetadata.authorization_servers; + if (!authServers || authServers.length === 0) { + return null; + } + + const authServerUrl = `${authServers[0]}/.well-known/oauth-authorization-server`; + const authServerRes = await fetch(authServerUrl); + if (!authServerRes.ok) { + return null; + } + + return authServerRes.json(); + } catch { + return null; + } +} + +export async function generatePKCE(): Promise<{ + codeVerifier: string; + codeChallenge: string; +}> { + const array = new Uint8Array(32); + crypto.getRandomValues(array); + const codeVerifier = base64UrlEncode(array); + + const encoder = new TextEncoder(); + const data = encoder.encode(codeVerifier); + const digest = await crypto.subtle.digest("SHA-256", data); + const codeChallenge = base64UrlEncode(new Uint8Array(digest)); + + return { codeVerifier, codeChallenge }; +} + +export function base64UrlEncode(buffer: Uint8Array | ArrayBuffer): string { + const bytes = buffer instanceof ArrayBuffer ? new Uint8Array(buffer) : buffer; + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +export function base64UrlDecode(base64url: string): Uint8Array { + const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/"); + const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +export function prepareWebAuthnCreationOptions( + options: { publicKey: Record }, +): PublicKeyCredentialCreationOptions { + const pk = options.publicKey; + return { + ...pk, + challenge: base64UrlDecode(pk.challenge as string), + user: { + ...(pk.user as Record), + id: base64UrlDecode((pk.user as Record).id as string), + }, + excludeCredentials: + ((pk.excludeCredentials as Array>) ?? []).map( + (cred) => ({ + ...cred, + id: base64UrlDecode(cred.id as string), + }), + ), + } as PublicKeyCredentialCreationOptions; +} + +async function computeAccessTokenHash(accessToken: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(accessToken); + const hash = await crypto.subtle.digest("SHA-256", data); + return base64UrlEncode(new Uint8Array(hash)); +} + +export function generateOAuthState(): string { + const array = new Uint8Array(16); + crypto.getRandomValues(array); + return base64UrlEncode(array); +} + +export function buildOAuthAuthorizationUrl( + metadata: OAuthServerMetadata, + params: { + clientId: string; + redirectUri: string; + codeChallenge: string; + state: string; + scope?: string; + dpopJkt?: string; + loginHint?: string; + }, +): string { + const url = new URL(metadata.authorization_endpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", params.clientId); + url.searchParams.set("redirect_uri", params.redirectUri); + url.searchParams.set("code_challenge", params.codeChallenge); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("state", params.state); + url.searchParams.set("scope", params.scope ?? "atproto"); + if (params.dpopJkt) { + url.searchParams.set("dpop_jkt", params.dpopJkt); + } + if (params.loginHint) { + url.searchParams.set("login_hint", params.loginHint); + } + return url.toString(); +} + +export async function exchangeOAuthCode( + metadata: OAuthServerMetadata, + params: { + code: string; + codeVerifier: string; + clientId: string; + redirectUri: string; + dpopKeyPair?: DPoPKeyPair; + }, +): Promise { + const body = new URLSearchParams({ + grant_type: "authorization_code", + code: params.code, + code_verifier: params.codeVerifier, + client_id: params.clientId, + redirect_uri: params.redirectUri, + }); + + const makeRequest = async (nonce?: string): Promise => { + const headers: Record = { + "Content-Type": "application/x-www-form-urlencoded", + }; + + if (params.dpopKeyPair) { + const dpopProof = await createDPoPProof( + params.dpopKeyPair, + "POST", + metadata.token_endpoint, + nonce, + ); + headers["DPoP"] = dpopProof; + } + + return fetch(metadata.token_endpoint, { + method: "POST", + headers, + body: body.toString(), + }); + }; + + let res = await makeRequest(); + + if (!res.ok) { + const err = await res.json().catch(() => ({ + error: "token_error", + error_description: res.statusText, + })); + + if (err.error === "use_dpop_nonce" && params.dpopKeyPair) { + const dpopNonce = res.headers.get("DPoP-Nonce"); + if (dpopNonce) { + res = await makeRequest(dpopNonce); + if (!res.ok) { + const retryErr = await res.json().catch(() => ({ + error: "token_error", + error_description: res.statusText, + })); + throw new Error( + retryErr.error_description || retryErr.error || "Token exchange failed", + ); + } + return res.json(); + } + } + + throw new Error(err.error_description || err.error || "Token exchange failed"); + } + + return res.json(); } export async function resolveDidDocument(did: string): Promise { @@ -466,7 +771,7 @@ export async function resolveDidDocument(did: string): Promise { export async function resolvePdsUrl( handleOrDid: string, ): Promise<{ did: string; pdsUrl: string }> { - let did: string; + let did: string | undefined; if (handleOrDid.startsWith("did:")) { did = handleOrDid; @@ -515,6 +820,10 @@ export async function resolvePdsUrl( } } + if (!did) { + throw new Error("Could not resolve DID"); + } + const didDoc = await resolveDidDocument(did); const pdsService = didDoc.service?.find( @@ -529,5 +838,159 @@ export async function resolvePdsUrl( } export function createLocalClient(): AtprotoClient { - return new AtprotoClient(window.location.origin); + return new AtprotoClient(globalThis.location.origin); +} + +export function getMigrationOAuthClientId(): string { + return `${globalThis.location.origin}/oauth/client-metadata.json`; +} + +export function getMigrationOAuthRedirectUri(): string { + return `${globalThis.location.origin}/migrate`; +} + +export interface DPoPKeyPair { + privateKey: CryptoKey; + publicKey: CryptoKey; + jwk: JsonWebKey; + thumbprint: string; +} + +const DPOP_KEY_STORAGE = "migration_dpop_key"; +const DPOP_KEY_MAX_AGE_MS = 24 * 60 * 60 * 1000; + +export async function generateDPoPKeyPair(): Promise { + const keyPair = await crypto.subtle.generateKey( + { + name: "ECDSA", + namedCurve: "P-256", + }, + true, + ["sign", "verify"], + ); + + const publicJwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey); + const thumbprint = await computeJwkThumbprint(publicJwk); + + return { + privateKey: keyPair.privateKey, + publicKey: keyPair.publicKey, + jwk: publicJwk, + thumbprint, + }; +} + +async function computeJwkThumbprint(jwk: JsonWebKey): Promise { + const thumbprintInput = JSON.stringify({ + crv: jwk.crv, + kty: jwk.kty, + x: jwk.x, + y: jwk.y, + }); + + const encoder = new TextEncoder(); + const data = encoder.encode(thumbprintInput); + const hash = await crypto.subtle.digest("SHA-256", data); + return base64UrlEncode(new Uint8Array(hash)); +} + +export async function saveDPoPKey(keyPair: DPoPKeyPair): Promise { + const privateJwk = await crypto.subtle.exportKey("jwk", keyPair.privateKey); + const stored = { + privateJwk, + publicJwk: keyPair.jwk, + thumbprint: keyPair.thumbprint, + createdAt: Date.now(), + }; + localStorage.setItem(DPOP_KEY_STORAGE, JSON.stringify(stored)); +} + +export async function loadDPoPKey(): Promise { + const stored = localStorage.getItem(DPOP_KEY_STORAGE); + if (!stored) return null; + + try { + const { privateJwk, publicJwk, thumbprint, createdAt } = JSON.parse(stored); + + if (createdAt && Date.now() - createdAt > DPOP_KEY_MAX_AGE_MS) { + localStorage.removeItem(DPOP_KEY_STORAGE); + return null; + } + + const privateKey = await crypto.subtle.importKey( + "jwk", + privateJwk, + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["sign"], + ); + + const publicKey = await crypto.subtle.importKey( + "jwk", + publicJwk, + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["verify"], + ); + + return { privateKey, publicKey, jwk: publicJwk, thumbprint }; + } catch { + localStorage.removeItem(DPOP_KEY_STORAGE); + return null; + } +} + +export function clearDPoPKey(): void { + localStorage.removeItem(DPOP_KEY_STORAGE); +} + +export async function createDPoPProof( + keyPair: DPoPKeyPair, + httpMethod: string, + httpUri: string, + nonce?: string, + accessTokenHash?: string, +): Promise { + const header = { + typ: "dpop+jwt", + alg: "ES256", + jwk: { + kty: keyPair.jwk.kty, + crv: keyPair.jwk.crv, + x: keyPair.jwk.x, + y: keyPair.jwk.y, + }, + }; + + const payload: Record = { + jti: crypto.randomUUID(), + htm: httpMethod, + htu: httpUri, + iat: Math.floor(Date.now() / 1000), + }; + + if (nonce) { + payload.nonce = nonce; + } + + if (accessTokenHash) { + payload.ath = accessTokenHash; + } + + const headerB64 = base64UrlEncode( + new TextEncoder().encode(JSON.stringify(header)), + ); + const payloadB64 = base64UrlEncode( + new TextEncoder().encode(JSON.stringify(payload)), + ); + + const signingInput = `${headerB64}.${payloadB64}`; + const signature = await crypto.subtle.sign( + { name: "ECDSA", hash: "SHA-256" }, + keyPair.privateKey, + new TextEncoder().encode(signingInput), + ); + + const signatureB64 = base64UrlEncode(new Uint8Array(signature)); + return `${headerB64}.${payloadB64}.${signatureB64}`; } diff --git a/frontend/src/lib/migration/flow.svelte.ts b/frontend/src/lib/migration/flow.svelte.ts index c21ea80..3dff981 100644 --- a/frontend/src/lib/migration/flow.svelte.ts +++ b/frontend/src/lib/migration/flow.svelte.ts @@ -2,19 +2,31 @@ import type { InboundMigrationState, InboundStep, MigrationProgress, + OAuthServerMetadata, OutboundMigrationState, OutboundStep, + PasskeyAccountSetup, ServerDescription, StoredMigrationState, } from "./types"; import { AtprotoClient, + buildOAuthAuthorizationUrl, + clearDPoPKey, createLocalClient, + exchangeOAuthCode, + generateDPoPKeyPair, + generateOAuthState, + generatePKCE, + getMigrationOAuthClientId, + getMigrationOAuthRedirectUri, + getOAuthServerMetadata, + loadDPoPKey, resolvePdsUrl, + saveDPoPKey, } from "./atproto-client"; import { clearMigrationState, - loadMigrationState, saveMigrationState, updateProgress, updateStep, @@ -63,14 +75,18 @@ export function createInboundMigrationFlow() { plcToken: "", progress: createInitialProgress(), error: null, - requires2FA: false, - twoFactorCode: "", targetVerificationMethod: null, + authMethod: "password", + passkeySetupToken: null, + oauthCodeVerifier: null, + generatedAppPassword: null, + generatedAppPasswordName: null, }); let sourceClient: AtprotoClient | null = null; let localClient: AtprotoClient | null = null; let localServerInfo: ServerDescription | null = null; + let sourceOAuthMetadata: OAuthServerMetadata | null = null; function setStep(step: InboundStep) { state.step = step; @@ -111,51 +127,162 @@ export function createInboundMigrationFlow() { } } - async function loginToSource( - handle: string, - password: string, - twoFactorCode?: string, - ): Promise { - migrationLog("loginToSource START", { handle, has2FA: !!twoFactorCode }); + async function initiateOAuthLogin(handle: string): Promise { + migrationLog("initiateOAuthLogin START", { handle }); if (!state.sourcePdsUrl) { await resolveSourcePds(handle); } - if (!sourceClient) { - sourceClient = new AtprotoClient(state.sourcePdsUrl); + const metadata = await getOAuthServerMetadata(state.sourcePdsUrl); + if (!metadata) { + throw new Error( + "Source PDS does not support OAuth. This PDS only supports OAuth-based migrations.", + ); + } + sourceOAuthMetadata = metadata; + + const { codeVerifier, codeChallenge } = await generatePKCE(); + const oauthState = generateOAuthState(); + + const dpopKeyPair = await generateDPoPKeyPair(); + await saveDPoPKey(dpopKeyPair); + + localStorage.setItem("migration_oauth_state", oauthState); + localStorage.setItem("migration_oauth_code_verifier", codeVerifier); + localStorage.setItem("migration_source_pds_url", state.sourcePdsUrl); + localStorage.setItem("migration_source_did", state.sourceDid); + localStorage.setItem("migration_source_handle", state.sourceHandle); + localStorage.setItem("migration_oauth_issuer", metadata.issuer); + + const authUrl = buildOAuthAuthorizationUrl(metadata, { + clientId: getMigrationOAuthClientId(), + redirectUri: getMigrationOAuthRedirectUri(), + codeChallenge, + state: oauthState, + scope: "atproto identity:* rpc:com.atproto.server.createAccount?aud=*", + dpopJkt: dpopKeyPair.thumbprint, + loginHint: state.sourceHandle, + }); + + migrationLog("initiateOAuthLogin: Redirecting to authorization", { + sourcePdsUrl: state.sourcePdsUrl, + authEndpoint: metadata.authorization_endpoint, + dpopJkt: dpopKeyPair.thumbprint, + }); + + state.oauthCodeVerifier = codeVerifier; + saveMigrationState(state); + + globalThis.location.href = authUrl; + } + + function cleanupOAuthSessionData(): void { + localStorage.removeItem("migration_oauth_state"); + localStorage.removeItem("migration_oauth_code_verifier"); + localStorage.removeItem("migration_source_pds_url"); + localStorage.removeItem("migration_source_did"); + localStorage.removeItem("migration_source_handle"); + localStorage.removeItem("migration_oauth_issuer"); + } + + async function handleOAuthCallback( + code: string, + returnedState: string, + ): Promise { + migrationLog("handleOAuthCallback START"); + + const savedState = localStorage.getItem("migration_oauth_state"); + const codeVerifier = localStorage.getItem("migration_oauth_code_verifier"); + const sourcePdsUrl = localStorage.getItem("migration_source_pds_url"); + const sourceDid = localStorage.getItem("migration_source_did"); + const sourceHandle = localStorage.getItem("migration_source_handle"); + const oauthIssuer = localStorage.getItem("migration_oauth_issuer"); + + if (returnedState !== savedState) { + cleanupOAuthSessionData(); + throw new Error("OAuth state mismatch - possible CSRF attack"); + } + + if (!codeVerifier || !sourcePdsUrl || !sourceDid || !sourceHandle) { + cleanupOAuthSessionData(); + throw new Error("Missing OAuth session data"); + } + + const dpopKeyPair = await loadDPoPKey(); + if (!dpopKeyPair) { + cleanupOAuthSessionData(); + throw new Error("Missing DPoP key - please restart the migration"); + } + + state.sourcePdsUrl = sourcePdsUrl; + state.sourceDid = sourceDid; + state.sourceHandle = sourceHandle; + sourceClient = new AtprotoClient(sourcePdsUrl); + + let metadata = await getOAuthServerMetadata(sourcePdsUrl); + if (!metadata && oauthIssuer) { + metadata = await getOAuthServerMetadata(oauthIssuer); + } + if (!metadata) { + cleanupOAuthSessionData(); + throw new Error("Could not fetch OAuth server metadata"); } + sourceOAuthMetadata = metadata; + migrationLog("handleOAuthCallback: Exchanging code for tokens"); + + let tokenResponse; try { - migrationLog("loginToSource: Calling createSession on OLD PDS", { - pdsUrl: state.sourcePdsUrl, - }); - const session = await sourceClient.login(handle, password, twoFactorCode); - migrationLog("loginToSource SUCCESS", { - did: session.did, - handle: session.handle, - pdsUrl: state.sourcePdsUrl, - }); - state.sourceAccessToken = session.accessJwt; - state.sourceRefreshToken = session.refreshJwt; - state.sourceDid = session.did; - state.sourceHandle = session.handle; - state.requires2FA = false; - saveMigrationState(state); - } catch (e) { - const err = e as Error & { error?: string }; - migrationLog("loginToSource FAILED", { - error: err.message, - errorCode: err.error, + tokenResponse = await exchangeOAuthCode(metadata, { + code, + codeVerifier, + clientId: getMigrationOAuthClientId(), + redirectUri: getMigrationOAuthRedirectUri(), + dpopKeyPair, }); - if (err.error === "AuthFactorTokenRequired") { - state.requires2FA = true; - throw new Error( - "Two-factor authentication required. Please enter the code sent to your email.", - ); + } catch (err) { + cleanupOAuthSessionData(); + throw err; + } + + migrationLog("handleOAuthCallback: Got access token"); + + state.sourceAccessToken = tokenResponse.access_token; + state.sourceRefreshToken = tokenResponse.refresh_token ?? null; + sourceClient.setAccessToken(tokenResponse.access_token); + sourceClient.setDPoPKeyPair(dpopKeyPair); + + cleanupOAuthSessionData(); + + if (state.needsReauth && state.resumeToStep) { + const targetStep = state.resumeToStep; + state.needsReauth = false; + state.resumeToStep = undefined; + + const postEmailSteps = [ + "plc-token", + "did-web-update", + "finalizing", + "app-password", + ]; + + if (postEmailSteps.includes(targetStep)) { + if (state.authMethod === "passkey" && state.passkeySetupToken) { + localClient = createLocalClient(); + setStep("passkey-setup"); + migrationLog("handleOAuthCallback: Resuming passkey flow at passkey-setup"); + } else { + setStep("email-verify"); + migrationLog("handleOAuthCallback: Resuming at email-verify for re-auth"); + } + } else { + setStep(targetStep); } - throw e; + } else { + setStep("choose-handle"); } + saveMigrationState(state); } async function checkHandleAvailability(handle: string): Promise { @@ -180,17 +307,20 @@ export function createInboundMigrationFlow() { await localClient.loginDeactivated(email, password); } + let passkeySetup: PasskeyAccountSetup | null = null; + async function startMigration(): Promise { migrationLog("startMigration START", { sourceDid: state.sourceDid, sourceHandle: state.sourceHandle, targetHandle: state.targetHandle, sourcePdsUrl: state.sourcePdsUrl, + authMethod: state.authMethod, }); if (!sourceClient || !state.sourceAccessToken) { - migrationLog("startMigration ERROR: Not logged in to source PDS"); - throw new Error("Not logged in to source PDS"); + migrationLog("startMigration ERROR: Not authenticated to source PDS"); + throw new Error("Not authenticated to source PDS"); } if (!localClient) { @@ -198,16 +328,16 @@ export function createInboundMigrationFlow() { } setStep("migrating"); - setProgress({ currentOperation: "Getting service auth token..." }); try { + setProgress({ currentOperation: "Getting service auth token..." }); migrationLog("startMigration: Loading local server info"); const serverInfo = await loadLocalServerInfo(); migrationLog("startMigration: Got server info", { serverDid: serverInfo.did, }); - migrationLog("startMigration: Getting service auth token from OLD PDS"); + migrationLog("startMigration: Getting service auth token from source PDS"); const { token } = await sourceClient.getServiceAuth( serverInfo.did, "com.atproto.server.createAccount", @@ -217,26 +347,51 @@ export function createInboundMigrationFlow() { setProgress({ currentOperation: "Creating account on new PDS..." }); - const accountParams = { - did: state.sourceDid, - handle: state.targetHandle, - email: state.targetEmail, - password: state.targetPassword, - inviteCode: state.inviteCode || undefined, - }; + if (state.authMethod === "passkey") { + const passkeyParams = { + did: state.sourceDid, + handle: state.targetHandle, + email: state.targetEmail, + inviteCode: state.inviteCode || undefined, + }; - migrationLog("startMigration: Creating account on NEW PDS", { - did: accountParams.did, - handle: accountParams.handle, - }); - const session = await localClient.createAccount(accountParams, token); - migrationLog("startMigration: Account created on NEW PDS", { - did: session.did, - }); - localClient.setAccessToken(session.accessJwt); + migrationLog("startMigration: Creating passkey account on NEW PDS", { + did: passkeyParams.did, + handle: passkeyParams.handle, + inviteCode: passkeyParams.inviteCode, + stateInviteCode: state.inviteCode, + }); + passkeySetup = await localClient.createPasskeyAccount(passkeyParams, token); + migrationLog("startMigration: Passkey account created on NEW PDS", { + did: passkeySetup.did, + hasAccessJwt: !!passkeySetup.accessJwt, + }); + state.passkeySetupToken = passkeySetup.setupToken; + if (passkeySetup.accessJwt) { + localClient.setAccessToken(passkeySetup.accessJwt); + } + } else { + const accountParams = { + did: state.sourceDid, + handle: state.targetHandle, + email: state.targetEmail, + password: state.targetPassword, + inviteCode: state.inviteCode || undefined, + }; + + migrationLog("startMigration: Creating account on NEW PDS", { + did: accountParams.did, + handle: accountParams.handle, + }); + const session = await localClient.createAccount(accountParams, token); + migrationLog("startMigration: Account created on NEW PDS", { + did: session.did, + }); + localClient.setAccessToken(session.accessJwt); + } setProgress({ currentOperation: "Exporting repository..." }); - migrationLog("startMigration: Exporting repo from OLD PDS"); + migrationLog("startMigration: Exporting repo from source PDS"); const exportStart = Date.now(); const car = await sourceClient.getRepo(state.sourceDid); migrationLog("startMigration: Repo exported", { @@ -320,7 +475,7 @@ export function createInboundMigrationFlow() { await localClient.uploadBlob(blobData, "application/octet-stream"); migrated++; setProgress({ blobsMigrated: migrated }); - } catch (e) { + } catch { state.progress.blobsFailed.push(blob.cid); } } @@ -336,8 +491,7 @@ export function createInboundMigrationFlow() { const prefs = await sourceClient.getPreferences(); await localClient.putPreferences(prefs); setProgress({ prefsMigrated: true }); - } catch { - } + } catch { /* optional, best-effort */ } } async function submitEmailVerifyToken( @@ -355,13 +509,21 @@ export function createInboundMigrationFlow() { await localClient.verifyToken(token, state.targetEmail); if (!sourceClient) { - setStep("source-login"); + setStep("source-handle"); setError( "Email verified! Please log in to your old account again to complete the migration.", ); return; } + if (state.authMethod === "passkey") { + migrationLog( + "submitEmailVerifyToken: Email verified, proceeding to passkey setup", + ); + setStep("passkey-setup"); + return; + } + if (localPassword) { setProgress({ currentOperation: "Authenticating to new PDS..." }); await localClient.loginDeactivated(state.targetEmail, localPassword); @@ -403,6 +565,10 @@ export function createInboundMigrationFlow() { if (checkingEmailVerification) return false; if (!sourceClient || !localClient) return false; + if (state.authMethod === "passkey") { + return false; + } + checkingEmailVerification = true; try { await localClient.loginDeactivated( @@ -460,7 +626,7 @@ export function createInboundMigrationFlow() { services: credentials.services, }); - migrationLog("Step 2: Signing PLC operation on OLD PDS", { + migrationLog("Step 2: Signing PLC operation on source PDS", { sourcePdsUrl: state.sourcePdsUrl, }); const signStart = Date.now(); @@ -497,13 +663,13 @@ export function createInboundMigrationFlow() { setProgress({ activated: true }); setProgress({ currentOperation: "Deactivating old account..." }); - migrationLog("Step 5: Deactivating account on OLD PDS", { + migrationLog("Step 5: Deactivating account on source PDS", { sourcePdsUrl: state.sourcePdsUrl, }); const deactivateStart = Date.now(); try { await sourceClient.deactivateAccount(); - migrationLog("Step 5 COMPLETE: Account deactivated on OLD PDS", { + migrationLog("Step 5 COMPLETE: Account deactivated on source PDS", { durationMs: Date.now() - deactivateStart, success: true, }); @@ -513,7 +679,7 @@ export function createInboundMigrationFlow() { error?: string; status?: number; }; - migrationLog("Step 5 FAILED: Could not deactivate on OLD PDS", { + migrationLog("Step 5 FAILED: Could not deactivate on source PDS", { durationMs: Date.now() - deactivateStart, error: err.message, errorCode: err.error, @@ -581,17 +747,17 @@ export function createInboundMigrationFlow() { setProgress({ activated: true }); setProgress({ currentOperation: "Deactivating old account..." }); - migrationLog("Deactivating account on OLD PDS"); + migrationLog("Deactivating account on source PDS"); const deactivateStart = Date.now(); try { await sourceClient.deactivateAccount(); - migrationLog("Account deactivated on OLD PDS", { + migrationLog("Account deactivated on source PDS", { durationMs: Date.now() - deactivateStart, }); setProgress({ deactivated: true }); } catch (deactivateErr) { const err = deactivateErr as Error & { error?: string }; - migrationLog("Could not deactivate on OLD PDS", { error: err.message }); + migrationLog("Could not deactivate on source PDS", { error: err.message }); } migrationLog("completeDidWebMigration SUCCESS"); @@ -607,6 +773,68 @@ export function createInboundMigrationFlow() { } } + async function startPasskeyRegistration(): Promise<{ options: unknown }> { + if (!localClient || !state.passkeySetupToken) { + throw new Error("Not ready for passkey registration"); + } + + migrationLog("startPasskeyRegistration START", { did: state.sourceDid }); + const result = await localClient.startPasskeyRegistrationForSetup( + state.sourceDid, + state.passkeySetupToken, + ); + migrationLog("startPasskeyRegistration: Got WebAuthn options"); + return result; + } + + async function completePasskeyRegistration( + credential: unknown, + friendlyName?: string, + ): Promise { + if (!localClient || !state.passkeySetupToken || !sourceClient) { + throw new Error("Not ready for passkey registration"); + } + + migrationLog("completePasskeyRegistration START", { did: state.sourceDid }); + + const result = await localClient.completePasskeySetup( + state.sourceDid, + state.passkeySetupToken, + credential, + friendlyName, + ); + migrationLog("completePasskeyRegistration: Passkey registered", { + appPassword: "***", + }); + + setProgress({ currentOperation: "Authenticating with app password..." }); + await localClient.loginDeactivated(state.targetEmail, result.appPassword); + migrationLog("completePasskeyRegistration: Authenticated to new PDS"); + + state.generatedAppPassword = result.appPassword; + state.generatedAppPasswordName = result.appPasswordName; + setStep("app-password"); + } + + async function proceedFromAppPassword(): Promise { + if (!sourceClient || !localClient) { + throw new Error("Clients not initialized"); + } + + migrationLog("proceedFromAppPassword: Starting"); + + if (state.sourceDid.startsWith("did:web:")) { + const credentials = await localClient.getRecommendedDidCredentials(); + state.targetVerificationMethod = + credentials.verificationMethods?.atproto || null; + setStep("did-web-update"); + } else { + setProgress({ currentOperation: "Requesting PLC operation token..." }); + await sourceClient.requestPlcOperationSignature(); + setStep("plc-token"); + } + } + function reset(): void { state = { direction: "inbound", @@ -625,12 +853,18 @@ export function createInboundMigrationFlow() { plcToken: "", progress: createInitialProgress(), error: null, - requires2FA: false, - twoFactorCode: "", targetVerificationMethod: null, + authMethod: "password", + passkeySetupToken: null, + oauthCodeVerifier: null, + generatedAppPassword: null, + generatedAppPasswordName: null, }; sourceClient = null; + passkeySetup = null; + sourceOAuthMetadata = null; clearMigrationState(); + clearDPoPKey(); } async function resumeFromState(stored: StoredMigrationState): Promise { @@ -641,12 +875,44 @@ export function createInboundMigrationFlow() { state.sourceHandle = stored.sourceHandle; state.targetHandle = stored.targetHandle; state.targetEmail = stored.targetEmail; + state.authMethod = stored.authMethod ?? "password"; state.progress = { ...createInitialProgress(), ...stored.progress, }; - state.step = "source-login"; + const stepsRequiringSourceAuth = [ + "choose-handle", + "review", + "migrating", + "email-verify", + "plc-token", + "did-web-update", + "finalizing", + "app-password", + ]; + + if (stepsRequiringSourceAuth.includes(stored.step)) { + state.step = "source-handle"; + state.needsReauth = true; + state.resumeToStep = stored.step as InboundMigrationState["step"]; + migrationLog("resumeFromState: Requiring re-auth for step", { + originalStep: stored.step, + }); + } else if (stored.step === "passkey-setup" && stored.passkeySetupToken) { + state.passkeySetupToken = stored.passkeySetupToken; + localClient = createLocalClient(); + state.step = "passkey-setup"; + migrationLog("resumeFromState: Restored passkey-setup with token"); + } else if (stored.step === "success") { + state.step = "success"; + } else if (stored.step === "error") { + state.step = "source-handle"; + state.needsReauth = true; + migrationLog("resumeFromState: Error state, requiring re-auth"); + } else { + state.step = stored.step as InboundMigrationState["step"]; + } } function getLocalSession(): @@ -666,10 +932,15 @@ export function createInboundMigrationFlow() { get state() { return state; }, + get passkeySetup() { + return passkeySetup; + }, setStep, setError, loadLocalServerInfo, - loginToSource, + resolveSourcePds, + initiateOAuthLogin, + handleOAuthCallback, authenticateToLocal, checkHandleAvailability, startMigration, @@ -680,6 +951,9 @@ export function createInboundMigrationFlow() { submitPlcToken, resendPlcToken, completeDidWebMigration, + startPasskeyRegistration, + completePasskeyRegistration, + proceedFromAppPassword, reset, resumeFromState, getLocalSession, @@ -856,7 +1130,7 @@ export function createOutboundMigrationFlow() { await targetClient.uploadBlob(blobData, "application/octet-stream"); migrated++; setProgress({ blobsMigrated: migrated }); - } catch (e) { + } catch { state.progress.blobsFailed.push(blob.cid); } } @@ -872,8 +1146,7 @@ export function createOutboundMigrationFlow() { const prefs = await localClient.getPreferences(); await targetClient.putPreferences(prefs); setProgress({ prefsMigrated: true }); - } catch { - } + } catch { /* optional, best-effort */ } } async function submitPlcToken(token: string): Promise { @@ -908,8 +1181,7 @@ export function createOutboundMigrationFlow() { try { await localClient.deactivateAccount(state.targetPdsUrl); setProgress({ deactivated: true }); - } catch { - } + } catch { /* optional, best-effort */ } setStep("success"); clearMigrationState(); diff --git a/frontend/src/lib/migration/storage.ts b/frontend/src/lib/migration/storage.ts index 1007bd3..078c334 100644 --- a/frontend/src/lib/migration/storage.ts +++ b/frontend/src/lib/migration/storage.ts @@ -3,6 +3,7 @@ import type { MigrationState, StoredMigrationState, } from "./types"; +import { clearDPoPKey } from "./atproto-client"; const STORAGE_KEY = "tranquil_migration_state"; const MAX_AGE_MS = 24 * 60 * 60 * 1000; @@ -15,14 +16,18 @@ export function saveMigrationState(state: MigrationState): void { startedAt: new Date().toISOString(), sourcePdsUrl: state.direction === "inbound" ? state.sourcePdsUrl - : window.location.origin, + : globalThis.location.origin, targetPdsUrl: state.direction === "inbound" - ? window.location.origin + ? globalThis.location.origin : state.targetPdsUrl, sourceDid: state.direction === "inbound" ? state.sourceDid : "", sourceHandle: state.direction === "inbound" ? state.sourceHandle : "", targetHandle: state.targetHandle, targetEmail: state.targetEmail, + authMethod: state.direction === "inbound" ? state.authMethod : undefined, + passkeySetupToken: state.direction === "inbound" + ? state.passkeySetupToken ?? undefined + : undefined, progress: { repoExported: state.progress.repoExported, repoImported: state.progress.repoImported, @@ -36,19 +41,21 @@ export function saveMigrationState(state: MigrationState): void { }; try { - sessionStorage.setItem(STORAGE_KEY, JSON.stringify(storedState)); - } catch { - } + localStorage.setItem(STORAGE_KEY, JSON.stringify(storedState)); + } catch { /* localStorage unavailable */ } } export function loadMigrationState(): StoredMigrationState | null { try { - const stored = sessionStorage.getItem(STORAGE_KEY); + const stored = localStorage.getItem(STORAGE_KEY); if (!stored) return null; const state = JSON.parse(stored) as StoredMigrationState; - if (state.version !== 1) return null; + if (state.version !== 1) { + clearMigrationState(); + return null; + } const startedAt = new Date(state.startedAt).getTime(); if (Date.now() - startedAt > MAX_AGE_MS) { @@ -58,15 +65,16 @@ export function loadMigrationState(): StoredMigrationState | null { return state; } catch { + clearMigrationState(); return null; } } export function clearMigrationState(): void { try { - sessionStorage.removeItem(STORAGE_KEY); - } catch { - } + localStorage.removeItem(STORAGE_KEY); + clearDPoPKey(); + } catch { /* localStorage unavailable */ } } export function hasPendingMigration(): boolean { @@ -79,6 +87,8 @@ export function getResumeInfo(): { targetHandle: string; sourcePdsUrl: string; targetPdsUrl: string; + targetEmail: string; + authMethod?: "password" | "passkey"; progressSummary: string; step: string; } | null { @@ -102,6 +112,8 @@ export function getResumeInfo(): { targetHandle: state.targetHandle, sourcePdsUrl: state.sourcePdsUrl, targetPdsUrl: state.targetPdsUrl, + targetEmail: state.targetEmail, + authMethod: state.authMethod, progressSummary: progressParts.length > 0 ? progressParts.join(", ") : "just started", @@ -117,9 +129,8 @@ export function updateProgress( state.progress = { ...state.progress, ...updates }; try { - sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state)); - } catch { - } + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } catch { /* localStorage unavailable */ } } export function updateStep(step: string): void { @@ -128,9 +139,8 @@ export function updateStep(step: string): void { state.step = step; try { - sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state)); - } catch { - } + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } catch { /* localStorage unavailable */ } } export function setError(error: string, step: string): void { @@ -140,7 +150,6 @@ export function setError(error: string, step: string): void { state.lastError = error; state.lastErrorStep = step; try { - sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state)); - } catch { - } + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } catch { /* localStorage unavailable */ } } diff --git a/frontend/src/lib/migration/types.ts b/frontend/src/lib/migration/types.ts index e920e8e..de8a2bd 100644 --- a/frontend/src/lib/migration/types.ts +++ b/frontend/src/lib/migration/types.ts @@ -1,9 +1,11 @@ export type InboundStep = | "welcome" - | "source-login" + | "source-handle" | "choose-handle" | "review" | "migrating" + | "passkey-setup" + | "app-password" | "email-verify" | "plc-token" | "did-web-update" @@ -11,6 +13,8 @@ export type InboundStep = | "success" | "error"; +export type AuthMethod = "password" | "passkey"; + export type OutboundStep = | "welcome" | "target-pds" @@ -54,9 +58,14 @@ export interface InboundMigrationState { plcToken: string; progress: MigrationProgress; error: string | null; - requires2FA: boolean; - twoFactorCode: string; targetVerificationMethod: string | null; + authMethod: AuthMethod; + passkeySetupToken: string | null; + oauthCodeVerifier: string | null; + generatedAppPassword: string | null; + generatedAppPasswordName: string | null; + needsReauth?: boolean; + resumeToStep?: InboundStep; } export interface OutboundMigrationState { @@ -92,6 +101,8 @@ export interface StoredMigrationState { sourceHandle: string; targetHandle: string; targetEmail: string; + authMethod?: AuthMethod; + passkeySetupToken?: string; progress: { repoExported: boolean; repoImported: boolean; @@ -199,6 +210,51 @@ export interface CreateAccountParams { recoveryKey?: string; } +export interface CreatePasskeyAccountParams { + did?: string; + handle: string; + email: string; + inviteCode?: string; +} + +export interface PasskeyAccountSetup { + setupToken: string; + did: string; + handle: string; + setupExpiresAt: string; + accessJwt?: string; +} + +export interface CompletePasskeySetupResponse { + did: string; + handle: string; + appPassword: string; + appPasswordName: string; +} + +export interface StartPasskeyRegistrationResponse { + options: unknown; +} + +export interface OAuthServerMetadata { + issuer: string; + authorization_endpoint: string; + token_endpoint: string; + scopes_supported?: string[]; + response_types_supported?: string[]; + grant_types_supported?: string[]; + code_challenge_methods_supported?: string[]; + dpop_signing_alg_values_supported?: string[]; +} + +export interface OAuthTokenResponse { + access_token: string; + token_type: string; + expires_in?: number; + refresh_token?: string; + scope?: string; +} + export interface Preferences { preferences: unknown[]; } @@ -214,3 +270,13 @@ export class MigrationError extends Error { this.name = "MigrationError"; } } + +export function getErrorMessage(err: unknown): string { + if (err instanceof Error) { + return err.message; + } + if (typeof err === "string") { + return err; + } + return String(err); +} diff --git a/frontend/src/lib/oauth.ts b/frontend/src/lib/oauth.ts index aaa61ac..dc47896 100644 --- a/frontend/src/lib/oauth.ts +++ b/frontend/src/lib/oauth.ts @@ -8,9 +8,9 @@ const SCOPES = [ "blob:*/*", ].join(" "); const CLIENT_ID = !(import.meta.env.DEV) - ? `${window.location.origin}/oauth/client-metadata.json` + ? `${globalThis.location.origin}/oauth/client-metadata.json` : `http://localhost/?scope=${SCOPES}`; -const REDIRECT_URI = `${window.location.origin}/`; +const REDIRECT_URI = `${globalThis.location.origin}/`; interface OAuthState { state: string; @@ -106,11 +106,11 @@ export async function startOAuthLogin(): Promise { const { request_uri } = await parResponse.json(); - const authorizeUrl = new URL("/oauth/authorize", window.location.origin); + const authorizeUrl = new URL("/oauth/authorize", globalThis.location.origin); authorizeUrl.searchParams.set("client_id", CLIENT_ID); authorizeUrl.searchParams.set("request_uri", request_uri); - window.location.href = authorizeUrl.toString(); + globalThis.location.href = authorizeUrl.toString(); } export interface OAuthTokens { @@ -191,7 +191,11 @@ export async function refreshOAuthToken( export function checkForOAuthCallback(): | { code: string; state: string } | null { - const params = new URLSearchParams(window.location.search); + if (globalThis.location.hash === "#/migrate") { + return null; + } + + const params = new URLSearchParams(globalThis.location.search); const code = params.get("code"); const state = params.get("state"); @@ -203,7 +207,7 @@ export function checkForOAuthCallback(): } export function clearOAuthCallbackParams(): void { - const url = new URL(window.location.href); + const url = new URL(globalThis.location.href); url.search = ""; - window.history.replaceState({}, "", url.toString()); + globalThis.history.replaceState({}, "", url.toString()); } diff --git a/frontend/src/lib/registration/flow.svelte.ts b/frontend/src/lib/registration/flow.svelte.ts index bca3317..7a2ca4b 100644 --- a/frontend/src/lib/registration/flow.svelte.ts +++ b/frontend/src/lib/registration/flow.svelte.ts @@ -104,7 +104,7 @@ export function createRegistrationFlow( state.externalDidWeb.reservedSigningKey = result.signingKey; publicKeyMultibase = result.signingKey.replace("did:key:", ""); } else { - const keypair = await generateKeypair(); + const keypair = generateKeypair(); state.externalDidWeb.byodPrivateKey = keypair.privateKey; state.externalDidWeb.byodPublicKeyMultibase = keypair.publicKeyMultibase; diff --git a/frontend/src/lib/router.svelte.ts b/frontend/src/lib/router.svelte.ts index ad95b35..2fdc535 100644 --- a/frontend/src/lib/router.svelte.ts +++ b/frontend/src/lib/router.svelte.ts @@ -1,5 +1,5 @@ let currentPath = $state( - getPathWithoutQuery(window.location.hash.slice(1) || "/"), + getPathWithoutQuery(globalThis.location.hash.slice(1) || "/"), ); function getPathWithoutQuery(hash: string): string { @@ -7,13 +7,13 @@ function getPathWithoutQuery(hash: string): string { return queryIndex === -1 ? hash : hash.slice(0, queryIndex); } -window.addEventListener("hashchange", () => { - currentPath = getPathWithoutQuery(window.location.hash.slice(1) || "/"); +globalThis.addEventListener("hashchange", () => { + currentPath = getPathWithoutQuery(globalThis.location.hash.slice(1) || "/"); }); export function navigate(path: string) { currentPath = path; - window.location.hash = path; + globalThis.location.hash = path; } export function getCurrentPath() { diff --git a/frontend/src/lib/serverConfig.svelte.ts b/frontend/src/lib/serverConfig.svelte.ts index 640e0c7..c3965ff 100644 --- a/frontend/src/lib/serverConfig.svelte.ts +++ b/frontend/src/lib/serverConfig.svelte.ts @@ -74,7 +74,7 @@ export async function initServerConfig(): Promise { if (initialized) return; initialized = true; - darkModeQuery = window.matchMedia("(prefers-color-scheme: dark)"); + darkModeQuery = globalThis.matchMedia("(prefers-color-scheme: dark)"); darkModeQuery.addEventListener("change", applyColors); try { diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 806ea9a..a487be9 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -902,6 +902,10 @@ "reauth": { "title": "Re-authentication Required", "subtitle": "Please verify your identity to continue.", + "password": "Password", + "totp": "TOTP", + "passkey": "Passkey", + "authenticatorCode": "Authenticator Code", "usePassword": "Use Password", "usePasskey": "Use Passkey", "useTotp": "Use Authenticator", @@ -909,6 +913,8 @@ "totpPlaceholder": "Enter 6-digit code", "verify": "Verify", "verifying": "Verifying...", + "authenticating": "Authenticating...", + "passkeyPrompt": "Click the button below to authenticate with your passkey.", "cancel": "Cancel" }, "delegation": { @@ -1071,6 +1077,10 @@ "beforeMigrate4": "Your old PDS will be notified to deactivate your account", "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.", "learnMore": "Learn more about migration risks", + "comingSoon": "Coming soon", + "oauthCompleting": "Completing authentication...", + "oauthFailed": "Authentication Failed", + "tryAgain": "Try Again", "resume": { "title": "Resume Migration?", "incomplete": "You have an incomplete migration in progress:", @@ -1090,44 +1100,85 @@ "desc": "Move your existing AT Protocol account to this server.", "understand": "I understand the risks and want to proceed" }, - "sourceLogin": { - "title": "Sign In to Your Current PDS", - "desc": "Enter your credentials for the account you want to migrate.", + "sourceAuth": { + "title": "Enter Your Current Handle", + "titleResume": "Resume Migration", + "desc": "Enter the handle of the account you want to migrate.", + "descResume": "Re-authenticate to your source PDS to continue the migration.", "handle": "Handle", - "handlePlaceholder": "you.bsky.social", - "password": "Password", - "twoFactorCode": "Two-Factor Code", - "twoFactorRequired": "Two-factor authentication required", - "signIn": "Sign In & Continue" + "handlePlaceholder": "alice.bsky.social", + "handleHint": "Your current handle on your existing PDS", + "continue": "Continue", + "connecting": "Connecting...", + "reauthenticate": "Re-authenticate", + "resumeTitle": "Migration in Progress", + "resumeFrom": "From", + "resumeTo": "To", + "resumeProgress": "Progress", + "resumeOAuthNote": "You need to re-authenticate via OAuth to continue." }, "chooseHandle": { "title": "Choose Your New Handle", "desc": "Select a handle for your account on this PDS.", - "handleHint": "Your full handle will be: @{handle}" + "migratingFrom": "Migrating from", + "newHandle": "New Handle", + "checkingAvailability": "Checking availability...", + "handleAvailable": "Handle is available!", + "handleTaken": "Handle is already taken", + "handleHint": "You can also use your own domain by entering the full handle (e.g., alice.mydomain.com)", + "email": "Email Address", + "authMethod": "Authentication Method", + "authPassword": "Password", + "authPasswordDesc": "Traditional password-based login", + "authPasskey": "Passkey", + "authPasskeyDesc": "Passwordless login using biometrics or security key", + "password": "Password", + "passwordHint": "At least 8 characters", + "passkeyInfo": "You'll set up a passkey after your account is created. Your device will prompt you to use biometrics (fingerprint, Face ID) or a security key.", + "inviteCode": "Invite Code" }, "review": { "title": "Review Migration", - "desc": "Please review and confirm your migration details.", + "desc": "Please confirm the details of your migration.", "currentHandle": "Current Handle", "newHandle": "New Handle", - "sourcePds": "Source PDS", - "targetPds": "This PDS", + "did": "DID", + "sourcePds": "From PDS", + "targetPds": "To PDS", "email": "Email", + "authentication": "Authentication", + "authPasskey": "Passkey (passwordless)", + "authPassword": "Password", "inviteCode": "Invite Code", - "confirm": "I confirm I want to migrate my account", - "startMigration": "Start Migration" + "warning": "After you click \"Start Migration\", your repository and data will begin transferring. This process cannot be easily undone.", + "startMigration": "Start Migration", + "starting": "Starting..." }, "migrating": { - "title": "Migrating Your Account", - "desc": "Please wait while we transfer your data...", - "gettingServiceAuth": "Getting service authorization...", - "creatingAccount": "Creating account on new PDS...", - "exportingRepo": "Exporting repository...", - "importingRepo": "Importing repository...", - "countingBlobs": "Counting blobs...", - "migratingBlobs": "Migrating blobs ({current}/{total})...", - "migratingPrefs": "Migrating preferences...", - "requestingPlc": "Requesting PLC operation..." + "title": "Migration in Progress", + "desc": "Please wait while your account is being transferred...", + "exportRepo": "Export repository", + "importRepo": "Import repository", + "migrateBlobs": "Migrate blobs", + "migratePrefs": "Migrate preferences" + }, + "passkeySetup": { + "title": "Set Up Your Passkey", + "desc": "Your email has been verified. Now set up your passkey for secure, passwordless login.", + "nameLabel": "Passkey Name (optional)", + "namePlaceholder": "e.g., MacBook Pro, iPhone", + "nameHint": "A friendly name to identify this passkey", + "instructions": "Click the button below to register your passkey. Your device will prompt you to use biometrics (fingerprint, Face ID) or a security key.", + "register": "Register Passkey", + "registering": "Registering..." + }, + "appPassword": { + "title": "Save Your App Password", + "desc": "Your passkey has been created. An app password has been generated for you to use with apps that don't support passkeys yet.", + "warning": "This app password is required to sign into apps that don't support passkeys yet (like bsky.app). You will only see this password once.", + "label": "App Password for", + "saved": "I have saved my app password in a secure location", + "continue": "Continue" }, "emailVerify": { "title": "Verify Your Email", @@ -1140,12 +1191,14 @@ "verifying": "Verifying..." }, "plcToken": { - "title": "Verify Your Identity", - "desc": "A verification code has been sent to your email on your current PDS.", - "tokenLabel": "Verification Token", - "tokenPlaceholder": "Enter the token from your email", - "resend": "Resend Token", - "resending": "Resending..." + "title": "Verify Migration", + "desc": "A verification code has been sent to the email registered with your old account.", + "info": "This code confirms you have access to the account and authorizes updating your identity to point to this PDS.", + "tokenLabel": "Verification Code", + "tokenPlaceholder": "Enter code from email", + "resend": "Resend Code", + "complete": "Complete Migration", + "completing": "Verifying..." }, "didWebUpdate": { "title": "Update Your DID Document", @@ -1168,9 +1221,30 @@ "success": { "title": "Migration Complete!", "desc": "Your account has been successfully migrated to this PDS.", - "newHandle": "New Handle", + "yourNewHandle": "Your new handle", "did": "DID", - "goToDashboard": "Go to Dashboard" + "blobsWarning": "{count} blobs could not be migrated. These may be images or other media that are no longer available.", + "redirecting": "Redirecting to dashboard..." + }, + "error": { + "title": "Migration Error", + "desc": "An error occurred during migration.", + "startOver": "Start Over" + }, + "common": { + "back": "Back", + "cancel": "Cancel", + "continue": "Continue", + "whatWillHappen": "What will happen:", + "step1": "Log in to your current PDS", + "step2": "Choose your new handle on this server", + "step3": "Your repository and blobs will be transferred", + "step4": "Verify the migration via email", + "step5": "Your identity will be updated to point here", + "beforeProceed": "Before you proceed:", + "warning1": "You need access to the email registered with your current account", + "warning2": "Large accounts may take several minutes to transfer", + "warning3": "Your old account will be deactivated after migration" } }, "outbound": { diff --git a/frontend/src/locales/fi.json b/frontend/src/locales/fi.json index 42e6adf..fb4b976 100644 --- a/frontend/src/locales/fi.json +++ b/frontend/src/locales/fi.json @@ -902,6 +902,10 @@ "reauth": { "title": "Uudelleentodennus vaaditaan", "subtitle": "Vahvista henkilöllisyytesi jatkaaksesi.", + "password": "Salasana", + "totp": "TOTP", + "passkey": "Pääsyavain", + "authenticatorCode": "Todentajan koodi", "usePassword": "Käytä salasanaa", "usePasskey": "Käytä pääsyavainta", "useTotp": "Käytä todentajaa", @@ -909,6 +913,8 @@ "totpPlaceholder": "Syötä 6-numeroinen koodi", "verify": "Vahvista", "verifying": "Vahvistetaan...", + "authenticating": "Todennetaan...", + "passkeyPrompt": "Klikkaa alla olevaa painiketta todentaaksesi pääsyavaimellasi.", "cancel": "Peruuta" }, "verifyChannel": { @@ -1071,6 +1077,10 @@ "beforeMigrate4": "Vanhalle PDS:llesi ilmoitetaan tilisi deaktivoinnista", "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ä.", "learnMore": "Lue lisää siirron riskeistä", + "comingSoon": "Tulossa pian", + "oauthCompleting": "Viimeistellään todennusta...", + "oauthFailed": "Todennus epäonnistui", + "tryAgain": "Yritä uudelleen", "resume": { "title": "Jatka siirtoa?", "incomplete": "Sinulla on keskeneräinen siirto:", @@ -1090,44 +1100,85 @@ "desc": "Siirrä olemassa oleva AT Protocol -tilisi tälle palvelimelle.", "understand": "Ymmärrän riskit ja haluan jatkaa" }, - "sourceLogin": { - "title": "Kirjaudu nykyiseen PDS:ääsi", - "desc": "Syötä siirrettävän tilin tunnukset.", + "sourceAuth": { + "title": "Syötä nykyinen käyttäjätunnuksesi", + "titleResume": "Jatka siirtoa", + "desc": "Syötä siirrettävän tilin käyttäjätunnus.", + "descResume": "Tunnistaudu uudelleen lähde-PDS:ään jatkaaksesi siirtoa.", "handle": "Käyttäjätunnus", - "handlePlaceholder": "sinä.bsky.social", - "password": "Salasana", - "twoFactorCode": "Kaksivaiheinen koodi", - "twoFactorRequired": "Kaksivaiheinen tunnistautuminen vaaditaan", - "signIn": "Kirjaudu ja jatka" + "handlePlaceholder": "maija.bsky.social", + "handleHint": "Nykyinen käyttäjätunnuksesi nykyisessä PDS:ssäsi", + "continue": "Jatka", + "connecting": "Yhdistetään...", + "reauthenticate": "Tunnistaudu uudelleen", + "resumeTitle": "Siirto käynnissä", + "resumeFrom": "Mistä", + "resumeTo": "Minne", + "resumeProgress": "Edistyminen", + "resumeOAuthNote": "Sinun täytyy tunnistautua uudelleen OAuth:n kautta jatkaaksesi." }, "chooseHandle": { "title": "Valitse uusi käyttäjätunnuksesi", "desc": "Valitse käyttäjätunnus tilillesi tässä PDS:ssä.", - "handleHint": "Täydellinen käyttäjätunnuksesi on: @{handle}" + "migratingFrom": "Siirretään tililtä", + "newHandle": "Uusi käyttäjätunnus", + "checkingAvailability": "Tarkistetaan saatavuutta...", + "handleAvailable": "Käyttäjätunnus on saatavilla!", + "handleTaken": "Käyttäjätunnus on jo varattu", + "handleHint": "Voit myös käyttää omaa verkkotunnustasi syöttämällä täydellisen käyttäjätunnuksen (esim. maija.omadomain.fi)", + "email": "Sähköpostiosoite", + "authMethod": "Tunnistautumistapa", + "authPassword": "Salasana", + "authPasswordDesc": "Perinteinen salasanapohjainen kirjautuminen", + "authPasskey": "Pääsyavain", + "authPasskeyDesc": "Salasanaton kirjautuminen biometriikalla tai suojausavaimella", + "password": "Salasana", + "passwordHint": "Vähintään 8 merkkiä", + "passkeyInfo": "Määrität pääsyavaimen tilisi luomisen jälkeen. Laitteesi pyytää käyttämään biometriikkaa (sormenjälki, Face ID) tai suojausavainta.", + "inviteCode": "Kutsukoodi" }, "review": { "title": "Tarkista siirto", - "desc": "Tarkista ja vahvista siirtotietosi.", + "desc": "Vahvista siirtosi tiedot.", "currentHandle": "Nykyinen käyttäjätunnus", "newHandle": "Uusi käyttäjätunnus", + "did": "DID", "sourcePds": "Lähde-PDS", - "targetPds": "Tämä PDS", + "targetPds": "Kohde-PDS", "email": "Sähköposti", + "authentication": "Tunnistautuminen", + "authPasskey": "Pääsyavain (salasanaton)", + "authPassword": "Salasana", "inviteCode": "Kutsukoodi", - "confirm": "Vahvistan haluavani siirtää tilini", - "startMigration": "Aloita siirto" + "warning": "Kun klikkaat \"Aloita siirto\", tietovarastosi ja datasi alkavat siirtyä. Tätä prosessia ei voi helposti peruuttaa.", + "startMigration": "Aloita siirto", + "starting": "Aloitetaan..." }, "migrating": { - "title": "Siirretään tiliäsi", - "desc": "Odota, kun siirrämme tietojasi...", - "gettingServiceAuth": "Haetaan palveluvaltuutusta...", - "creatingAccount": "Luodaan tiliä uuteen PDS:ään...", - "exportingRepo": "Viedään tietovarastoa...", - "importingRepo": "Tuodaan tietovarastoa...", - "countingBlobs": "Lasketaan blob-tiedostoja...", - "migratingBlobs": "Siirretään blob-tiedostoja ({current}/{total})...", - "migratingPrefs": "Siirretään asetuksia...", - "requestingPlc": "Pyydetään PLC-toimintoa..." + "title": "Siirto käynnissä", + "desc": "Odota, kun tiliäsi siirretään...", + "exportRepo": "Vie tietovarasto", + "importRepo": "Tuo tietovarasto", + "migrateBlobs": "Siirrä blob-tiedostot", + "migratePrefs": "Siirrä asetukset" + }, + "passkeySetup": { + "title": "Määritä pääsyavaimesi", + "desc": "Sähköpostisi on vahvistettu. Määritä nyt pääsyavaimesi turvallista, salasanatonta kirjautumista varten.", + "nameLabel": "Pääsyavaimen nimi (valinnainen)", + "namePlaceholder": "esim. MacBook Pro, iPhone", + "nameHint": "Kutsumanimi tämän pääsyavaimen tunnistamiseen", + "instructions": "Klikkaa alla olevaa painiketta rekisteröidäksesi pääsyavaimesi. Laitteesi pyytää käyttämään biometriikkaa (sormenjälki, Face ID) tai suojausavainta.", + "register": "Rekisteröi pääsyavain", + "registering": "Rekisteröidään..." + }, + "appPassword": { + "title": "Tallenna sovellussalasanasi", + "desc": "Pääsyavaimesi on luotu. Sovellussalasana on luotu sinulle käytettäväksi sovellusten kanssa, jotka eivät vielä tue pääsyavaimia.", + "warning": "Tämä sovellussalasana vaaditaan kirjautumiseen sovelluksissa, jotka eivät vielä tue pääsyavaimia (kuten bsky.app). Näet tämän salasanan vain kerran.", + "label": "Sovellussalasana kohteelle", + "saved": "Olen tallentanut sovellussalasanani turvalliseen paikkaan", + "continue": "Jatka" }, "emailVerify": { "title": "Vahvista sähköpostisi", @@ -1140,12 +1191,14 @@ "verifying": "Vahvistetaan..." }, "plcToken": { - "title": "Vahvista henkilöllisyytesi", - "desc": "Vahvistuskoodi on lähetetty sähköpostiisi nykyisessä PDS:ssäsi.", + "title": "Vahvista siirto", + "desc": "Vahvistuskoodi on lähetetty vanhaan tiliisi rekisteröityyn sähköpostiin.", + "info": "Tämä koodi vahvistaa, että sinulla on pääsy tiliin ja valtuuttaa identiteettisi päivityksen osoittamaan tähän PDS:ään.", "tokenLabel": "Vahvistuskoodi", "tokenPlaceholder": "Syötä sähköpostista saatu koodi", - "resend": "Lähetä uudelleen", - "resending": "Lähetetään..." + "resend": "Lähetä koodi uudelleen", + "complete": "Viimeistele siirto", + "completing": "Vahvistetaan..." }, "didWebUpdate": { "title": "Päivitä DID-dokumenttisi", @@ -1168,9 +1221,30 @@ "success": { "title": "Siirto valmis!", "desc": "Tilisi on siirretty onnistuneesti tähän PDS:ään.", - "newHandle": "Uusi käyttäjätunnus", + "yourNewHandle": "Uusi käyttäjätunnuksesi", "did": "DID", - "goToDashboard": "Siirry hallintapaneeliin" + "blobsWarning": "{count} blob-tiedostoa ei voitu siirtää. Nämä voivat olla kuvia tai muuta mediaa, jotka eivät ole enää saatavilla.", + "redirecting": "Uudelleenohjataan hallintapaneeliin..." + }, + "error": { + "title": "Siirtovirhe", + "desc": "Siirron aikana tapahtui virhe.", + "startOver": "Aloita alusta" + }, + "common": { + "back": "Takaisin", + "cancel": "Peruuta", + "continue": "Jatka", + "whatWillHappen": "Mitä tapahtuu:", + "step1": "Kirjaudu nykyiseen PDS:ääsi", + "step2": "Valitse uusi käyttäjätunnus tällä palvelimella", + "step3": "Tietovarastosi ja blob-tiedostosi siirretään", + "step4": "Vahvista siirto sähköpostilla", + "step5": "Identiteettisi päivitetään osoittamaan tänne", + "beforeProceed": "Ennen kuin jatkat:", + "warning1": "Tarvitset pääsyn nykyiseen tiliisi rekisteröityyn sähköpostiin", + "warning2": "Suurten tilien siirto voi kestää useita minuutteja", + "warning3": "Vanha tilisi deaktivoidaan siirron jälkeen" } }, "outbound": { diff --git a/frontend/src/locales/ja.json b/frontend/src/locales/ja.json index 6f55e68..246bc86 100644 --- a/frontend/src/locales/ja.json +++ b/frontend/src/locales/ja.json @@ -189,19 +189,31 @@ "title": "DID ドキュメントエディター", "preview": "現在の DID ドキュメント", "verificationMethods": "検証方法(署名キー)", + "verificationMethodsDesc": "DIDの代わりに動作できる署名キー。新しいPDSに移行する際は、そのPDSの署名キーをここに追加してください。", "addKey": "キーを追加", "removeKey": "削除", "keyId": "キー ID", "keyIdPlaceholder": "#atproto", "publicKey": "公開キー(Multibase)", "publicKeyPlaceholder": "zQ3sh...", + "noKeys": "検証方法が設定されていません。ローカルPDSキーを使用しています。", "alsoKnownAs": "別名(ハンドル)", + "alsoKnownAsDesc": "DIDを指すハンドル。新しいPDSでハンドルが変更されたら更新してください。", "addHandle": "ハンドルを追加", + "removeHandle": "削除", + "handle": "ハンドル", "handlePlaceholder": "at://handle.pds.com", - "serviceEndpoint": "サービスエンドポイント(現在の PDS)", + "noHandles": "ハンドルが設定されていません。ローカルハンドルを使用しています。", + "serviceEndpoint": "サービスエンドポイント", + "serviceEndpointDesc": "アカウントデータを現在ホストしているPDS。移行時に更新してください。", + "currentPds": "現在のPDS URL", "save": "変更を保存", "saving": "保存中...", "success": "DID ドキュメントを更新しました", + "saveFailed": "DIDドキュメントの保存に失敗しました", + "loadFailed": "DIDドキュメントの読み込みに失敗しました", + "invalidMultibase": "公開キーは'z'で始まる有効なmultibase文字列である必要があります", + "invalidHandle": "ハンドルはat:// URIである必要があります(例:at://handle.example.com)", "helpTitle": "これは何ですか?", "helpText": "別の PDS に移行すると、その PDS が新しい署名キーを生成します。ここで DID ドキュメントを更新して、新しいキーと場所を指すようにしてください。" }, @@ -890,6 +902,10 @@ "reauth": { "title": "再認証が必要です", "subtitle": "続行するには本人確認を行ってください。", + "password": "パスワード", + "totp": "TOTP", + "passkey": "パスキー", + "authenticatorCode": "認証コード", "usePassword": "パスワードを使用", "usePasskey": "パスキーを使用", "useTotp": "認証アプリを使用", @@ -897,6 +913,8 @@ "totpPlaceholder": "6桁のコードを入力", "verify": "確認", "verifying": "確認中...", + "authenticating": "認証中...", + "passkeyPrompt": "下のボタンをクリックしてパスキーで認証してください。", "cancel": "キャンセル" }, "verifyChannel": { @@ -1059,6 +1077,10 @@ "beforeMigrate4": "古いPDSにアカウントの無効化が通知されます", "importantWarning": "アカウント移行は重要な操作です。移行先のPDSを信頼し、データが移動されることを理解してください。問題が発生した場合、手動での復旧が必要になる可能性があります。", "learnMore": "移行のリスクについて詳しく", + "comingSoon": "近日公開", + "oauthCompleting": "認証を完了しています...", + "oauthFailed": "認証に失敗しました", + "tryAgain": "再試行", "resume": { "title": "移行を再開しますか?", "incomplete": "未完了の移行があります:", @@ -1078,44 +1100,85 @@ "desc": "既存のAT Protocolアカウントをこのサーバーに移動します。", "understand": "リスクを理解し、続行します" }, - "sourceLogin": { - "title": "現在のPDSにサインイン", - "desc": "移行するアカウントの認証情報を入力してください。", + "sourceAuth": { + "title": "現在のハンドルを入力", + "titleResume": "移行を再開", + "desc": "移行するアカウントのハンドルを入力してください。", + "descResume": "移行を続行するには、元のPDSに再認証してください。", "handle": "ハンドル", - "handlePlaceholder": "you.bsky.social", - "password": "パスワード", - "twoFactorCode": "2要素認証コード", - "twoFactorRequired": "2要素認証が必要です", - "signIn": "サインインして続行" + "handlePlaceholder": "alice.bsky.social", + "handleHint": "現在のPDSでのハンドル", + "continue": "続行", + "connecting": "接続中...", + "reauthenticate": "再認証", + "resumeTitle": "移行中", + "resumeFrom": "移行元", + "resumeTo": "移行先", + "resumeProgress": "進行状況", + "resumeOAuthNote": "続行するにはOAuthで再認証が必要です。" }, "chooseHandle": { "title": "新しいハンドルを選択", "desc": "このPDSでのアカウントのハンドルを選択してください。", - "handleHint": "完全なハンドル: @{handle}" + "migratingFrom": "移行元", + "newHandle": "新しいハンドル", + "checkingAvailability": "利用可能か確認中...", + "handleAvailable": "ハンドルは利用可能です!", + "handleTaken": "このハンドルは既に使用されています", + "handleHint": "フルハンドル(例:alice.mydomain.com)を入力して独自ドメインを使用することもできます", + "email": "メールアドレス", + "authMethod": "認証方法", + "authPassword": "パスワード", + "authPasswordDesc": "従来のパスワードベースのログイン", + "authPasskey": "パスキー", + "authPasskeyDesc": "生体認証やセキュリティキーを使用したパスワードレスログイン", + "password": "パスワード", + "passwordHint": "8文字以上", + "passkeyInfo": "アカウント作成後にパスキーを設定します。デバイスが生体認証(指紋、Face ID)またはセキュリティキーの使用を促します。", + "inviteCode": "招待コード" }, "review": { "title": "移行の確認", "desc": "移行の詳細を確認してください。", "currentHandle": "現在のハンドル", "newHandle": "新しいハンドル", + "did": "DID", "sourcePds": "移行元PDS", - "targetPds": "このPDS", + "targetPds": "移行先PDS", "email": "メール", + "authentication": "認証", + "authPasskey": "パスキー(パスワードレス)", + "authPassword": "パスワード", "inviteCode": "招待コード", - "confirm": "アカウントを移行することを確認します", - "startMigration": "移行を開始" + "warning": "「移行を開始」をクリックすると、リポジトリとデータの転送が始まります。このプロセスは簡単に元に戻すことができません。", + "startMigration": "移行を開始", + "starting": "開始中..." }, "migrating": { - "title": "アカウントを移行中", - "desc": "データを転送しています...", - "gettingServiceAuth": "サービス認証を取得中...", - "creatingAccount": "新しいPDSにアカウントを作成中...", - "exportingRepo": "リポジトリをエクスポート中...", - "importingRepo": "リポジトリをインポート中...", - "countingBlobs": "blobをカウント中...", - "migratingBlobs": "blobを移行中 ({current}/{total})...", - "migratingPrefs": "設定を移行中...", - "requestingPlc": "PLC操作をリクエスト中..." + "title": "移行中", + "desc": "アカウントを転送しています...", + "exportRepo": "リポジトリをエクスポート", + "importRepo": "リポジトリをインポート", + "migrateBlobs": "blobを移行", + "migratePrefs": "設定を移行" + }, + "passkeySetup": { + "title": "パスキーを設定", + "desc": "メールが確認されました。安全なパスワードレスログインのためにパスキーを設定してください。", + "nameLabel": "パスキー名(任意)", + "namePlaceholder": "例:MacBook Pro、iPhone", + "nameHint": "このパスキーを識別するためのわかりやすい名前", + "instructions": "下のボタンをクリックしてパスキーを登録してください。デバイスが生体認証(指紋、Face ID)またはセキュリティキーの使用を促します。", + "register": "パスキーを登録", + "registering": "登録中..." + }, + "appPassword": { + "title": "アプリパスワードを保存", + "desc": "パスキーが作成されました。パスキーをまだサポートしていないアプリで使用するためのアプリパスワードが生成されました。", + "warning": "このアプリパスワードは、パスキーをまだサポートしていないアプリ(bsky.appなど)へのサインインに必要です。このパスワードは一度しか表示されません。", + "label": "アプリパスワード:", + "saved": "アプリパスワードを安全な場所に保存しました", + "continue": "続ける" }, "emailVerify": { "title": "メールアドレスを確認", @@ -1128,12 +1191,14 @@ "verifying": "確認中..." }, "plcToken": { - "title": "本人確認", - "desc": "現在のPDSに登録されているメールアドレスに確認コードが送信されました。", - "tokenLabel": "確認トークン", - "tokenPlaceholder": "メールに記載されたトークンを入力", - "resend": "再送信", - "resending": "送信中..." + "title": "移行を確認", + "desc": "古いアカウントに登録されているメールアドレスに確認コードが送信されました。", + "info": "このコードはアカウントへのアクセス権を確認し、このPDSを指すようにアイデンティティを更新することを承認します。", + "tokenLabel": "確認コード", + "tokenPlaceholder": "メールに記載されたコードを入力", + "resend": "コードを再送信", + "complete": "移行を完了", + "completing": "確認中..." }, "didWebUpdate": { "title": "DIDドキュメントを更新", @@ -1156,9 +1221,30 @@ "success": { "title": "移行完了!", "desc": "アカウントはこのPDSに正常に移行されました。", - "newHandle": "新しいハンドル", + "yourNewHandle": "新しいハンドル", "did": "DID", - "goToDashboard": "ダッシュボードへ" + "blobsWarning": "{count}個のblobを移行できませんでした。これらは利用できなくなった画像やその他のメディアの可能性があります。", + "redirecting": "ダッシュボードにリダイレクト中..." + }, + "error": { + "title": "移行エラー", + "desc": "移行中にエラーが発生しました。", + "startOver": "最初からやり直す" + }, + "common": { + "back": "戻る", + "cancel": "キャンセル", + "continue": "続行", + "whatWillHappen": "何が起こるか:", + "step1": "現在のPDSにログイン", + "step2": "このサーバーでの新しいハンドルを選択", + "step3": "リポジトリとblobが転送されます", + "step4": "メールで移行を確認", + "step5": "アイデンティティがここを指すように更新されます", + "beforeProceed": "続行する前に:", + "warning1": "現在のアカウントに登録されているメールへのアクセスが必要です", + "warning2": "大きなアカウントの転送には数分かかる場合があります", + "warning3": "移行後、古いアカウントは無効化されます" } }, "outbound": { diff --git a/frontend/src/locales/ko.json b/frontend/src/locales/ko.json index b4ec653..76473e4 100644 --- a/frontend/src/locales/ko.json +++ b/frontend/src/locales/ko.json @@ -189,19 +189,31 @@ "title": "DID 문서 편집기", "preview": "현재 DID 문서", "verificationMethods": "검증 방법 (서명 키)", + "verificationMethodsDesc": "DID를 대신하여 동작할 수 있는 서명 키입니다. 새 PDS로 마이그레이션할 때 해당 서명 키를 여기에 추가하세요.", "addKey": "키 추가", "removeKey": "삭제", "keyId": "키 ID", "keyIdPlaceholder": "#atproto", "publicKey": "공개 키 (Multibase)", "publicKeyPlaceholder": "zQ3sh...", + "noKeys": "구성된 검증 방법이 없습니다. 로컬 PDS 키를 사용 중입니다.", "alsoKnownAs": "다른 이름 (핸들)", + "alsoKnownAsDesc": "DID를 가리키는 핸들입니다. 새 PDS에서 핸들이 변경되면 업데이트하세요.", "addHandle": "핸들 추가", + "removeHandle": "삭제", + "handle": "핸들", "handlePlaceholder": "at://handle.pds.com", - "serviceEndpoint": "서비스 엔드포인트 (현재 PDS)", + "noHandles": "구성된 핸들이 없습니다. 로컬 핸들을 사용 중입니다.", + "serviceEndpoint": "서비스 엔드포인트", + "serviceEndpointDesc": "현재 계정 데이터를 호스팅하는 PDS입니다. 마이그레이션할 때 업데이트하세요.", + "currentPds": "현재 PDS URL", "save": "변경사항 저장", "saving": "저장 중...", "success": "DID 문서가 업데이트되었습니다", + "saveFailed": "DID 문서 저장에 실패했습니다", + "loadFailed": "DID 문서 로드에 실패했습니다", + "invalidMultibase": "공개 키는 'z'로 시작하는 유효한 multibase 문자열이어야 합니다", + "invalidHandle": "핸들은 at:// URI여야 합니다 (예: at://handle.example.com)", "helpTitle": "이것은 무엇인가요?", "helpText": "다른 PDS로 마이그레이션하면 해당 PDS가 새 서명 키를 생성합니다. 여기에서 DID 문서를 업데이트하여 새 키와 위치를 가리키도록 하세요." }, @@ -890,6 +902,10 @@ "reauth": { "title": "재인증 필요", "subtitle": "계속하려면 본인 확인을 해주세요.", + "password": "비밀번호", + "totp": "TOTP", + "passkey": "패스키", + "authenticatorCode": "인증 코드", "usePassword": "비밀번호 사용", "usePasskey": "패스키 사용", "useTotp": "인증 앱 사용", @@ -897,6 +913,8 @@ "totpPlaceholder": "6자리 코드 입력", "verify": "확인", "verifying": "확인 중...", + "authenticating": "인증 중...", + "passkeyPrompt": "아래 버튼을 클릭하여 패스키로 인증하세요.", "cancel": "취소" }, "verifyChannel": { @@ -1059,6 +1077,10 @@ "beforeMigrate4": "이전 PDS에 계정 비활성화가 통보됩니다", "importantWarning": "계정 마이그레이션은 중요한 작업입니다. 대상 PDS를 신뢰하고 데이터가 이동된다는 것을 이해하세요. 문제가 발생하면 수동 복구가 필요할 수 있습니다.", "learnMore": "마이그레이션 위험에 대해 자세히 알아보기", + "comingSoon": "곧 출시 예정", + "oauthCompleting": "인증 완료 중...", + "oauthFailed": "인증 실패", + "tryAgain": "다시 시도", "resume": { "title": "마이그레이션을 재개하시겠습니까?", "incomplete": "완료되지 않은 마이그레이션이 있습니다:", @@ -1078,44 +1100,85 @@ "desc": "기존 AT Protocol 계정을 이 서버로 이동합니다.", "understand": "위험을 이해하고 계속 진행합니다" }, - "sourceLogin": { - "title": "현재 PDS에 로그인", - "desc": "마이그레이션할 계정의 인증 정보를 입력하세요.", + "sourceAuth": { + "title": "현재 핸들 입력", + "titleResume": "마이그레이션 재개", + "desc": "마이그레이션할 계정의 핸들을 입력하세요.", + "descResume": "마이그레이션을 계속하려면 소스 PDS에 재인증하세요.", "handle": "핸들", - "handlePlaceholder": "you.bsky.social", - "password": "비밀번호", - "twoFactorCode": "2단계 인증 코드", - "twoFactorRequired": "2단계 인증이 필요합니다", - "signIn": "로그인 및 계속" + "handlePlaceholder": "alice.bsky.social", + "handleHint": "현재 PDS에서의 핸들", + "continue": "계속", + "connecting": "연결 중...", + "reauthenticate": "재인증", + "resumeTitle": "마이그레이션 진행 중", + "resumeFrom": "출발지", + "resumeTo": "목적지", + "resumeProgress": "진행 상황", + "resumeOAuthNote": "계속하려면 OAuth로 재인증이 필요합니다." }, "chooseHandle": { "title": "새 핸들 선택", "desc": "이 PDS에서 사용할 계정 핸들을 선택하세요.", - "handleHint": "전체 핸들: @{handle}" + "migratingFrom": "마이그레이션 원본", + "newHandle": "새 핸들", + "checkingAvailability": "사용 가능 여부 확인 중...", + "handleAvailable": "핸들을 사용할 수 있습니다!", + "handleTaken": "핸들이 이미 사용 중입니다", + "handleHint": "전체 핸들(예: alice.mydomain.com)을 입력하여 자체 도메인을 사용할 수도 있습니다", + "email": "이메일 주소", + "authMethod": "인증 방법", + "authPassword": "비밀번호", + "authPasswordDesc": "기존 비밀번호 기반 로그인", + "authPasskey": "패스키", + "authPasskeyDesc": "생체 인식 또는 보안 키를 사용한 비밀번호 없는 로그인", + "password": "비밀번호", + "passwordHint": "최소 8자", + "passkeyInfo": "계정 생성 후 패스키를 설정합니다. 기기에서 생체 인식(지문, Face ID) 또는 보안 키 사용을 요청합니다.", + "inviteCode": "초대 코드" }, "review": { "title": "마이그레이션 검토", - "desc": "마이그레이션 세부 정보를 검토하고 확인하세요.", + "desc": "마이그레이션 세부 정보를 확인하세요.", "currentHandle": "현재 핸들", "newHandle": "새 핸들", + "did": "DID", "sourcePds": "소스 PDS", - "targetPds": "이 PDS", + "targetPds": "대상 PDS", "email": "이메일", + "authentication": "인증", + "authPasskey": "패스키 (비밀번호 없음)", + "authPassword": "비밀번호", "inviteCode": "초대 코드", - "confirm": "계정 마이그레이션을 확인합니다", - "startMigration": "마이그레이션 시작" + "warning": "\"마이그레이션 시작\"을 클릭하면 저장소와 데이터 전송이 시작됩니다. 이 과정은 쉽게 되돌릴 수 없습니다.", + "startMigration": "마이그레이션 시작", + "starting": "시작 중..." }, "migrating": { - "title": "계정 마이그레이션 중", - "desc": "데이터를 전송하는 중입니다...", - "gettingServiceAuth": "서비스 인증 획득 중...", - "creatingAccount": "새 PDS에 계정 생성 중...", - "exportingRepo": "저장소 내보내기 중...", - "importingRepo": "저장소 가져오기 중...", - "countingBlobs": "blob 개수 세는 중...", - "migratingBlobs": "blob 마이그레이션 중 ({current}/{total})...", - "migratingPrefs": "환경설정 마이그레이션 중...", - "requestingPlc": "PLC 작업 요청 중..." + "title": "마이그레이션 진행 중", + "desc": "계정을 전송하는 중입니다...", + "exportRepo": "저장소 내보내기", + "importRepo": "저장소 가져오기", + "migrateBlobs": "blob 마이그레이션", + "migratePrefs": "환경설정 마이그레이션" + }, + "passkeySetup": { + "title": "패스키 설정", + "desc": "이메일이 인증되었습니다. 안전한 비밀번호 없는 로그인을 위해 패스키를 설정하세요.", + "nameLabel": "패스키 이름 (선택사항)", + "namePlaceholder": "예: MacBook Pro, iPhone", + "nameHint": "이 패스키를 식별하기 위한 이름", + "instructions": "아래 버튼을 클릭하여 패스키를 등록하세요. 기기에서 생체 인식(지문, Face ID) 또는 보안 키 사용을 요청합니다.", + "register": "패스키 등록", + "registering": "등록 중..." + }, + "appPassword": { + "title": "앱 비밀번호 저장", + "desc": "패스키가 생성되었습니다. 아직 패스키를 지원하지 않는 앱에서 사용할 앱 비밀번호가 생성되었습니다.", + "warning": "이 앱 비밀번호는 아직 패스키를 지원하지 않는 앱(예: bsky.app)에 로그인할 때 필요합니다. 이 비밀번호는 한 번만 표시됩니다.", + "label": "앱 비밀번호:", + "saved": "앱 비밀번호를 안전한 곳에 저장했습니다", + "continue": "계속" }, "emailVerify": { "title": "이메일 인증", @@ -1128,12 +1191,14 @@ "verifying": "인증 중..." }, "plcToken": { - "title": "신원 확인", - "desc": "현재 PDS에 등록된 이메일로 인증 코드가 전송되었습니다.", - "tokenLabel": "인증 토큰", - "tokenPlaceholder": "이메일에서 받은 토큰 입력", - "resend": "재전송", - "resending": "전송 중..." + "title": "마이그레이션 확인", + "desc": "이전 계정에 등록된 이메일로 인증 코드가 전송되었습니다.", + "info": "이 코드는 계정 접근 권한을 확인하고 이 PDS를 가리키도록 아이덴티티 업데이트를 승인합니다.", + "tokenLabel": "인증 코드", + "tokenPlaceholder": "이메일에서 받은 코드 입력", + "resend": "코드 재전송", + "complete": "마이그레이션 완료", + "completing": "확인 중..." }, "didWebUpdate": { "title": "DID 문서 업데이트", @@ -1156,9 +1221,30 @@ "success": { "title": "마이그레이션 완료!", "desc": "계정이 이 PDS로 성공적으로 마이그레이션되었습니다.", - "newHandle": "새 핸들", + "yourNewHandle": "새 핸들", "did": "DID", - "goToDashboard": "대시보드로 이동" + "blobsWarning": "{count}개의 blob을 마이그레이션할 수 없습니다. 더 이상 사용할 수 없는 이미지나 기타 미디어일 수 있습니다.", + "redirecting": "대시보드로 리디렉션 중..." + }, + "error": { + "title": "마이그레이션 오류", + "desc": "마이그레이션 중 오류가 발생했습니다.", + "startOver": "처음부터 다시 시작" + }, + "common": { + "back": "뒤로", + "cancel": "취소", + "continue": "계속", + "whatWillHappen": "진행 과정:", + "step1": "현재 PDS에 로그인", + "step2": "이 서버에서 새 핸들 선택", + "step3": "저장소와 blob이 전송됩니다", + "step4": "이메일로 마이그레이션 확인", + "step5": "아이덴티티가 여기를 가리키도록 업데이트됩니다", + "beforeProceed": "진행하기 전에:", + "warning1": "현재 계정에 등록된 이메일에 접근할 수 있어야 합니다", + "warning2": "대용량 계정 전송에는 몇 분이 걸릴 수 있습니다", + "warning3": "마이그레이션 후 이전 계정은 비활성화됩니다" } }, "outbound": { diff --git a/frontend/src/locales/sv.json b/frontend/src/locales/sv.json index f605bc7..ee1603f 100644 --- a/frontend/src/locales/sv.json +++ b/frontend/src/locales/sv.json @@ -189,19 +189,31 @@ "title": "DID-dokumentredigerare", "preview": "Nuvarande DID-dokument", "verificationMethods": "Verifieringsmetoder (signeringsnycklar)", + "verificationMethodsDesc": "Signeringsnycklar som kan agera å din DIDs vägnar. När du migrerar till en ny PDS, lägg till deras signeringsnyckel här.", "addKey": "Lägg till nyckel", "removeKey": "Ta bort", "keyId": "Nyckel-ID", "keyIdPlaceholder": "#atproto", "publicKey": "Publik nyckel (Multibase)", "publicKeyPlaceholder": "zQ3sh...", + "noKeys": "Inga verifieringsmetoder konfigurerade. Använder lokal PDS-nyckel.", "alsoKnownAs": "Även känd som (användarnamn)", + "alsoKnownAsDesc": "Användarnamn som pekar på din DID. Uppdatera detta när ditt användarnamn ändras på en ny PDS.", "addHandle": "Lägg till användarnamn", + "removeHandle": "Ta bort", + "handle": "Användarnamn", "handlePlaceholder": "at://handle.pds.com", - "serviceEndpoint": "Tjänstslutpunkt (nuvarande PDS)", + "noHandles": "Inga användarnamn konfigurerade. Använder lokalt användarnamn.", + "serviceEndpoint": "Tjänstslutpunkt", + "serviceEndpointDesc": "PDS som för närvarande lagrar din kontodata. Uppdatera detta vid migrering.", + "currentPds": "Nuvarande PDS-URL", "save": "Spara ändringar", "saving": "Sparar...", "success": "DID-dokumentet har uppdaterats", + "saveFailed": "Kunde inte spara DID-dokument", + "loadFailed": "Kunde inte ladda DID-dokument", + "invalidMultibase": "Publik nyckel måste vara en giltig multibase-sträng som börjar med 'z'", + "invalidHandle": "Användarnamn måste vara en at:// URI (t.ex. at://handle.example.com)", "helpTitle": "Vad är detta?", "helpText": "När du flyttar till en annan PDS genererar den PDS nya signeringsnycklar. Uppdatera ditt DID-dokument här så att det pekar på dina nya nycklar och plats." }, @@ -890,6 +902,10 @@ "reauth": { "title": "Återautentisering krävs", "subtitle": "Verifiera din identitet för att fortsätta.", + "password": "Lösenord", + "totp": "TOTP", + "passkey": "Passkey", + "authenticatorCode": "Autentiseringskod", "usePassword": "Använd lösenord", "usePasskey": "Använd nyckel", "useTotp": "Använd autentiserare", @@ -897,6 +913,8 @@ "totpPlaceholder": "Ange 6-siffrig kod", "verify": "Verifiera", "verifying": "Verifierar...", + "authenticating": "Autentiserar...", + "passkeyPrompt": "Klicka på knappen nedan för att autentisera med din passkey.", "cancel": "Avbryt" }, "verifyChannel": { @@ -1059,6 +1077,10 @@ "beforeMigrate4": "Din gamla PDS kommer att meddelas om kontoinaktivering", "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.", "learnMore": "Läs mer om flyttningsrisker", + "comingSoon": "Kommer snart", + "oauthCompleting": "Slutför autentisering...", + "oauthFailed": "Autentisering misslyckades", + "tryAgain": "Försök igen", "resume": { "title": "Återuppta flytt?", "incomplete": "Du har en ofullständig flytt pågående:", @@ -1078,44 +1100,85 @@ "desc": "Flytta ditt befintliga AT Protocol-konto till denna server.", "understand": "Jag förstår riskerna och vill fortsätta" }, - "sourceLogin": { - "title": "Logga in på din nuvarande PDS", - "desc": "Ange uppgifterna för kontot du vill flytta.", + "sourceAuth": { + "title": "Ange ditt nuvarande användarnamn", + "titleResume": "Återuppta flytt", + "desc": "Ange användarnamnet för kontot du vill flytta.", + "descResume": "Autentisera dig igen till din käll-PDS för att fortsätta flytten.", "handle": "Användarnamn", - "handlePlaceholder": "du.bsky.social", - "password": "Lösenord", - "twoFactorCode": "Tvåfaktorkod", - "twoFactorRequired": "Tvåfaktorautentisering krävs", - "signIn": "Logga in och fortsätt" + "handlePlaceholder": "alice.bsky.social", + "handleHint": "Ditt nuvarande användarnamn på din befintliga PDS", + "continue": "Fortsätt", + "connecting": "Ansluter...", + "reauthenticate": "Autentisera igen", + "resumeTitle": "Flytt pågår", + "resumeFrom": "Från", + "resumeTo": "Till", + "resumeProgress": "Framsteg", + "resumeOAuthNote": "Du måste autentisera dig igen via OAuth för att fortsätta." }, "chooseHandle": { "title": "Välj ditt nya användarnamn", "desc": "Välj ett användarnamn för ditt konto på denna PDS.", - "handleHint": "Ditt fullständiga användarnamn blir: @{handle}" + "migratingFrom": "Flyttar från", + "newHandle": "Nytt användarnamn", + "checkingAvailability": "Kontrollerar tillgänglighet...", + "handleAvailable": "Användarnamnet är tillgängligt!", + "handleTaken": "Användarnamnet är redan taget", + "handleHint": "Du kan också använda din egen domän genom att ange det fullständiga användarnamnet (t.ex. alice.mindomän.se)", + "email": "E-postadress", + "authMethod": "Autentiseringsmetod", + "authPassword": "Lösenord", + "authPasswordDesc": "Traditionell lösenordsbaserad inloggning", + "authPasskey": "Passkey", + "authPasskeyDesc": "Lösenordslös inloggning med biometri eller säkerhetsnyckel", + "password": "Lösenord", + "passwordHint": "Minst 8 tecken", + "passkeyInfo": "Du kommer att konfigurera en passkey efter att ditt konto skapats. Din enhet kommer att uppmana dig att använda biometri (fingeravtryck, Face ID) eller en säkerhetsnyckel.", + "inviteCode": "Inbjudningskod" }, "review": { "title": "Granska flytt", - "desc": "Granska och bekräfta dina flyttdetaljer.", + "desc": "Bekräfta detaljerna för din flytt.", "currentHandle": "Nuvarande användarnamn", "newHandle": "Nytt användarnamn", - "sourcePds": "Käll-PDS", - "targetPds": "Denna PDS", + "did": "DID", + "sourcePds": "Från PDS", + "targetPds": "Till PDS", "email": "E-post", + "authentication": "Autentisering", + "authPasskey": "Passkey (lösenordslös)", + "authPassword": "Lösenord", "inviteCode": "Inbjudningskod", - "confirm": "Jag bekräftar att jag vill flytta mitt konto", - "startMigration": "Starta flytt" + "warning": "När du klickar på \"Starta flytt\" börjar ditt arkiv och data överföras. Denna process kan inte enkelt ångras.", + "startMigration": "Starta flytt", + "starting": "Startar..." }, "migrating": { - "title": "Flyttar ditt konto", - "desc": "Vänta medan vi överför din data...", - "gettingServiceAuth": "Hämtar tjänstauktorisering...", - "creatingAccount": "Skapar konto på ny PDS...", - "exportingRepo": "Exporterar arkiv...", - "importingRepo": "Importerar arkiv...", - "countingBlobs": "Räknar blobbar...", - "migratingBlobs": "Flyttar blobbar ({current}/{total})...", - "migratingPrefs": "Flyttar inställningar...", - "requestingPlc": "Begär PLC-operation..." + "title": "Flytt pågår", + "desc": "Vänta medan ditt konto överförs...", + "exportRepo": "Exportera arkiv", + "importRepo": "Importera arkiv", + "migrateBlobs": "Flytta blobbar", + "migratePrefs": "Flytta inställningar" + }, + "passkeySetup": { + "title": "Konfigurera din passkey", + "desc": "Din e-post har verifierats. Konfigurera nu din passkey för säker, lösenordslös inloggning.", + "nameLabel": "Passkey-namn (valfritt)", + "namePlaceholder": "t.ex. MacBook Pro, iPhone", + "nameHint": "Ett vänligt namn för att identifiera denna passkey", + "instructions": "Klicka på knappen nedan för att registrera din passkey. Din enhet kommer att uppmana dig att använda biometri (fingeravtryck, Face ID) eller en säkerhetsnyckel.", + "register": "Registrera passkey", + "registering": "Registrerar..." + }, + "appPassword": { + "title": "Spara ditt applösenord", + "desc": "Din passkey har skapats. Ett applösenord har genererats för dig att använda med appar som inte stödjer passkeys ännu.", + "warning": "Detta applösenord krävs för att logga in i appar som inte stödjer passkeys ännu (som bsky.app). Du kommer bara att se detta lösenord en gång.", + "label": "Applösenord för", + "saved": "Jag har sparat mitt applösenord på en säker plats", + "continue": "Fortsätt" }, "emailVerify": { "title": "Verifiera din e-post", @@ -1128,12 +1191,14 @@ "verifying": "Verifierar..." }, "plcToken": { - "title": "Verifiera din identitet", - "desc": "En verifieringskod har skickats till din e-post på din nuvarande PDS.", - "tokenLabel": "Verifieringstoken", - "tokenPlaceholder": "Ange token från din e-post", - "resend": "Skicka igen", - "resending": "Skickar..." + "title": "Verifiera flytt", + "desc": "En verifieringskod har skickats till e-posten registrerad på ditt gamla konto.", + "info": "Denna kod bekräftar att du har tillgång till kontot och auktoriserar uppdatering av din identitet för att peka på denna PDS.", + "tokenLabel": "Verifieringskod", + "tokenPlaceholder": "Ange kod från e-post", + "resend": "Skicka kod igen", + "complete": "Slutför flytt", + "completing": "Verifierar..." }, "didWebUpdate": { "title": "Uppdatera ditt DID-dokument", @@ -1156,9 +1221,30 @@ "success": { "title": "Flytt klar!", "desc": "Ditt konto har framgångsrikt flyttats till denna PDS.", - "newHandle": "Nytt användarnamn", + "yourNewHandle": "Ditt nya användarnamn", "did": "DID", - "goToDashboard": "Gå till instrumentpanel" + "blobsWarning": "{count} blobbar kunde inte flyttas. Dessa kan vara bilder eller annan media som inte längre är tillgängliga.", + "redirecting": "Omdirigerar till instrumentpanel..." + }, + "error": { + "title": "Flyttfel", + "desc": "Ett fel uppstod under flytten.", + "startOver": "Börja om" + }, + "common": { + "back": "Tillbaka", + "cancel": "Avbryt", + "continue": "Fortsätt", + "whatWillHappen": "Vad som kommer att hända:", + "step1": "Logga in på din nuvarande PDS", + "step2": "Välj ditt nya användarnamn på denna server", + "step3": "Ditt arkiv och blobbar kommer att överföras", + "step4": "Verifiera flytten via e-post", + "step5": "Din identitet kommer att uppdateras för att peka hit", + "beforeProceed": "Innan du fortsätter:", + "warning1": "Du behöver tillgång till e-posten registrerad på ditt nuvarande konto", + "warning2": "Stora konton kan ta flera minuter att överföra", + "warning3": "Ditt gamla konto kommer att inaktiveras efter flytten" } }, "outbound": { diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json index 49fa3bc..1c0472c 100644 --- a/frontend/src/locales/zh.json +++ b/frontend/src/locales/zh.json @@ -189,19 +189,31 @@ "title": "DID 文档编辑器", "preview": "当前 DID 文档", "verificationMethods": "验证方法(签名密钥)", + "verificationMethodsDesc": "可以代表您的 DID 进行操作的签名密钥。迁移到新 PDS 时,请在此添加其签名密钥。", "addKey": "添加密钥", "removeKey": "删除", "keyId": "密钥 ID", "keyIdPlaceholder": "#atproto", "publicKey": "公钥(Multibase)", "publicKeyPlaceholder": "zQ3sh...", + "noKeys": "未配置验证方法。正在使用本地 PDS 密钥。", "alsoKnownAs": "别名(用户名)", + "alsoKnownAsDesc": "指向您的 DID 的用户名。当您在新 PDS 上更改用户名时请更新此项。", "addHandle": "添加用户名", + "removeHandle": "删除", + "handle": "用户名", "handlePlaceholder": "at://handle.pds.com", - "serviceEndpoint": "服务端点(当前 PDS)", + "noHandles": "未配置用户名。正在使用本地用户名。", + "serviceEndpoint": "服务端点", + "serviceEndpointDesc": "当前托管您账户数据的 PDS。迁移时请更新此项。", + "currentPds": "当前 PDS URL", "save": "保存更改", "saving": "保存中...", "success": "DID 文档已更新", + "saveFailed": "保存 DID 文档失败", + "loadFailed": "加载 DID 文档失败", + "invalidMultibase": "公钥必须是以 'z' 开头的有效 multibase 字符串", + "invalidHandle": "用户名必须是 at:// URI(例如:at://handle.example.com)", "helpTitle": "这是什么?", "helpText": "当您迁移到另一个 PDS 时,该 PDS 会生成新的签名密钥。在此处更新您的 DID 文档,使其指向您的新密钥和位置。" }, @@ -890,6 +902,10 @@ "reauth": { "title": "需要重新验证", "subtitle": "请验证您的身份以继续。", + "password": "密码", + "totp": "TOTP", + "passkey": "通行密钥", + "authenticatorCode": "验证码", "usePassword": "使用密码", "usePasskey": "使用通行密钥", "useTotp": "使用身份验证器", @@ -897,6 +913,8 @@ "totpPlaceholder": "输入6位验证码", "verify": "验证", "verifying": "验证中...", + "authenticating": "正在验证...", + "passkeyPrompt": "点击下方按钮使用通行密钥进行验证。", "cancel": "取消" }, "verifyChannel": { @@ -1059,6 +1077,10 @@ "beforeMigrate4": "您的旧PDS将收到账户停用通知", "importantWarning": "账户迁移是一项重要操作。请确保您信任目标PDS,并了解您的数据将被移动。如果出现问题,可能需要手动恢复。", "learnMore": "了解更多迁移风险", + "comingSoon": "即将推出", + "oauthCompleting": "正在完成身份验证...", + "oauthFailed": "身份验证失败", + "tryAgain": "重试", "resume": { "title": "恢复迁移?", "incomplete": "您有一个未完成的迁移:", @@ -1078,44 +1100,85 @@ "desc": "将您现有的AT Protocol账户移至此服务器。", "understand": "我了解风险并希望继续" }, - "sourceLogin": { - "title": "登录到您当前的PDS", - "desc": "输入您要迁移的账户凭据。", + "sourceAuth": { + "title": "输入您当前的用户名", + "titleResume": "恢复迁移", + "desc": "输入您要迁移的账户用户名。", + "descResume": "重新验证您的源PDS以继续迁移。", "handle": "用户名", - "handlePlaceholder": "you.bsky.social", - "password": "密码", - "twoFactorCode": "双因素验证码", - "twoFactorRequired": "需要双因素认证", - "signIn": "登录并继续" + "handlePlaceholder": "alice.bsky.social", + "handleHint": "您在现有PDS上的当前用户名", + "continue": "继续", + "connecting": "连接中...", + "reauthenticate": "重新验证", + "resumeTitle": "迁移进行中", + "resumeFrom": "来自", + "resumeTo": "迁移至", + "resumeProgress": "进度", + "resumeOAuthNote": "您需要通过OAuth重新验证才能继续。" }, "chooseHandle": { "title": "选择新用户名", "desc": "为您在此PDS上的账户选择用户名。", - "handleHint": "您的完整用户名将是:@{handle}" + "migratingFrom": "迁移自", + "newHandle": "新用户名", + "checkingAvailability": "检查可用性...", + "handleAvailable": "用户名可用!", + "handleTaken": "用户名已被占用", + "handleHint": "您也可以输入完整的用户名(如alice.mydomain.com)来使用您自己的域名", + "email": "邮箱地址", + "authMethod": "身份验证方式", + "authPassword": "密码", + "authPasswordDesc": "传统的密码登录", + "authPasskey": "通行密钥", + "authPasskeyDesc": "使用生物识别或安全密钥的无密码登录", + "password": "密码", + "passwordHint": "至少8个字符", + "passkeyInfo": "您将在账户创建后设置通行密钥。您的设备将提示您使用生物识别(指纹、面容ID)或安全密钥。", + "inviteCode": "邀请码" }, "review": { "title": "检查迁移", - "desc": "请检查并确认您的迁移详情。", + "desc": "确认您的迁移详情。", "currentHandle": "当前用户名", "newHandle": "新用户名", + "did": "DID", "sourcePds": "源PDS", - "targetPds": "此PDS", + "targetPds": "目标PDS", "email": "邮箱", + "authentication": "身份验证", + "authPasskey": "通行密钥(无密码)", + "authPassword": "密码", "inviteCode": "邀请码", - "confirm": "我确认要迁移我的账户", - "startMigration": "开始迁移" + "warning": "点击「开始迁移」后,您的存储库和数据将开始转移。此过程无法轻易撤销。", + "startMigration": "开始迁移", + "starting": "启动中..." }, "migrating": { - "title": "正在迁移您的账户", - "desc": "请稍候,正在转移您的数据...", - "gettingServiceAuth": "正在获取服务授权...", - "creatingAccount": "正在新PDS上创建账户...", - "exportingRepo": "正在导出存储库...", - "importingRepo": "正在导入存储库...", - "countingBlobs": "正在统计blob...", - "migratingBlobs": "正在迁移blob ({current}/{total})...", - "migratingPrefs": "正在迁移偏好设置...", - "requestingPlc": "正在请求PLC操作..." + "title": "迁移进行中", + "desc": "正在转移您的账户...", + "exportRepo": "导出存储库", + "importRepo": "导入存储库", + "migrateBlobs": "迁移blob", + "migratePrefs": "迁移偏好设置" + }, + "passkeySetup": { + "title": "设置您的通行密钥", + "desc": "您的邮箱已验证。现在设置通行密钥以实现安全的无密码登录。", + "nameLabel": "通行密钥名称(可选)", + "namePlaceholder": "例如:MacBook Pro、iPhone", + "nameHint": "用于识别此通行密钥的友好名称", + "instructions": "点击下方按钮注册您的通行密钥。您的设备将提示您使用生物识别(指纹、面容ID)或安全密钥。", + "register": "注册通行密钥", + "registering": "注册中..." + }, + "appPassword": { + "title": "保存您的应用密码", + "desc": "您的通行密钥已创建。已为您生成应用密码,用于尚不支持通行密钥的应用。", + "warning": "此应用密码用于登录尚不支持通行密钥的应用(如 bsky.app)。此密码仅显示一次。", + "label": "应用密码:", + "saved": "我已将应用密码保存在安全的地方", + "continue": "继续" }, "emailVerify": { "title": "验证您的邮箱", @@ -1128,12 +1191,14 @@ "verifying": "验证中..." }, "plcToken": { - "title": "验证您的身份", - "desc": "验证码已发送到您在当前PDS注册的邮箱。", - "tokenLabel": "验证令牌", - "tokenPlaceholder": "输入邮件中的令牌", + "title": "验证迁移", + "desc": "验证码已发送到您旧账户注册的邮箱。", + "info": "此代码确认您有权访问该账户,并授权将您的身份更新为指向此PDS。", + "tokenLabel": "验证码", + "tokenPlaceholder": "输入邮件中的验证码", "resend": "重新发送", - "resending": "发送中..." + "complete": "完成迁移", + "completing": "验证中..." }, "didWebUpdate": { "title": "更新您的DID文档", @@ -1156,9 +1221,30 @@ "success": { "title": "迁移完成!", "desc": "您的账户已成功迁移到此PDS。", - "newHandle": "新用户名", + "yourNewHandle": "您的新用户名", "did": "DID", - "goToDashboard": "前往仪表板" + "blobsWarning": "{count}个blob无法迁移。这些可能是不再可用的图片或其他媒体。", + "redirecting": "正在跳转到仪表板..." + }, + "error": { + "title": "迁移错误", + "desc": "迁移过程中发生错误。", + "startOver": "重新开始" + }, + "common": { + "back": "返回", + "cancel": "取消", + "continue": "继续", + "whatWillHappen": "将会发生什么:", + "step1": "登录到您当前的PDS", + "step2": "在此服务器上选择新用户名", + "step3": "您的存储库和blob将被转移", + "step4": "通过邮件验证迁移", + "step5": "您的身份将更新为指向此处", + "beforeProceed": "继续之前:", + "warning1": "您需要访问当前账户注册的邮箱", + "warning2": "大型账户可能需要几分钟才能转移", + "warning3": "迁移后您的旧账户将被停用" } }, "outbound": { diff --git a/frontend/src/routes/Migration.svelte b/frontend/src/routes/Migration.svelte index 18a5aee..e93cffb 100644 --- a/frontend/src/routes/Migration.svelte +++ b/frontend/src/routes/Migration.svelte @@ -1,6 +1,7 @@