this repo has no description

Email conf. vs ref

lewis a0ea2f24 9d6d792f

+14
.sqlx/query-92d601a6ea9ca3bcbafc228b258ede6948c18f2c824be8dcce434041d386e439.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE users SET email_verified = TRUE, updated_at = NOW() WHERE id = $1", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Uuid" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "92d601a6ea9ca3bcbafc228b258ede6948c18f2c824be8dcce434041d386e439" 14 + }
+15
.sqlx/query-ad9d1f4dbd7075a15733e2366db78fb42554ba52b985068318ff3af0e4ad81a6.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE users SET email = $1, email_verified = FALSE, updated_at = NOW() WHERE id = $2", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Uuid" 10 + ] 11 + }, 12 + "nullable": [] 13 + }, 14 + "hash": "ad9d1f4dbd7075a15733e2366db78fb42554ba52b985068318ff3af0e4ad81a6" 15 + }
-28
.sqlx/query-d6ec64a262936b8def9e5cad7d021df408b3a9b80fce5a7446647fbf276092ca.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT id, email FROM users WHERE did = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "id", 9 - "type_info": "Uuid" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "email", 14 - "type_info": "Text" 15 - } 16 - ], 17 - "parameters": { 18 - "Left": [ 19 - "Text" 20 - ] 21 - }, 22 - "nullable": [ 23 - false, 24 - true 25 - ] 26 - }, 27 - "hash": "d6ec64a262936b8def9e5cad7d021df408b3a9b80fce5a7446647fbf276092ca" 28 - }
···
+40
.sqlx/query-e1d6f474116c4eade83f39956a7ce32a175f8cdfce0d30bbb2cf155aa11bc840.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT id, handle, email, email_verified FROM users WHERE did = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "handle", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "email", 19 + "type_info": "Text" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "email_verified", 24 + "type_info": "Bool" 25 + } 26 + ], 27 + "parameters": { 28 + "Left": [ 29 + "Text" 30 + ] 31 + }, 32 + "nullable": [ 33 + false, 34 + false, 35 + true, 36 + false 37 + ] 38 + }, 39 + "hash": "e1d6f474116c4eade83f39956a7ce32a175f8cdfce0d30bbb2cf155aa11bc840" 40 + }
-2
frontend/src/lib/api.ts
··· 279 280 async requestEmailUpdate( 281 token: string, 282 - email: string, 283 ): Promise<{ tokenRequired: boolean }> { 284 return xrpc("com.atproto.server.requestEmailUpdate", { 285 method: "POST", 286 token, 287 - body: { email }, 288 }); 289 }, 290
··· 279 280 async requestEmailUpdate( 281 token: string, 282 ): Promise<{ tokenRequired: boolean }> { 283 return xrpc("com.atproto.server.requestEmailUpdate", { 284 method: "POST", 285 token, 286 }); 287 }, 288
+14 -1
frontend/src/locales/en.json
··· 234 "deleting": "Deleting...", 235 "messages": { 236 "emailCodeSent": "Verification code sent to your notification channel", 237 "emailUpdated": "Email updated successfully", 238 "emailUpdateFailed": "Failed to update email", 239 "handleUpdated": "Handle updated successfully", ··· 659 "codeResent": "Verification code resent!", 660 "codeResentDetail": "Verification code sent! Check your inbox.", 661 "backToLogin": "Back to Login", 662 "verifyingAccount": "Verifying account: @{handle}", 663 "startOver": "Start over with a different account", 664 "noPending": "No pending verification found.", ··· 671 "continue": "Continue", 672 "identifierLabel": "Email or Identifier", 673 "identifierPlaceholder": "you@example.com", 674 - "identifierHelp": "The email address or identifier the code was sent to" 675 }, 676 "resetPassword": { 677 "title": "Reset Password",
··· 234 "deleting": "Deleting...", 235 "messages": { 236 "emailCodeSent": "Verification code sent to your notification channel", 237 + "emailCodeSentToCurrent": "Verification code sent to your current email address", 238 "emailUpdated": "Email updated successfully", 239 "emailUpdateFailed": "Failed to update email", 240 "handleUpdated": "Handle updated successfully", ··· 660 "codeResent": "Verification code resent!", 661 "codeResentDetail": "Verification code sent! Check your inbox.", 662 "backToLogin": "Back to Login", 663 + "backToSettings": "Back to Settings", 664 "verifyingAccount": "Verifying account: @{handle}", 665 "startOver": "Start over with a different account", 666 "noPending": "No pending verification found.", ··· 673 "continue": "Continue", 674 "identifierLabel": "Email or Identifier", 675 "identifierPlaceholder": "you@example.com", 676 + "identifierHelp": "The email address or identifier the code was sent to", 677 + "emailUpdateTitle": "Update Email Address", 678 + "emailUpdateSubtitle": "Enter your new email address and the verification code sent to your current email.", 679 + "emailUpdateRequiresAuth": "You must be signed in to update your email address.", 680 + "emailUpdateFailed": "Failed to update email address", 681 + "emailUpdateCodeHelp": "The code was sent to your current email address", 682 + "newEmailLabel": "New Email Address", 683 + "newEmailPlaceholder": "new@example.com", 684 + "updateEmail": "Update Email", 685 + "updating": "Updating...", 686 + "emailUpdated": "Your email has been updated successfully.", 687 + "emailUpdatedInfo": "You may need to verify your new email address." 688 }, 689 "resetPassword": { 690 "title": "Reset Password",
+14 -17
frontend/src/locales/fi.json
··· 234 "deleting": "Poistetaan...", 235 "messages": { 236 "emailCodeSent": "Vahvistuskoodi lähetetty ilmoituskanavallesi", 237 "emailUpdated": "Sähköposti päivitetty", 238 "emailUpdateFailed": "Sähköpostin päivitys epäonnistui", 239 "handleUpdated": "Käyttäjänimi päivitetty", ··· 671 "noPending": "Odottavaa vahvistusta ei löytynyt.", 672 "noPendingInfo": "Jos loit tilin äskettäin ja sinun on vahvistettava se, sinun on ehkä luotava uusi tili. Jos olet jo vahvistanut tilisi, voit kirjautua sisään.", 673 "createAccount": "Luo tili", 674 - "signIn": "Kirjaudu sisään" 675 }, 676 "resetPassword": { 677 "title": "Palauta salasana", ··· 754 "verificationMethod": "Vahvistusmenetelmä", 755 "email": "Sähköpostiosoite", 756 "emailPlaceholder": "sinä@esimerkki.fi", 757 - "discord": "Discord", 758 - "discordId": "Discord-käyttäjätunnus", 759 - "discordIdPlaceholder": "Discord-käyttäjätunnuksesi", 760 - "discordIdHint": "Numeerinen Discord-käyttäjätunnuksesi (ota Kehittäjätila käyttöön löytääksesi sen)", 761 - "telegram": "Telegram", 762 - "telegramUsername": "Telegram-käyttäjänimi", 763 - "telegramUsernamePlaceholder": "@käyttäjänimesi", 764 - "signal": "Signal", 765 - "signalNumber": "Signal-puhelinnumero", 766 - "signalNumberPlaceholder": "+358401234567", 767 - "signalNumberHint": "Sisällytä maakoodi (esim. +358 Suomelle)", 768 "inviteCode": "Kutsukoodi", 769 "inviteCodePlaceholder": "Syötä kutsukoodisi", 770 - "inviteCodeRequired": "vaaditaan", 771 - "didWebDescription": "Käytä DID-identiteettiä, jota isännöidään omalla verkkotunnuksellasi.", 772 - "didWebToggle": "Käytä ulkoista did:web", 773 "externalDid": "Sinun did:web", 774 "externalDidPlaceholder": "did:web:verkkotunnuksesi.fi", 775 - "dnsVerificationInstructions": "Vahvistaaksesi verkkotunnuksesi, lisää tämä TXT-tietue:", 776 - "copyDid": "Kopioi DID", 777 "createButton": "Luo tili", 778 "creating": "Luodaan...", 779 "alreadyHaveAccount": "Onko sinulla jo tili?",
··· 234 "deleting": "Poistetaan...", 235 "messages": { 236 "emailCodeSent": "Vahvistuskoodi lähetetty ilmoituskanavallesi", 237 + "emailCodeSentToCurrent": "Vahvistuskoodi lähetetty nykyiseen sähköpostiosoitteeseesi", 238 "emailUpdated": "Sähköposti päivitetty", 239 "emailUpdateFailed": "Sähköpostin päivitys epäonnistui", 240 "handleUpdated": "Käyttäjänimi päivitetty", ··· 672 "noPending": "Odottavaa vahvistusta ei löytynyt.", 673 "noPendingInfo": "Jos loit tilin äskettäin ja sinun on vahvistettava se, sinun on ehkä luotava uusi tili. Jos olet jo vahvistanut tilisi, voit kirjautua sisään.", 674 "createAccount": "Luo tili", 675 + "signIn": "Kirjaudu sisään", 676 + "backToSettings": "Takaisin asetuksiin", 677 + "emailUpdateCodeHelp": "Koodi lähetettiin nykyiseen sähköpostiosoitteeseesi", 678 + "emailUpdateFailed": "Sähköpostiosoitteen päivitys epäonnistui", 679 + "emailUpdateRequiresAuth": "Sinun on kirjauduttava sisään päivittääksesi sähköpostiosoitteesi.", 680 + "emailUpdateSubtitle": "Syötä uusi sähköpostiosoitteesi ja nykyiseen sähköpostiisi lähetetty vahvistuskoodi.", 681 + "emailUpdateTitle": "Päivitä sähköpostiosoite", 682 + "emailUpdated": "Sähköpostiosoitteesi on päivitetty.", 683 + "emailUpdatedInfo": "Sinun on ehkä vahvistettava uusi sähköpostiosoitteesi.", 684 + "newEmailLabel": "Uusi sähköpostiosoite", 685 + "newEmailPlaceholder": "uusi@esimerkki.fi", 686 + "updateEmail": "Päivitä sähköposti", 687 + "updating": "Päivitetään..." 688 }, 689 "resetPassword": { 690 "title": "Palauta salasana", ··· 767 "verificationMethod": "Vahvistusmenetelmä", 768 "email": "Sähköpostiosoite", 769 "emailPlaceholder": "sinä@esimerkki.fi", 770 "inviteCode": "Kutsukoodi", 771 "inviteCodePlaceholder": "Syötä kutsukoodisi", 772 "externalDid": "Sinun did:web", 773 "externalDidPlaceholder": "did:web:verkkotunnuksesi.fi", 774 "createButton": "Luo tili", 775 "creating": "Luodaan...", 776 "alreadyHaveAccount": "Onko sinulla jo tili?",
+16 -41
frontend/src/locales/ja.json
··· 234 "deleting": "削除中...", 235 "messages": { 236 "emailCodeSent": "通知チャンネルに確認コードを送信しました", 237 "emailUpdated": "メールを更新しました", 238 "emailUpdateFailed": "メールの更新に失敗しました", 239 "handleUpdated": "ハンドルを更新しました", ··· 671 "noPending": "保留中の確認が見つかりません。", 672 "noPendingInfo": "最近アカウントを作成して確認が必要な場合は、新しいアカウントを作成する必要があります。すでにアカウントを確認した場合は、サインインできます。", 673 "createAccount": "アカウントを作成", 674 - "signIn": "サインイン" 675 }, 676 "resetPassword": { 677 "title": "パスワードリセット", ··· 754 "verificationMethod": "確認方法", 755 "email": "メールアドレス", 756 "emailPlaceholder": "you@example.com", 757 - "discord": "Discord", 758 - "discordId": "Discord ユーザー ID", 759 - "discordIdPlaceholder": "Discord ユーザー ID", 760 - "discordIdHint": "数値の Discord ユーザー ID(開発者モードを有効にして確認)", 761 - "telegram": "Telegram", 762 - "telegramUsername": "Telegram ユーザー名", 763 - "telegramUsernamePlaceholder": "@yourusername", 764 - "signal": "Signal", 765 - "signalNumber": "Signal 電話番号", 766 - "signalNumberPlaceholder": "+81XXXXXXXXXX", 767 - "signalNumberHint": "国番号を含めてください(例: 日本は +81)", 768 "inviteCode": "招待コード", 769 "inviteCodePlaceholder": "招待コードを入力", 770 - "inviteCodeRequired": "必須", 771 - "didWebDescription": "独自ドメインでホストされる DID アイデンティティを使用します。", 772 - "didWebToggle": "外部 did:web を使用", 773 "externalDid": "あなたの did:web", 774 "externalDidPlaceholder": "did:web:yourdomain.com", 775 - "dnsVerificationInstructions": "ドメインを確認するには、この TXT レコードを追加してください:", 776 - "copyDid": "DID をコピー", 777 "createButton": "アカウントを作成", 778 "creating": "作成中...", 779 "alreadyHaveAccount": "すでにアカウントをお持ちですか?", ··· 896 "delegation": { 897 "title": "アカウント委任", 898 "controllers": "コントローラー", 899 - "controllersDescription": "コントローラーはあなたのアカウントの管理者として行動できます。あなたが許可した操作を実行し、あなたの代わりに投稿を作成し、リポジトリを変更できます。", 900 "controlledAccounts": "管理アカウント", 901 - "controlledAccountsDescription": "これらはあなたがコントローラーとして追加されているアカウントです。これらのアカウントで許可されたアクションを実行できます。", 902 "noControllers": "コントローラーはまだいません", 903 "noControlledAccounts": "管理アカウントはありません", 904 "addController": "コントローラーを追加", 905 - "revokeAccess": "アクセスを取り消す", 906 - "revokeConfirm": "このコントローラーのアクセスを取り消しますか?あなたのアカウントで操作できなくなります。", 907 "handle": "ハンドル", 908 - "handlePlaceholder": "@user.bsky.social", 909 "did": "DID", 910 - "didPlaceholder": "did:plc:...", 911 - "scopes": "権限レベル", 912 "scopeOwner": "オーナー", 913 - "scopeOwnerDesc": "完全な管理(すべてのアクションを実行可能)", 914 - "scopeAdmin": "管理者", 915 - "scopeAdminDesc": "投稿、アプリパスワード、設定の管理", 916 - "scopeEditor": "編集者", 917 - "scopeEditorDesc": "投稿、いいね、フォローの作成・管理", 918 "scopeViewer": "閲覧者", 919 - "scopeViewerDesc": "リポジトリと設定の読み取り専用アクセス", 920 "scopeCustom": "カスタム", 921 - "scopeCustomDesc": "個別の権限を選択", 922 - "grantedAt": "許可日時", 923 - "expiresAt": "有効期限", 924 - "noExpiration": "無期限", 925 "actAs": "として行動", 926 "auditLog": "監査ログ", 927 "auditLogTitle": "委任監査ログ", ··· 944 "showing": "{start}~{end} / {total}件", 945 "refresh": "更新", 946 "failedToLoadAuditLog": "監査ログの読み込みに失敗しました", 947 - "addControllerTitle": "コントローラーを追加", 948 - "addControllerDescription": "このアカウントに対して指定した権限で操作できるユーザーを追加します。", 949 - "controllerIdentifier": "コントローラーのハンドルまたはDID", 950 - "selectScopes": "権限レベルを選択", 951 - "add": "追加", 952 "adding": "追加中...", 953 - "cancel": "キャンセル", 954 "accessLevel": "アクセスレベル", 955 "addControllerButton": "+ コントローラーを追加", 956 "auditLogDesc": "すべての委任アクティビティを表示", ··· 974 "remove": "削除", 975 "removeConfirm": "このコントローラーを削除しますか?", 976 "viewAuditLog": "監査ログを表示", 977 - "yourAccessLevel": "あなたのアクセスレベル" 978 }, 979 "actAs": { 980 "title": "として行動",
··· 234 "deleting": "削除中...", 235 "messages": { 236 "emailCodeSent": "通知チャンネルに確認コードを送信しました", 237 + "emailCodeSentToCurrent": "現在のメールアドレスに確認コードを送信しました", 238 "emailUpdated": "メールを更新しました", 239 "emailUpdateFailed": "メールの更新に失敗しました", 240 "handleUpdated": "ハンドルを更新しました", ··· 672 "noPending": "保留中の確認が見つかりません。", 673 "noPendingInfo": "最近アカウントを作成して確認が必要な場合は、新しいアカウントを作成する必要があります。すでにアカウントを確認した場合は、サインインできます。", 674 "createAccount": "アカウントを作成", 675 + "signIn": "サインイン", 676 + "backToSettings": "設定に戻る", 677 + "emailUpdateCodeHelp": "コードは現在のメールアドレスに送信されました", 678 + "emailUpdateFailed": "メールアドレスの更新に失敗しました", 679 + "emailUpdateRequiresAuth": "メールアドレスを更新するにはサインインが必要です。", 680 + "emailUpdateSubtitle": "新しいメールアドレスと、現在のメールに送信された確認コードを入力してください。", 681 + "emailUpdateTitle": "メールアドレスの更新", 682 + "emailUpdated": "メールアドレスが正常に更新されました。", 683 + "emailUpdatedInfo": "新しいメールアドレスの確認が必要な場合があります。", 684 + "newEmailLabel": "新しいメールアドレス", 685 + "newEmailPlaceholder": "new@example.com", 686 + "updateEmail": "メールを更新", 687 + "updating": "更新中..." 688 }, 689 "resetPassword": { 690 "title": "パスワードリセット", ··· 767 "verificationMethod": "確認方法", 768 "email": "メールアドレス", 769 "emailPlaceholder": "you@example.com", 770 "inviteCode": "招待コード", 771 "inviteCodePlaceholder": "招待コードを入力", 772 "externalDid": "あなたの did:web", 773 "externalDidPlaceholder": "did:web:yourdomain.com", 774 "createButton": "アカウントを作成", 775 "creating": "作成中...", 776 "alreadyHaveAccount": "すでにアカウントをお持ちですか?", ··· 893 "delegation": { 894 "title": "アカウント委任", 895 "controllers": "コントローラー", 896 "controlledAccounts": "管理アカウント", 897 "noControllers": "コントローラーはまだいません", 898 "noControlledAccounts": "管理アカウントはありません", 899 "addController": "コントローラーを追加", 900 "handle": "ハンドル", 901 "did": "DID", 902 "scopeOwner": "オーナー", 903 "scopeViewer": "閲覧者", 904 "scopeCustom": "カスタム", 905 "actAs": "として行動", 906 "auditLog": "監査ログ", 907 "auditLogTitle": "委任監査ログ", ··· 924 "showing": "{start}~{end} / {total}件", 925 "refresh": "更新", 926 "failedToLoadAuditLog": "監査ログの読み込みに失敗しました", 927 "adding": "追加中...", 928 "accessLevel": "アクセスレベル", 929 "addControllerButton": "+ コントローラーを追加", 930 "auditLogDesc": "すべての委任アクティビティを表示", ··· 948 "remove": "削除", 949 "removeConfirm": "このコントローラーを削除しますか?", 950 "viewAuditLog": "監査ログを表示", 951 + "yourAccessLevel": "あなたのアクセスレベル", 952 + "accountCreated": "委任アカウントを作成しました: {handle}" 953 }, 954 "actAs": { 955 "title": "として行動",
+16 -41
frontend/src/locales/ko.json
··· 234 "deleting": "삭제 중...", 235 "messages": { 236 "emailCodeSent": "알림 채널로 인증 코드를 보냈습니다", 237 "emailUpdated": "이메일이 업데이트되었습니다", 238 "emailUpdateFailed": "이메일 업데이트에 실패했습니다", 239 "handleUpdated": "핸들이 업데이트되었습니다", ··· 671 "noPending": "보류 중인 인증이 없습니다.", 672 "noPendingInfo": "최근에 계정을 만들고 인증이 필요한 경우 새 계정을 만들어야 합니다. 이미 계정을 인증한 경우 로그인할 수 있습니다.", 673 "createAccount": "계정 만들기", 674 - "signIn": "로그인" 675 }, 676 "resetPassword": { 677 "title": "비밀번호 재설정", ··· 754 "verificationMethod": "인증 방법", 755 "email": "이메일 주소", 756 "emailPlaceholder": "you@example.com", 757 - "discord": "Discord", 758 - "discordId": "Discord 사용자 ID", 759 - "discordIdPlaceholder": "Discord 사용자 ID", 760 - "discordIdHint": "숫자 Discord 사용자 ID (개발자 모드를 활성화하여 찾기)", 761 - "telegram": "Telegram", 762 - "telegramUsername": "Telegram 사용자 이름", 763 - "telegramUsernamePlaceholder": "@yourusername", 764 - "signal": "Signal", 765 - "signalNumber": "Signal 전화번호", 766 - "signalNumberPlaceholder": "+821012345678", 767 - "signalNumberHint": "국가 코드 포함 (예: 한국 +82)", 768 "inviteCode": "초대 코드", 769 "inviteCodePlaceholder": "초대 코드 입력", 770 - "inviteCodeRequired": "필수", 771 - "didWebDescription": "자체 도메인에서 호스팅되는 DID 아이덴티티를 사용합니다.", 772 - "didWebToggle": "외부 did:web 사용", 773 "externalDid": "귀하의 did:web", 774 "externalDidPlaceholder": "did:web:yourdomain.com", 775 - "dnsVerificationInstructions": "도메인을 인증하려면 이 TXT 레코드를 추가하세요:", 776 - "copyDid": "DID 복사", 777 "createButton": "계정 만들기", 778 "creating": "생성 중...", 779 "alreadyHaveAccount": "이미 계정이 있으신가요?", ··· 896 "delegation": { 897 "title": "계정 위임", 898 "controllers": "컨트롤러", 899 - "controllersDescription": "컨트롤러는 귀하의 계정 관리자로서 행동할 수 있습니다. 귀하가 허용한 작업을 수행하고, 귀하를 대신하여 게시물을 생성하고, 저장소를 수정할 수 있습니다.", 900 "controlledAccounts": "관리 계정", 901 - "controlledAccountsDescription": "귀하가 컨트롤러로 추가된 계정들입니다. 이 계정들에서 허용된 작업을 수행할 수 있습니다.", 902 "noControllers": "아직 컨트롤러가 없습니다", 903 "noControlledAccounts": "관리 계정이 없습니다", 904 "addController": "컨트롤러 추가", 905 - "revokeAccess": "액세스 취소", 906 - "revokeConfirm": "이 컨트롤러의 액세스를 취소하시겠습니까? 귀하의 계정에서 더 이상 작업을 수행할 수 없습니다.", 907 "handle": "핸들", 908 - "handlePlaceholder": "@user.bsky.social", 909 "did": "DID", 910 - "didPlaceholder": "did:plc:...", 911 - "scopes": "권한 수준", 912 "scopeOwner": "소유자", 913 - "scopeOwnerDesc": "전체 관리(모든 작업 수행 가능)", 914 - "scopeAdmin": "관리자", 915 - "scopeAdminDesc": "게시물, 앱 비밀번호, 설정 관리", 916 - "scopeEditor": "편집자", 917 - "scopeEditorDesc": "게시물, 좋아요, 팔로우 생성 및 관리", 918 "scopeViewer": "뷰어", 919 - "scopeViewerDesc": "저장소 및 설정 읽기 전용 액세스", 920 "scopeCustom": "사용자 정의", 921 - "scopeCustomDesc": "개별 권한 선택", 922 - "grantedAt": "허용 일시", 923 - "expiresAt": "만료", 924 - "noExpiration": "무기한", 925 "actAs": "로 활동", 926 "auditLog": "감사 로그", 927 "auditLogTitle": "위임 감사 로그", ··· 944 "showing": "{start}~{end} / {total}개", 945 "refresh": "새로고침", 946 "failedToLoadAuditLog": "감사 로그를 불러오지 못했습니다", 947 - "addControllerTitle": "컨트롤러 추가", 948 - "addControllerDescription": "이 계정에서 지정된 권한으로 작업할 수 있는 사용자를 추가합니다.", 949 - "controllerIdentifier": "컨트롤러 핸들 또는 DID", 950 - "selectScopes": "권한 수준 선택", 951 - "add": "추가", 952 "adding": "추가 중...", 953 - "cancel": "취소", 954 "accessLevel": "액세스 수준", 955 "addControllerButton": "+ 컨트롤러 추가", 956 "auditLogDesc": "모든 위임 활동 보기", ··· 974 "remove": "제거", 975 "removeConfirm": "이 컨트롤러를 제거하시겠습니까?", 976 "viewAuditLog": "감사 로그 보기", 977 - "yourAccessLevel": "귀하의 액세스 수준" 978 }, 979 "actAs": { 980 "title": "로 활동",
··· 234 "deleting": "삭제 중...", 235 "messages": { 236 "emailCodeSent": "알림 채널로 인증 코드를 보냈습니다", 237 + "emailCodeSentToCurrent": "현재 이메일 주소로 인증 코드를 보냈습니다", 238 "emailUpdated": "이메일이 업데이트되었습니다", 239 "emailUpdateFailed": "이메일 업데이트에 실패했습니다", 240 "handleUpdated": "핸들이 업데이트되었습니다", ··· 672 "noPending": "보류 중인 인증이 없습니다.", 673 "noPendingInfo": "최근에 계정을 만들고 인증이 필요한 경우 새 계정을 만들어야 합니다. 이미 계정을 인증한 경우 로그인할 수 있습니다.", 674 "createAccount": "계정 만들기", 675 + "signIn": "로그인", 676 + "backToSettings": "설정으로 돌아가기", 677 + "emailUpdateCodeHelp": "코드가 현재 이메일 주소로 전송되었습니다", 678 + "emailUpdateFailed": "이메일 주소 업데이트 실패", 679 + "emailUpdateRequiresAuth": "이메일 주소를 업데이트하려면 로그인해야 합니다.", 680 + "emailUpdateSubtitle": "새 이메일 주소와 현재 이메일로 전송된 인증 코드를 입력하세요.", 681 + "emailUpdateTitle": "이메일 주소 업데이트", 682 + "emailUpdated": "이메일 주소가 성공적으로 업데이트되었습니다.", 683 + "emailUpdatedInfo": "새 이메일 주소를 인증해야 할 수 있습니다.", 684 + "newEmailLabel": "새 이메일 주소", 685 + "newEmailPlaceholder": "new@example.com", 686 + "updateEmail": "이메일 업데이트", 687 + "updating": "업데이트 중..." 688 }, 689 "resetPassword": { 690 "title": "비밀번호 재설정", ··· 767 "verificationMethod": "인증 방법", 768 "email": "이메일 주소", 769 "emailPlaceholder": "you@example.com", 770 "inviteCode": "초대 코드", 771 "inviteCodePlaceholder": "초대 코드 입력", 772 "externalDid": "귀하의 did:web", 773 "externalDidPlaceholder": "did:web:yourdomain.com", 774 "createButton": "계정 만들기", 775 "creating": "생성 중...", 776 "alreadyHaveAccount": "이미 계정이 있으신가요?", ··· 893 "delegation": { 894 "title": "계정 위임", 895 "controllers": "컨트롤러", 896 "controlledAccounts": "관리 계정", 897 "noControllers": "아직 컨트롤러가 없습니다", 898 "noControlledAccounts": "관리 계정이 없습니다", 899 "addController": "컨트롤러 추가", 900 "handle": "핸들", 901 "did": "DID", 902 "scopeOwner": "소유자", 903 "scopeViewer": "뷰어", 904 "scopeCustom": "사용자 정의", 905 "actAs": "로 활동", 906 "auditLog": "감사 로그", 907 "auditLogTitle": "위임 감사 로그", ··· 924 "showing": "{start}~{end} / {total}개", 925 "refresh": "새로고침", 926 "failedToLoadAuditLog": "감사 로그를 불러오지 못했습니다", 927 "adding": "추가 중...", 928 "accessLevel": "액세스 수준", 929 "addControllerButton": "+ 컨트롤러 추가", 930 "auditLogDesc": "모든 위임 활동 보기", ··· 948 "remove": "제거", 949 "removeConfirm": "이 컨트롤러를 제거하시겠습니까?", 950 "viewAuditLog": "감사 로그 보기", 951 + "yourAccessLevel": "귀하의 액세스 수준", 952 + "accountCreated": "위임 계정이 생성되었습니다: {handle}" 953 }, 954 "actAs": { 955 "title": "로 활동",
+16 -41
frontend/src/locales/sv.json
··· 234 "deleting": "Raderar...", 235 "messages": { 236 "emailCodeSent": "Verifieringskod skickad till din meddelandekanal", 237 "emailUpdated": "E-post uppdaterad", 238 "emailUpdateFailed": "Kunde inte uppdatera e-post", 239 "handleUpdated": "Användarnamn uppdaterat", ··· 671 "noPending": "Ingen väntande verifiering hittades.", 672 "noPendingInfo": "Om du nyligen skapade ett konto och behöver verifiera det kan du behöva skapa ett nytt konto. Om du redan verifierat ditt konto kan du logga in.", 673 "createAccount": "Skapa konto", 674 - "signIn": "Logga in" 675 }, 676 "resetPassword": { 677 "title": "Återställ lösenord", ··· 754 "verificationMethod": "Verifieringsmetod", 755 "email": "E-postadress", 756 "emailPlaceholder": "du@exempel.se", 757 - "discord": "Discord", 758 - "discordId": "Discord användar-ID", 759 - "discordIdPlaceholder": "Ditt Discord användar-ID", 760 - "discordIdHint": "Ditt numeriska Discord användar-ID (aktivera Utvecklarläge för att hitta det)", 761 - "telegram": "Telegram", 762 - "telegramUsername": "Telegram-användarnamn", 763 - "telegramUsernamePlaceholder": "@dittanvändarnamn", 764 - "signal": "Signal", 765 - "signalNumber": "Signal-telefonnummer", 766 - "signalNumberPlaceholder": "+46701234567", 767 - "signalNumberHint": "Inkludera landskod (t.ex. +46 för Sverige)", 768 "inviteCode": "Inbjudningskod", 769 "inviteCodePlaceholder": "Ange din inbjudningskod", 770 - "inviteCodeRequired": "krävs", 771 - "didWebDescription": "Använd en DID-identitet som är lagrad på din egen domän.", 772 - "didWebToggle": "Använd extern did:web", 773 "externalDid": "Din did:web", 774 "externalDidPlaceholder": "did:web:dindomän.se", 775 - "dnsVerificationInstructions": "För att verifiera din domän, lägg till denna TXT-post:", 776 - "copyDid": "Kopiera DID", 777 "createButton": "Skapa konto", 778 "creating": "Skapar...", 779 "alreadyHaveAccount": "Har du redan ett konto?", ··· 896 "delegation": { 897 "title": "Kontodelegering", 898 "controllers": "Kontrollanter", 899 - "controllersDescription": "Kontrollanter kan agera som administratörer för ditt konto. De kan utföra åtgärder du tillåter, skapa inlägg för din räkning och modifiera din dataförvaring.", 900 "controlledAccounts": "Kontrollerade konton", 901 - "controlledAccountsDescription": "Detta är konton där du har lagts till som kontrollant. Du kan utföra tillåtna åtgärder på dessa konton.", 902 "noControllers": "Inga kontrollanter ännu", 903 "noControlledAccounts": "Inga kontrollerade konton", 904 "addController": "Lägg till kontrollant", 905 - "revokeAccess": "Återkalla åtkomst", 906 - "revokeConfirm": "Återkalla denna kontrollants åtkomst? De kommer inte längre kunna utföra åtgärder på ditt konto.", 907 "handle": "Användarnamn", 908 - "handlePlaceholder": "@user.bsky.social", 909 "did": "DID", 910 - "didPlaceholder": "did:plc:...", 911 - "scopes": "Behörighetsnivå", 912 "scopeOwner": "Ägare", 913 - "scopeOwnerDesc": "Fullständig kontroll (kan utföra alla åtgärder)", 914 - "scopeAdmin": "Administratör", 915 - "scopeAdminDesc": "Hantera inlägg, applösenord, inställningar", 916 - "scopeEditor": "Redaktör", 917 - "scopeEditorDesc": "Skapa och hantera inlägg, gillningar, följningar", 918 "scopeViewer": "Läsare", 919 - "scopeViewerDesc": "Endast läsåtkomst till dataförvaring och inställningar", 920 "scopeCustom": "Anpassad", 921 - "scopeCustomDesc": "Välj individuella behörigheter", 922 - "grantedAt": "Beviljad", 923 - "expiresAt": "Upphör", 924 - "noExpiration": "Ingen utgång", 925 "actAs": "Agera som", 926 "auditLog": "Granskningslogg", 927 "auditLogTitle": "Delegerings-granskningslogg", ··· 944 "showing": "{start}–{end} av {total}", 945 "refresh": "Uppdatera", 946 "failedToLoadAuditLog": "Kunde inte ladda granskningsloggen", 947 - "addControllerTitle": "Lägg till kontrollant", 948 - "addControllerDescription": "Lägg till en användare som kan utföra åtgärder på detta konto med specificerade behörigheter.", 949 - "controllerIdentifier": "Kontrollantens användarnamn eller DID", 950 - "selectScopes": "Välj behörighetsnivå", 951 - "add": "Lägg till", 952 "adding": "Lägger till...", 953 - "cancel": "Avbryt", 954 "accessLevel": "Åtkomstnivå", 955 "addControllerButton": "+ Lägg till kontrollant", 956 "auditLogDesc": "Visa all delegeringsaktivitet", ··· 974 "remove": "Ta bort", 975 "removeConfirm": "Vill du ta bort denna kontrollant?", 976 "viewAuditLog": "Visa granskningslogg", 977 - "yourAccessLevel": "Din åtkomstnivå" 978 }, 979 "actAs": { 980 "title": "Agera som",
··· 234 "deleting": "Raderar...", 235 "messages": { 236 "emailCodeSent": "Verifieringskod skickad till din meddelandekanal", 237 + "emailCodeSentToCurrent": "Verifieringskod skickad till din nuvarande e-postadress", 238 "emailUpdated": "E-post uppdaterad", 239 "emailUpdateFailed": "Kunde inte uppdatera e-post", 240 "handleUpdated": "Användarnamn uppdaterat", ··· 672 "noPending": "Ingen väntande verifiering hittades.", 673 "noPendingInfo": "Om du nyligen skapade ett konto och behöver verifiera det kan du behöva skapa ett nytt konto. Om du redan verifierat ditt konto kan du logga in.", 674 "createAccount": "Skapa konto", 675 + "signIn": "Logga in", 676 + "backToSettings": "Tillbaka till inställningar", 677 + "emailUpdateCodeHelp": "Koden skickades till din nuvarande e-postadress", 678 + "emailUpdateFailed": "Kunde inte uppdatera e-postadress", 679 + "emailUpdateRequiresAuth": "Du måste vara inloggad för att uppdatera din e-postadress.", 680 + "emailUpdateSubtitle": "Ange din nya e-postadress och verifieringskoden som skickades till din nuvarande e-post.", 681 + "emailUpdateTitle": "Uppdatera e-postadress", 682 + "emailUpdated": "Din e-postadress har uppdaterats.", 683 + "emailUpdatedInfo": "Du kan behöva verifiera din nya e-postadress.", 684 + "newEmailLabel": "Ny e-postadress", 685 + "newEmailPlaceholder": "ny@exempel.se", 686 + "updateEmail": "Uppdatera e-post", 687 + "updating": "Uppdaterar..." 688 }, 689 "resetPassword": { 690 "title": "Återställ lösenord", ··· 767 "verificationMethod": "Verifieringsmetod", 768 "email": "E-postadress", 769 "emailPlaceholder": "du@exempel.se", 770 "inviteCode": "Inbjudningskod", 771 "inviteCodePlaceholder": "Ange din inbjudningskod", 772 "externalDid": "Din did:web", 773 "externalDidPlaceholder": "did:web:dindomän.se", 774 "createButton": "Skapa konto", 775 "creating": "Skapar...", 776 "alreadyHaveAccount": "Har du redan ett konto?", ··· 893 "delegation": { 894 "title": "Kontodelegering", 895 "controllers": "Kontrollanter", 896 "controlledAccounts": "Kontrollerade konton", 897 "noControllers": "Inga kontrollanter ännu", 898 "noControlledAccounts": "Inga kontrollerade konton", 899 "addController": "Lägg till kontrollant", 900 "handle": "Användarnamn", 901 "did": "DID", 902 "scopeOwner": "Ägare", 903 "scopeViewer": "Läsare", 904 "scopeCustom": "Anpassad", 905 "actAs": "Agera som", 906 "auditLog": "Granskningslogg", 907 "auditLogTitle": "Delegerings-granskningslogg", ··· 924 "showing": "{start}–{end} av {total}", 925 "refresh": "Uppdatera", 926 "failedToLoadAuditLog": "Kunde inte ladda granskningsloggen", 927 "adding": "Lägger till...", 928 "accessLevel": "Åtkomstnivå", 929 "addControllerButton": "+ Lägg till kontrollant", 930 "auditLogDesc": "Visa all delegeringsaktivitet", ··· 948 "remove": "Ta bort", 949 "removeConfirm": "Vill du ta bort denna kontrollant?", 950 "viewAuditLog": "Visa granskningslogg", 951 + "yourAccessLevel": "Din åtkomstnivå", 952 + "accountCreated": "Skapade delegerat konto: {handle}" 953 }, 954 "actAs": { 955 "title": "Agera som",
+16 -25
frontend/src/locales/zh.json
··· 234 "deleting": "删除中...", 235 "messages": { 236 "emailCodeSent": "验证码已发送到您的通知渠道", 237 "emailUpdated": "邮箱更新成功", 238 "emailUpdateFailed": "邮箱更新失败", 239 "handleUpdated": "用户名更新成功", ··· 671 "continue": "继续", 672 "identifierLabel": "邮箱或标识符", 673 "identifierPlaceholder": "you@example.com", 674 - "identifierHelp": "接收验证码的邮箱地址或标识符" 675 }, 676 "resetPassword": { 677 "title": "重置密码", ··· 821 "passkeysNotSupported": "此浏览器不支持通行密钥。请使用其他浏览器或使用密码注册。", 822 "passkeyCancelled": "通行密钥创建已取消", 823 "passkeyFailed": "通行密钥注册失败" 824 - } 825 }, 826 "trustedDevices": { 827 "title": "受信任设备", ··· 879 "delegation": { 880 "title": "账户委托", 881 "controllers": "控制者", 882 - "controllersDescription": "控制者可以作为您账户的管理员。他们可以执行您允许的操作,代表您发布帖子,以及修改您的数据仓库。", 883 "controlledAccounts": "受控账户", 884 - "controlledAccountsDescription": "这些是您被添加为控制者的账户。您可以在这些账户上执行允许的操作。", 885 "noControllers": "暂无控制者", 886 "noControlledAccounts": "无受控账户", 887 "addController": "添加控制者", 888 - "revokeAccess": "撤销访问", 889 - "revokeConfirm": "撤销此控制者的访问权限?他们将无法再在您的账户上执行操作。", 890 "handle": "用户名", 891 - "handlePlaceholder": "@user.bsky.social", 892 "did": "DID", 893 - "didPlaceholder": "did:plc:...", 894 - "scopes": "权限级别", 895 "scopeOwner": "所有者", 896 - "scopeOwnerDesc": "完全控制(可执行所有操作)", 897 - "scopeAdmin": "管理员", 898 - "scopeAdminDesc": "管理帖子、应用专用密码、设置", 899 - "scopeEditor": "编辑者", 900 - "scopeEditorDesc": "创建和管理帖子、点赞、关注", 901 "scopeViewer": "查看者", 902 - "scopeViewerDesc": "只读访问数据仓库和设置", 903 "scopeCustom": "自定义", 904 - "scopeCustomDesc": "选择单独的权限", 905 - "grantedAt": "授权时间", 906 - "expiresAt": "过期时间", 907 - "noExpiration": "永不过期", 908 "actAs": "代理操作", 909 "auditLog": "审计日志", 910 "auditLogTitle": "委托审计日志", ··· 928 "showing": "{start}–{end} / 共{total}条", 929 "refresh": "刷新", 930 "failedToLoadAuditLog": "加载审计日志失败", 931 - "addControllerTitle": "添加控制者", 932 - "addControllerDescription": "添加一个可以在此账户上执行指定权限操作的用户。", 933 - "controllerIdentifier": "控制者用户名或 DID", 934 - "selectScopes": "选择权限级别", 935 - "add": "添加", 936 "adding": "添加中...", 937 - "cancel": "取消", 938 "accessLevel": "访问级别", 939 "addControllerButton": "+ 添加控制者", 940 "auditLogDesc": "查看所有委托活动",
··· 234 "deleting": "删除中...", 235 "messages": { 236 "emailCodeSent": "验证码已发送到您的通知渠道", 237 + "emailCodeSentToCurrent": "验证码已发送到您当前的邮箱地址", 238 "emailUpdated": "邮箱更新成功", 239 "emailUpdateFailed": "邮箱更新失败", 240 "handleUpdated": "用户名更新成功", ··· 672 "continue": "继续", 673 "identifierLabel": "邮箱或标识符", 674 "identifierPlaceholder": "you@example.com", 675 + "identifierHelp": "接收验证码的邮箱地址或标识符", 676 + "backToSettings": "返回设置", 677 + "emailUpdateCodeHelp": "验证码已发送到您当前的邮箱地址", 678 + "emailUpdateFailed": "更新邮箱地址失败", 679 + "emailUpdateRequiresAuth": "您需要登录才能更新邮箱地址。", 680 + "emailUpdateSubtitle": "输入您的新邮箱地址和发送到当前邮箱的验证码。", 681 + "emailUpdateTitle": "更新邮箱地址", 682 + "emailUpdated": "您的邮箱地址已成功更新。", 683 + "emailUpdatedInfo": "您可能需要验证新的邮箱地址。", 684 + "newEmailLabel": "新邮箱地址", 685 + "newEmailPlaceholder": "new@example.com", 686 + "updateEmail": "更新邮箱", 687 + "updating": "更新中..." 688 }, 689 "resetPassword": { 690 "title": "重置密码", ··· 834 "passkeysNotSupported": "此浏览器不支持通行密钥。请使用其他浏览器或使用密码注册。", 835 "passkeyCancelled": "通行密钥创建已取消", 836 "passkeyFailed": "通行密钥注册失败" 837 + }, 838 + "didWebWarning1Detail": "您的身份将是 {did}。" 839 }, 840 "trustedDevices": { 841 "title": "受信任设备", ··· 893 "delegation": { 894 "title": "账户委托", 895 "controllers": "控制者", 896 "controlledAccounts": "受控账户", 897 "noControllers": "暂无控制者", 898 "noControlledAccounts": "无受控账户", 899 "addController": "添加控制者", 900 "handle": "用户名", 901 "did": "DID", 902 "scopeOwner": "所有者", 903 "scopeViewer": "查看者", 904 "scopeCustom": "自定义", 905 "actAs": "代理操作", 906 "auditLog": "审计日志", 907 "auditLogTitle": "委托审计日志", ··· 925 "showing": "{start}–{end} / 共{total}条", 926 "refresh": "刷新", 927 "failedToLoadAuditLog": "加载审计日志失败", 928 "adding": "添加中...", 929 "accessLevel": "访问级别", 930 "addControllerButton": "+ 添加控制者", 931 "auditLogDesc": "查看所有委托活动",
+17 -23
frontend/src/routes/Settings.svelte
··· 56 if (message?.text === text) message = null 57 }, 5000) 58 } 59 - async function handleRequestEmailUpdate(e: Event) { 60 - e.preventDefault() 61 - if (!auth.session || !newEmail) return 62 emailLoading = true 63 message = null 64 try { 65 - const result = await api.requestEmailUpdate(auth.session.accessJwt, newEmail) 66 emailTokenRequired = result.tokenRequired 67 if (emailTokenRequired) { 68 - showMessage('success', $_('settings.messages.emailCodeSent')) 69 } else { 70 - await api.updateEmail(auth.session.accessJwt, newEmail) 71 - await refreshSession() 72 - showMessage('success', $_('settings.messages.emailUpdated')) 73 - newEmail = '' 74 } 75 } catch (e) { 76 showMessage('error', e instanceof ApiError ? e.message : $_('settings.messages.emailUpdateFailed')) ··· 244 required 245 /> 246 </div> 247 - <div class="actions"> 248 - <button type="submit" disabled={emailLoading || !emailToken}> 249 - {emailLoading ? $_('settings.updating') : $_('settings.confirmEmailChange')} 250 - </button> 251 - <button type="button" class="secondary" onclick={() => { emailTokenRequired = false; emailToken = '' }}> 252 - {$_('common.cancel')} 253 - </button> 254 - </div> 255 - </form> 256 - {:else} 257 - <form onsubmit={handleRequestEmailUpdate}> 258 <div class="field"> 259 <label for="new-email">{$_('settings.newEmail')}</label> 260 <input ··· 266 required 267 /> 268 </div> 269 - <button type="submit" disabled={emailLoading || !newEmail}> 270 - {emailLoading ? $_('settings.requesting') : $_('settings.changeEmailButton')} 271 - </button> 272 </form> 273 {/if} 274 </section> 275 <section>
··· 56 if (message?.text === text) message = null 57 }, 5000) 58 } 59 + async function handleRequestEmailUpdate() { 60 + if (!auth.session) return 61 emailLoading = true 62 message = null 63 try { 64 + const result = await api.requestEmailUpdate(auth.session.accessJwt) 65 emailTokenRequired = result.tokenRequired 66 if (emailTokenRequired) { 67 + showMessage('success', $_('settings.messages.emailCodeSentToCurrent')) 68 } else { 69 + emailTokenRequired = true 70 } 71 } catch (e) { 72 showMessage('error', e instanceof ApiError ? e.message : $_('settings.messages.emailUpdateFailed')) ··· 240 required 241 /> 242 </div> 243 <div class="field"> 244 <label for="new-email">{$_('settings.newEmail')}</label> 245 <input ··· 251 required 252 /> 253 </div> 254 + <div class="actions"> 255 + <button type="submit" disabled={emailLoading || !emailToken || !newEmail}> 256 + {emailLoading ? $_('settings.updating') : $_('settings.confirmEmailChange')} 257 + </button> 258 + <button type="button" class="secondary" onclick={() => { emailTokenRequired = false; emailToken = ''; newEmail = '' }}> 259 + {$_('common.cancel')} 260 + </button> 261 + </div> 262 </form> 263 + {:else} 264 + <button onclick={handleRequestEmailUpdate} disabled={emailLoading}> 265 + {emailLoading ? $_('settings.requesting') : $_('settings.changeEmailButton')} 266 + </button> 267 {/if} 268 </section> 269 <section>
+94 -3
frontend/src/routes/Verify.svelte
··· 13 channel: string 14 } 15 16 - type VerificationMode = 'signup' | 'token' 17 18 let mode = $state<VerificationMode>('signup') 19 let pendingVerification = $state<PendingVerification | null>(null) 20 let verificationCode = $state('') 21 let identifier = $state('') ··· 50 onMount(async () => { 51 const params = parseQueryParams() 52 53 - if (params.token) { 54 mode = 'token' 55 verificationCode = params.token 56 if (params.identifier) { ··· 134 } 135 } 136 137 async function handleResendCode() { 138 if (mode === 'signup') { 139 if (!pendingVerification || resendingCode) return ··· 198 {:else if success} 199 <div class="success-container"> 200 <h1>{$_('verify.verified')}</h1> 201 - {#if successPurpose === 'migration' || successPurpose === 'signup'} 202 <p class="subtitle">{$_('verify.channelVerified', { values: { channel: channelLabel(successChannel || '') } })}</p> 203 <p class="info-text">{$_('verify.canNowSignIn')}</p> 204 <div class="actions"> ··· 213 </div> 214 {/if} 215 </div> 216 {:else if mode === 'token'} 217 <h1>{$_('verify.tokenTitle')}</h1> 218 <p class="subtitle">{$_('verify.tokenSubtitle')}</p>
··· 13 channel: string 14 } 15 16 + type VerificationMode = 'signup' | 'token' | 'email-update' 17 18 let mode = $state<VerificationMode>('signup') 19 + let newEmail = $state('') 20 let pendingVerification = $state<PendingVerification | null>(null) 21 let verificationCode = $state('') 22 let identifier = $state('') ··· 51 onMount(async () => { 52 const params = parseQueryParams() 53 54 + if (params.type === 'email-update') { 55 + mode = 'email-update' 56 + if (params.token) { 57 + verificationCode = params.token 58 + } 59 + } else if (params.token) { 60 mode = 'token' 61 verificationCode = params.token 62 if (params.identifier) { ··· 140 } 141 } 142 143 + async function handleEmailUpdate() { 144 + if (!verificationCode.trim() || !newEmail.trim()) return 145 + 146 + if (!auth.session) { 147 + error = $_('verify.emailUpdateRequiresAuth') 148 + return 149 + } 150 + 151 + submitting = true 152 + error = null 153 + 154 + try { 155 + await api.updateEmail(auth.session.accessJwt, newEmail.trim(), verificationCode.trim()) 156 + success = true 157 + successPurpose = 'email-update' 158 + successChannel = 'email' 159 + } catch (e: any) { 160 + if (e instanceof ApiError) { 161 + error = e.message 162 + } else { 163 + error = $_('verify.emailUpdateFailed') 164 + } 165 + } finally { 166 + submitting = false 167 + } 168 + } 169 + 170 async function handleResendCode() { 171 if (mode === 'signup') { 172 if (!pendingVerification || resendingCode) return ··· 231 {:else if success} 232 <div class="success-container"> 233 <h1>{$_('verify.verified')}</h1> 234 + {#if successPurpose === 'email-update'} 235 + <p class="subtitle">{$_('verify.emailUpdated')}</p> 236 + <p class="info-text">{$_('verify.emailUpdatedInfo')}</p> 237 + <div class="actions"> 238 + <a href="#/settings" class="btn">{$_('verify.backToSettings')}</a> 239 + </div> 240 + {:else if successPurpose === 'migration' || successPurpose === 'signup'} 241 <p class="subtitle">{$_('verify.channelVerified', { values: { channel: channelLabel(successChannel || '') } })}</p> 242 <p class="info-text">{$_('verify.canNowSignIn')}</p> 243 <div class="actions"> ··· 252 </div> 253 {/if} 254 </div> 255 + {:else if mode === 'email-update'} 256 + <h1>{$_('verify.emailUpdateTitle')}</h1> 257 + <p class="subtitle">{$_('verify.emailUpdateSubtitle')}</p> 258 + 259 + {#if !auth.session} 260 + <div class="message warning">{$_('verify.emailUpdateRequiresAuth')}</div> 261 + <div class="actions"> 262 + <a href="#/login" class="btn">{$_('verify.signIn')}</a> 263 + </div> 264 + {:else} 265 + {#if error} 266 + <div class="message error">{error}</div> 267 + {/if} 268 + 269 + <form onsubmit={(e) => { e.preventDefault(); handleEmailUpdate(); }}> 270 + <div class="field"> 271 + <label for="new-email">{$_('verify.newEmailLabel')}</label> 272 + <input 273 + id="new-email" 274 + type="email" 275 + bind:value={newEmail} 276 + placeholder={$_('verify.newEmailPlaceholder')} 277 + disabled={submitting} 278 + required 279 + autocomplete="email" 280 + /> 281 + </div> 282 + 283 + <div class="field"> 284 + <label for="verification-code">{$_('verify.codeLabel')}</label> 285 + <input 286 + id="verification-code" 287 + type="text" 288 + bind:value={verificationCode} 289 + placeholder={$_('verify.codePlaceholder')} 290 + disabled={submitting} 291 + required 292 + autocomplete="off" 293 + class="token-input" 294 + /> 295 + <p class="field-help">{$_('verify.emailUpdateCodeHelp')}</p> 296 + </div> 297 + 298 + <button type="submit" disabled={submitting || !verificationCode.trim() || !newEmail.trim()}> 299 + {submitting ? $_('verify.updating') : $_('verify.updateEmail')} 300 + </button> 301 + </form> 302 + 303 + <p class="link-text"> 304 + <a href="#/settings">{$_('verify.backToSettings')}</a> 305 + </p> 306 + {/if} 307 {:else if mode === 'token'} 308 <h1>{$_('verify.tokenTitle')}</h1> 309 <p class="subtitle">{$_('verify.tokenSubtitle')}</p>
+200 -175
src/api/server/email.rs
··· 1 use crate::api::ApiError; 2 use crate::state::{AppState, RateLimitKind}; 3 use axum::{ 4 Json, ··· 10 use serde_json::json; 11 use tracing::{error, info, warn}; 12 13 - #[derive(Deserialize)] 14 - #[serde(rename_all = "camelCase")] 15 - pub struct RequestEmailUpdateInput { 16 - pub email: String, 17 - } 18 - 19 pub async fn request_email_update( 20 State(state): State<AppState>, 21 headers: axum::http::HeaderMap, 22 - Json(input): Json<RequestEmailUpdateInput>, 23 ) -> Response { 24 let client_ip = crate::rate_limit::extract_client_ip(&headers, None); 25 if !state ··· 37 .into_response(); 38 } 39 40 - let token = match crate::auth::extract_bearer_token_from_header( 41 - headers.get("Authorization").and_then(|h| h.to_str().ok()), 42 - ) { 43 - Some(t) => t, 44 - None => { 45 - return ( 46 - StatusCode::UNAUTHORIZED, 47 - Json(json!({"error": "AuthenticationRequired"})), 48 - ) 49 - .into_response(); 50 - } 51 - }; 52 - 53 - let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; 54 - let auth_user = match auth_result { 55 - Ok(user) => user, 56 - Err(e) => return ApiError::from(e).into_response(), 57 - }; 58 - 59 if let Err(e) = crate::auth::scope_check::check_account_scope( 60 - auth_user.is_oauth, 61 - auth_user.scope.as_deref(), 62 crate::oauth::scopes::AccountAttr::Email, 63 crate::oauth::scopes::AccountAction::Manage, 64 ) { 65 return e; 66 } 67 68 - let did = auth_user.did.clone(); 69 - let user = match sqlx::query!("SELECT id, handle, email FROM users WHERE did = $1", did) 70 - .fetch_optional(&state.db) 71 - .await 72 { 73 Ok(Some(row)) => row, 74 - _ => { 75 return ( 76 StatusCode::INTERNAL_SERVER_ERROR, 77 Json(json!({"error": "InternalError"})), ··· 80 } 81 }; 82 83 - let user_id = user.id; 84 - let handle = user.handle; 85 - let current_email = user.email; 86 - let email = input.email.trim().to_lowercase(); 87 88 - if !crate::api::validation::is_valid_email(&email) { 89 - return ( 90 - StatusCode::BAD_REQUEST, 91 - Json(json!({"error": "InvalidEmail", "message": "Invalid email format"})), 92 - ) 93 - .into_response(); 94 - } 95 96 - if current_email.as_ref().map(|e| e.to_lowercase()) == Some(email.clone()) { 97 - return (StatusCode::OK, Json(json!({ "tokenRequired": false }))).into_response(); 98 - } 99 - 100 - let exists = sqlx::query!( 101 - "SELECT 1 as one FROM users WHERE LOWER(email) = $1 AND id != $2", 102 - email, 103 - user_id 104 - ) 105 - .fetch_optional(&state.db) 106 - .await; 107 - 108 - if let Ok(Some(_)) = exists { 109 - return ( 110 - StatusCode::BAD_REQUEST, 111 - Json(json!({"error": "EmailTaken", "message": "Email already taken"})), 112 - ) 113 - .into_response(); 114 - } 115 116 - if let Err(e) = crate::api::notification_prefs::request_channel_verification( 117 - &state.db, 118 - user_id, 119 - &did, 120 - "email", 121 - &email, 122 - Some(&handle), 123 - ) 124 - .await 125 - { 126 - error!("Failed to request email verification: {}", e); 127 - return ( 128 - StatusCode::INTERNAL_SERVER_ERROR, 129 - Json(json!({"error": "InternalError"})), 130 ) 131 - .into_response(); 132 } 133 134 - info!("Email update requested for user {}", user_id); 135 - (StatusCode::OK, Json(json!({ "tokenRequired": true }))).into_response() 136 } 137 138 #[derive(Deserialize)] ··· 145 pub async fn confirm_email( 146 State(state): State<AppState>, 147 headers: axum::http::HeaderMap, 148 Json(input): Json<ConfirmEmailInput>, 149 ) -> Response { 150 let client_ip = crate::rate_limit::extract_client_ip(&headers, None); 151 if !state 152 - .check_rate_limit(RateLimitKind::AppPassword, &client_ip) 153 .await 154 { 155 warn!(ip = %client_ip, "Confirm email rate limit exceeded"); ··· 163 .into_response(); 164 } 165 166 - let token = match crate::auth::extract_bearer_token_from_header( 167 - headers.get("Authorization").and_then(|h| h.to_str().ok()), 168 - ) { 169 - Some(t) => t, 170 - None => { 171 - return ( 172 - StatusCode::UNAUTHORIZED, 173 - Json(json!({"error": "AuthenticationRequired"})), 174 - ) 175 - .into_response(); 176 - } 177 - }; 178 - 179 - let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; 180 - let auth_user = match auth_result { 181 - Ok(user) => user, 182 - Err(e) => return ApiError::from(e).into_response(), 183 - }; 184 - 185 if let Err(e) = crate::auth::scope_check::check_account_scope( 186 - auth_user.is_oauth, 187 - auth_user.scope.as_deref(), 188 crate::oauth::scopes::AccountAttr::Email, 189 crate::oauth::scopes::AccountAction::Manage, 190 ) { 191 return e; 192 } 193 194 - let did = auth_user.did; 195 - let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) 196 - .fetch_one(&state.db) 197 - .await 198 { 199 - Ok(id) => id, 200 - Err(_) => { 201 return ( 202 StatusCode::INTERNAL_SERVER_ERROR, 203 Json(json!({"error": "InternalError"})), ··· 206 } 207 }; 208 209 - let email = input.email.trim().to_lowercase(); 210 let confirmation_code = 211 crate::auth::verification_token::normalize_token_input(input.token.trim()); 212 213 - let verified = crate::auth::verification_token::verify_channel_update_token( 214 &confirmation_code, 215 "email", 216 - &email, 217 ); 218 219 match verified { ··· 245 } 246 247 let update = sqlx::query!( 248 - "UPDATE users SET email = $1, email_verified = TRUE, updated_at = NOW() WHERE id = $2", 249 - email, 250 - user_id 251 ) 252 .execute(&state.db) 253 .await; 254 255 if let Err(e) = update { 256 - error!("DB error finalizing email update: {:?}", e); 257 - if e.as_database_error() 258 - .map(|db_err| db_err.is_unique_violation()) 259 - .unwrap_or(false) 260 - { 261 - return ( 262 - StatusCode::BAD_REQUEST, 263 - Json(json!({"error": "EmailTaken", "message": "Email already taken"})), 264 - ) 265 - .into_response(); 266 - } 267 return ( 268 StatusCode::INTERNAL_SERVER_ERROR, 269 Json(json!({"error": "InternalError"})), ··· 271 .into_response(); 272 } 273 274 - info!("Email updated for user {}", user_id); 275 (StatusCode::OK, Json(json!({}))).into_response() 276 } 277 ··· 289 headers: axum::http::HeaderMap, 290 Json(input): Json<UpdateEmailInput>, 291 ) -> Response { 292 - let token = match crate::auth::extract_bearer_token_from_header( 293 headers.get("Authorization").and_then(|h| h.to_str().ok()), 294 ) { 295 Some(t) => t, ··· 302 } 303 }; 304 305 - let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; 306 let auth_user = match auth_result { 307 Ok(user) => user, 308 Err(e) => return ApiError::from(e).into_response(), ··· 318 } 319 320 let did = auth_user.did; 321 - let user = match sqlx::query!("SELECT id, email FROM users WHERE did = $1", did) 322 - .fetch_optional(&state.db) 323 - .await 324 { 325 Ok(Some(row)) => row, 326 - _ => { 327 return ( 328 StatusCode::INTERNAL_SERVER_ERROR, 329 Json(json!({"error": "InternalError"})), ··· 333 }; 334 335 let user_id = user.id; 336 - let current_email = user.email; 337 let new_email = input.email.trim().to_lowercase(); 338 339 if !crate::api::validation::is_valid_email(&new_email) { 340 return ( 341 StatusCode::BAD_REQUEST, 342 - Json(json!({"error": "InvalidEmail", "message": "Invalid email format"})), 343 ) 344 .into_response(); 345 } ··· 350 return (StatusCode::OK, Json(json!({}))).into_response(); 351 } 352 353 - let confirmation_token = match &input.token { 354 - Some(t) => crate::auth::verification_token::normalize_token_input(t.trim()), 355 - None => { 356 - return ( 357 - StatusCode::BAD_REQUEST, 358 - Json(json!({"error": "TokenRequired", "message": "Token required. Call requestEmailUpdate first."})), 359 - ) 360 - .into_response(); 361 - } 362 - }; 363 364 - let verified = crate::auth::verification_token::verify_channel_update_token( 365 - &confirmation_token, 366 - "email", 367 - &new_email, 368 - ); 369 370 - match verified { 371 - Ok(token_data) => { 372 - if token_data.did != did { 373 return ( 374 StatusCode::BAD_REQUEST, 375 - Json( 376 - json!({"error": "InvalidToken", "message": "Token does not match account"}), 377 - ), 378 ) 379 .into_response(); 380 } 381 } 382 - Err(crate::auth::verification_token::VerifyError::Expired) => { 383 - return ( 384 - StatusCode::BAD_REQUEST, 385 - Json(json!({"error": "ExpiredToken", "message": "Token has expired"})), 386 - ) 387 - .into_response(); 388 - } 389 - Err(_) => { 390 - return ( 391 - StatusCode::BAD_REQUEST, 392 - Json(json!({"error": "InvalidToken", "message": "Invalid token"})), 393 - ) 394 - .into_response(); 395 - } 396 } 397 398 let exists = sqlx::query!( ··· 406 if let Ok(Some(_)) = exists { 407 return ( 408 StatusCode::BAD_REQUEST, 409 - Json(json!({"error": "InvalidRequest", "message": "Email already in use"})), 410 ) 411 .into_response(); 412 } 413 414 - let update = sqlx::query!( 415 - "UPDATE users SET email = $1, email_verified = TRUE, updated_at = NOW() WHERE id = $2", 416 new_email, 417 user_id 418 ) ··· 420 .await; 421 422 if let Err(e) = update { 423 - error!("DB error finalizing email update: {:?}", e); 424 if e.as_database_error() 425 - .map(|db_err| db_err.is_unique_violation()) 426 .unwrap_or(false) 427 { 428 return ( 429 StatusCode::BAD_REQUEST, 430 - Json(json!({"error": "InvalidRequest", "message": "Email already in use"})), 431 ) 432 .into_response(); 433 } ··· 436 Json(json!({"error": "InternalError"})), 437 ) 438 .into_response(); 439 } 440 441 match sqlx::query!(
··· 1 use crate::api::ApiError; 2 + use crate::auth::BearerAuth; 3 use crate::state::{AppState, RateLimitKind}; 4 use axum::{ 5 Json, ··· 11 use serde_json::json; 12 use tracing::{error, info, warn}; 13 14 pub async fn request_email_update( 15 State(state): State<AppState>, 16 headers: axum::http::HeaderMap, 17 + auth: BearerAuth, 18 ) -> Response { 19 let client_ip = crate::rate_limit::extract_client_ip(&headers, None); 20 if !state ··· 32 .into_response(); 33 } 34 35 if let Err(e) = crate::auth::scope_check::check_account_scope( 36 + auth.0.is_oauth, 37 + auth.0.scope.as_deref(), 38 crate::oauth::scopes::AccountAttr::Email, 39 crate::oauth::scopes::AccountAction::Manage, 40 ) { 41 return e; 42 } 43 44 + let did = auth.0.did.clone(); 45 + let user = match sqlx::query!( 46 + "SELECT id, handle, email, email_verified FROM users WHERE did = $1", 47 + did 48 + ) 49 + .fetch_optional(&state.db) 50 + .await 51 { 52 Ok(Some(row)) => row, 53 + Ok(None) => { 54 + return ( 55 + StatusCode::BAD_REQUEST, 56 + Json(json!({"error": "InvalidRequest", "message": "account not found"})), 57 + ) 58 + .into_response(); 59 + } 60 + Err(e) => { 61 + error!("DB error: {:?}", e); 62 return ( 63 StatusCode::INTERNAL_SERVER_ERROR, 64 Json(json!({"error": "InternalError"})), ··· 67 } 68 }; 69 70 + let current_email: String = match user.email { 71 + Some(e) => e, 72 + None => { 73 + return ( 74 + StatusCode::BAD_REQUEST, 75 + Json(json!({"error": "InvalidRequest", "message": "account does not have an email address"})), 76 + ) 77 + .into_response(); 78 + } 79 + }; 80 81 + let token_required = user.email_verified; 82 83 + if token_required { 84 + let code = crate::auth::verification_token::generate_channel_update_token( 85 + &did, 86 + "email_update", 87 + &current_email.to_lowercase(), 88 + ); 89 + let formatted_code = 90 + crate::auth::verification_token::format_token_for_display(&code); 91 92 + let hostname = 93 + std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 94 + if let Err(e) = crate::comms::enqueue_email_update_token( 95 + &state.db, 96 + user.id, 97 + &formatted_code, 98 + &hostname, 99 ) 100 + .await 101 + { 102 + warn!("Failed to enqueue email update notification: {:?}", e); 103 + } 104 } 105 106 + info!("Email update requested for user {}", user.id); 107 + (StatusCode::OK, Json(json!({ "tokenRequired": token_required }))).into_response() 108 } 109 110 #[derive(Deserialize)] ··· 117 pub async fn confirm_email( 118 State(state): State<AppState>, 119 headers: axum::http::HeaderMap, 120 + auth: BearerAuth, 121 Json(input): Json<ConfirmEmailInput>, 122 ) -> Response { 123 let client_ip = crate::rate_limit::extract_client_ip(&headers, None); 124 if !state 125 + .check_rate_limit(RateLimitKind::EmailUpdate, &client_ip) 126 .await 127 { 128 warn!(ip = %client_ip, "Confirm email rate limit exceeded"); ··· 136 .into_response(); 137 } 138 139 if let Err(e) = crate::auth::scope_check::check_account_scope( 140 + auth.0.is_oauth, 141 + auth.0.scope.as_deref(), 142 crate::oauth::scopes::AccountAttr::Email, 143 crate::oauth::scopes::AccountAction::Manage, 144 ) { 145 return e; 146 } 147 148 + let did = auth.0.did; 149 + let user = match sqlx::query!( 150 + "SELECT id, email, email_verified FROM users WHERE did = $1", 151 + did 152 + ) 153 + .fetch_optional(&state.db) 154 + .await 155 { 156 + Ok(Some(row)) => row, 157 + Ok(None) => { 158 + return ( 159 + StatusCode::BAD_REQUEST, 160 + Json(json!({"error": "AccountNotFound", "message": "user not found"})), 161 + ) 162 + .into_response(); 163 + } 164 + Err(e) => { 165 + error!("DB error: {:?}", e); 166 return ( 167 StatusCode::INTERNAL_SERVER_ERROR, 168 Json(json!({"error": "InternalError"})), ··· 171 } 172 }; 173 174 + let current_email = match &user.email { 175 + Some(e) => e.to_lowercase(), 176 + None => { 177 + return ( 178 + StatusCode::BAD_REQUEST, 179 + Json(json!({"error": "InvalidEmail", "message": "account does not have an email address"})), 180 + ) 181 + .into_response(); 182 + } 183 + }; 184 + 185 + let provided_email = input.email.trim().to_lowercase(); 186 + if provided_email != current_email { 187 + return ( 188 + StatusCode::BAD_REQUEST, 189 + Json(json!({"error": "InvalidEmail", "message": "invalid email"})), 190 + ) 191 + .into_response(); 192 + } 193 + 194 + if user.email_verified { 195 + return (StatusCode::OK, Json(json!({}))).into_response(); 196 + } 197 + 198 let confirmation_code = 199 crate::auth::verification_token::normalize_token_input(input.token.trim()); 200 201 + let verified = crate::auth::verification_token::verify_signup_token( 202 &confirmation_code, 203 "email", 204 + &provided_email, 205 ); 206 207 match verified { ··· 233 } 234 235 let update = sqlx::query!( 236 + "UPDATE users SET email_verified = TRUE, updated_at = NOW() WHERE id = $1", 237 + user.id 238 ) 239 .execute(&state.db) 240 .await; 241 242 if let Err(e) = update { 243 + error!("DB error confirming email: {:?}", e); 244 return ( 245 StatusCode::INTERNAL_SERVER_ERROR, 246 Json(json!({"error": "InternalError"})), ··· 248 .into_response(); 249 } 250 251 + info!("Email confirmed for user {}", user.id); 252 (StatusCode::OK, Json(json!({}))).into_response() 253 } 254 ··· 266 headers: axum::http::HeaderMap, 267 Json(input): Json<UpdateEmailInput>, 268 ) -> Response { 269 + let bearer_token = match crate::auth::extract_bearer_token_from_header( 270 headers.get("Authorization").and_then(|h| h.to_str().ok()), 271 ) { 272 Some(t) => t, ··· 279 } 280 }; 281 282 + let auth_result = crate::auth::validate_bearer_token(&state.db, &bearer_token).await; 283 let auth_user = match auth_result { 284 Ok(user) => user, 285 Err(e) => return ApiError::from(e).into_response(), ··· 295 } 296 297 let did = auth_user.did; 298 + let user = match sqlx::query!( 299 + "SELECT id, email, email_verified FROM users WHERE did = $1", 300 + did 301 + ) 302 + .fetch_optional(&state.db) 303 + .await 304 { 305 Ok(Some(row)) => row, 306 + Ok(None) => { 307 + return ( 308 + StatusCode::BAD_REQUEST, 309 + Json(json!({"error": "InvalidRequest", "message": "account not found"})), 310 + ) 311 + .into_response(); 312 + } 313 + Err(e) => { 314 + error!("DB error: {:?}", e); 315 return ( 316 StatusCode::INTERNAL_SERVER_ERROR, 317 Json(json!({"error": "InternalError"})), ··· 321 }; 322 323 let user_id = user.id; 324 + let current_email = user.email.clone(); 325 + let email_verified = user.email_verified; 326 let new_email = input.email.trim().to_lowercase(); 327 328 if !crate::api::validation::is_valid_email(&new_email) { 329 return ( 330 StatusCode::BAD_REQUEST, 331 + Json(json!({ 332 + "error": "InvalidRequest", 333 + "message": "This email address is not supported, please use a different email." 334 + })), 335 ) 336 .into_response(); 337 } ··· 342 return (StatusCode::OK, Json(json!({}))).into_response(); 343 } 344 345 + if email_verified { 346 + let confirmation_token = match &input.token { 347 + Some(t) => crate::auth::verification_token::normalize_token_input(t.trim()), 348 + None => { 349 + return ( 350 + StatusCode::BAD_REQUEST, 351 + Json(json!({ 352 + "error": "TokenRequired", 353 + "message": "confirmation token required" 354 + })), 355 + ) 356 + .into_response(); 357 + } 358 + }; 359 + 360 + let current_email_lower = current_email 361 + .as_ref() 362 + .map(|e| e.to_lowercase()) 363 + .unwrap_or_default(); 364 365 + let verified = crate::auth::verification_token::verify_channel_update_token( 366 + &confirmation_token, 367 + "email_update", 368 + &current_email_lower, 369 + ); 370 371 + match verified { 372 + Ok(token_data) => { 373 + if token_data.did != did { 374 + return ( 375 + StatusCode::BAD_REQUEST, 376 + Json( 377 + json!({"error": "InvalidToken", "message": "Token does not match account"}), 378 + ), 379 + ) 380 + .into_response(); 381 + } 382 + } 383 + Err(crate::auth::verification_token::VerifyError::Expired) => { 384 + return ( 385 + StatusCode::BAD_REQUEST, 386 + Json(json!({"error": "ExpiredToken", "message": "Token has expired"})), 387 + ) 388 + .into_response(); 389 + } 390 + Err(_) => { 391 return ( 392 StatusCode::BAD_REQUEST, 393 + Json(json!({"error": "InvalidToken", "message": "Invalid token"})), 394 ) 395 .into_response(); 396 } 397 } 398 } 399 400 let exists = sqlx::query!( ··· 408 if let Ok(Some(_)) = exists { 409 return ( 410 StatusCode::BAD_REQUEST, 411 + Json(json!({ 412 + "error": "InvalidRequest", 413 + "message": "This email address is already in use, please use a different email." 414 + })), 415 ) 416 .into_response(); 417 } 418 419 + let update: Result<sqlx::postgres::PgQueryResult, sqlx::Error> = sqlx::query!( 420 + "UPDATE users SET email = $1, email_verified = FALSE, updated_at = NOW() WHERE id = $2", 421 new_email, 422 user_id 423 ) ··· 425 .await; 426 427 if let Err(e) = update { 428 + error!("DB error updating email: {:?}", e); 429 if e.as_database_error() 430 + .map(|db_err: &dyn sqlx::error::DatabaseError| db_err.is_unique_violation()) 431 .unwrap_or(false) 432 { 433 return ( 434 StatusCode::BAD_REQUEST, 435 + Json(json!({ 436 + "error": "InvalidRequest", 437 + "message": "This email address is already in use, please use a different email." 438 + })), 439 ) 440 .into_response(); 441 } ··· 444 Json(json!({"error": "InternalError"})), 445 ) 446 .into_response(); 447 + } 448 + 449 + let verification_token = 450 + crate::auth::verification_token::generate_signup_token(&did, "email", &new_email); 451 + let formatted_token = 452 + crate::auth::verification_token::format_token_for_display(&verification_token); 453 + if let Err(e) = crate::comms::enqueue_signup_verification( 454 + &state.db, 455 + user_id, 456 + "email", 457 + &new_email, 458 + &formatted_token, 459 + None, 460 + ) 461 + .await 462 + { 463 + warn!("Failed to send verification email to new address: {:?}", e); 464 } 465 466 match sqlx::query!(
+3 -3
src/comms/mod.rs
··· 10 11 pub use service::{ 12 CommsService, channel_display_name, enqueue_2fa_code, enqueue_account_deletion, enqueue_comms, 13 - enqueue_email_update, enqueue_migration_verification, enqueue_passkey_recovery, 14 - enqueue_password_reset, enqueue_plc_operation, enqueue_signup_verification, enqueue_welcome, 15 - queue_legacy_login_notification, 16 }; 17 18 pub use types::{CommsChannel, CommsStatus, CommsType, NewComms, QueuedComms};
··· 10 11 pub use service::{ 12 CommsService, channel_display_name, enqueue_2fa_code, enqueue_account_deletion, enqueue_comms, 13 + enqueue_email_update, enqueue_email_update_token, enqueue_migration_verification, 14 + enqueue_passkey_recovery, enqueue_password_reset, enqueue_plc_operation, 15 + enqueue_signup_verification, enqueue_welcome, queue_legacy_login_notification, 16 }; 17 18 pub use types::{CommsChannel, CommsStatus, CommsType, NewComms, QueuedComms};
+38
src/comms/service.rs
··· 380 .await 381 } 382 383 pub async fn enqueue_account_deletion( 384 db: &PgPool, 385 user_id: Uuid,
··· 380 .await 381 } 382 383 + pub async fn enqueue_email_update_token( 384 + db: &PgPool, 385 + user_id: Uuid, 386 + code: &str, 387 + hostname: &str, 388 + ) -> Result<Uuid, sqlx::Error> { 389 + let prefs = get_user_comms_prefs(db, user_id).await?; 390 + let strings = get_strings(&prefs.locale); 391 + let current_email = prefs.email.clone().unwrap_or_default(); 392 + let verify_page = format!("https://{}/#/verify?type=email-update", hostname); 393 + let verify_link = format!( 394 + "https://{}/#/verify?type=email-update&token={}", 395 + hostname, 396 + urlencoding::encode(code) 397 + ); 398 + let body = format_message( 399 + strings.email_update_body, 400 + &[ 401 + ("handle", &prefs.handle), 402 + ("code", code), 403 + ("verify_page", &verify_page), 404 + ("verify_link", &verify_link), 405 + ], 406 + ); 407 + let subject = format_message(strings.email_update_subject, &[("hostname", hostname)]); 408 + enqueue_comms( 409 + db, 410 + NewComms::email( 411 + user_id, 412 + super::types::CommsType::EmailUpdate, 413 + current_email, 414 + subject, 415 + body, 416 + ), 417 + ) 418 + .await 419 + } 420 + 421 pub async fn enqueue_account_deletion( 422 db: &PgPool, 423 user_id: Uuid,
+255 -158
tests/email_update.rs
··· 63 } 64 65 #[tokio::test] 66 - async fn test_email_update_flow_success() { 67 let client = common::client(); 68 let base_url = common::base_url().await; 69 let pool = get_pool().await; ··· 71 let email = format!("{}@example.com", handle); 72 let (access_jwt, did) = create_verified_account(&client, &base_url, &handle, &email).await; 73 let new_email = format!("new_{}@example.com", handle); 74 let res = client 75 .post(format!( 76 "{}/xrpc/com.atproto.server.requestEmailUpdate", 77 base_url 78 )) 79 .bearer_auth(&access_jwt) 80 - .json(&json!({"email": new_email})) 81 .send() 82 .await 83 .expect("Failed to request email update"); ··· 86 assert_eq!(body["tokenRequired"], true); 87 88 let code = get_email_update_token(&pool, &did).await; 89 let res = client 90 - .post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url)) 91 .bearer_auth(&access_jwt) 92 .json(&json!({ 93 "email": new_email, ··· 95 })) 96 .send() 97 .await 98 - .expect("Failed to confirm email"); 99 assert_eq!(res.status(), StatusCode::OK); 100 - let user = sqlx::query!("SELECT email FROM users WHERE did = $1", did) 101 .fetch_one(&pool) 102 .await 103 .expect("User not found"); 104 - assert_eq!(user.email, Some(new_email)); 105 } 106 107 #[tokio::test] 108 - async fn test_request_email_update_taken_email() { 109 let client = common::client(); 110 let base_url = common::base_url().await; 111 - let handle1 = format!("emailup-taken1-{}", uuid::Uuid::new_v4()); 112 - let email1 = format!("{}@example.com", handle1); 113 - let (_, _) = create_verified_account(&client, &base_url, &handle1, &email1).await; 114 - let handle2 = format!("emailup-taken2-{}", uuid::Uuid::new_v4()); 115 - let email2 = format!("{}@example.com", handle2); 116 - let (access_jwt2, _) = create_verified_account(&client, &base_url, &handle2, &email2).await; 117 let res = client 118 - .post(format!( 119 - "{}/xrpc/com.atproto.server.requestEmailUpdate", 120 - base_url 121 - )) 122 - .bearer_auth(&access_jwt2) 123 - .json(&json!({"email": email1})) 124 .send() 125 .await 126 - .expect("Failed to request email update"); 127 assert_eq!(res.status(), StatusCode::BAD_REQUEST); 128 let body: Value = res.json().await.expect("Invalid JSON"); 129 - assert_eq!(body["error"], "EmailTaken"); 130 } 131 132 #[tokio::test] 133 - async fn test_confirm_email_invalid_token() { 134 let client = common::client(); 135 let base_url = common::base_url().await; 136 - let handle = format!("emailup-inv-{}", uuid::Uuid::new_v4()); 137 let email = format!("{}@example.com", handle); 138 let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await; 139 - let new_email = format!("new_{}@example.com", handle); 140 - let res = client 141 - .post(format!( 142 - "{}/xrpc/com.atproto.server.requestEmailUpdate", 143 - base_url 144 - )) 145 - .bearer_auth(&access_jwt) 146 - .json(&json!({"email": new_email})) 147 - .send() 148 - .await 149 - .expect("Failed to request email update"); 150 - assert_eq!(res.status(), StatusCode::OK); 151 let res = client 152 - .post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url)) 153 .bearer_auth(&access_jwt) 154 - .json(&json!({ 155 - "email": new_email, 156 - "token": "wrong-token" 157 - })) 158 .send() 159 .await 160 - .expect("Failed to confirm email"); 161 - assert_eq!(res.status(), StatusCode::BAD_REQUEST); 162 - let body: Value = res.json().await.expect("Invalid JSON"); 163 - assert_eq!(body["error"], "InvalidToken"); 164 } 165 166 #[tokio::test] 167 - async fn test_confirm_email_wrong_email() { 168 let client = common::client(); 169 let base_url = common::base_url().await; 170 - let pool = get_pool().await; 171 - let handle = format!("emailup-wrong-{}", uuid::Uuid::new_v4()); 172 let email = format!("{}@example.com", handle); 173 - let (access_jwt, did) = create_verified_account(&client, &base_url, &handle, &email).await; 174 - let new_email = format!("new_{}@example.com", handle); 175 let res = client 176 .post(format!( 177 "{}/xrpc/com.atproto.server.requestEmailUpdate", 178 base_url 179 )) 180 .bearer_auth(&access_jwt) 181 - .json(&json!({"email": new_email})) 182 .send() 183 .await 184 .expect("Failed to request email update"); 185 assert_eq!(res.status(), StatusCode::OK); 186 - let code = get_email_update_token(&pool, &did).await; 187 let res = client 188 - .post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url)) 189 .bearer_auth(&access_jwt) 190 .json(&json!({ 191 - "email": "another_random@example.com", 192 - "token": code 193 })) 194 .send() 195 .await 196 - .expect("Failed to confirm email"); 197 assert_eq!(res.status(), StatusCode::BAD_REQUEST); 198 let body: Value = res.json().await.expect("Invalid JSON"); 199 - assert!( 200 - body["message"].as_str().unwrap().contains("mismatch") || body["error"] == "InvalidToken" 201 - ); 202 } 203 204 #[tokio::test] 205 - async fn test_update_email_requires_token() { 206 let client = common::client(); 207 let base_url = common::base_url().await; 208 - let handle = format!("emailup-direct-{}", uuid::Uuid::new_v4()); 209 - let email = format!("{}@example.com", handle); 210 - let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await; 211 - let new_email = format!("direct_{}@example.com", handle); 212 let res = client 213 .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) 214 - .bearer_auth(&access_jwt) 215 - .json(&json!({ "email": new_email })) 216 .send() 217 .await 218 - .expect("Failed to update email"); 219 - assert_eq!(res.status(), StatusCode::BAD_REQUEST); 220 let body: Value = res.json().await.expect("Invalid JSON"); 221 - assert_eq!(body["error"], "TokenRequired"); 222 } 223 224 #[tokio::test] 225 - async fn test_update_email_same_email_noop() { 226 let client = common::client(); 227 let base_url = common::base_url().await; 228 - let handle = format!("emailup-same-{}", uuid::Uuid::new_v4()); 229 let email = format!("{}@example.com", handle); 230 let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await; 231 let res = client 232 .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) 233 .bearer_auth(&access_jwt) 234 - .json(&json!({ "email": email })) 235 .send() 236 .await 237 - .expect("Failed to update email"); 238 - assert_eq!( 239 - res.status(), 240 - StatusCode::OK, 241 - "Updating to same email should succeed as no-op" 242 - ); 243 } 244 245 #[tokio::test] 246 - async fn test_update_email_requires_token_after_pending() { 247 let client = common::client(); 248 let base_url = common::base_url().await; 249 - let handle = format!("emailup-token-{}", uuid::Uuid::new_v4()); 250 let email = format!("{}@example.com", handle); 251 - let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await; 252 - let new_email = format!("pending_{}@example.com", handle); 253 let res = client 254 .post(format!( 255 - "{}/xrpc/com.atproto.server.requestEmailUpdate", 256 base_url 257 )) 258 - .bearer_auth(&access_jwt) 259 - .json(&json!({"email": new_email})) 260 .send() 261 .await 262 - .expect("Failed to request email update"); 263 assert_eq!(res.status(), StatusCode::OK); 264 let res = client 265 - .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) 266 .bearer_auth(&access_jwt) 267 - .json(&json!({ "email": new_email })) 268 .send() 269 .await 270 - .expect("Failed to attempt email update"); 271 - assert_eq!(res.status(), StatusCode::BAD_REQUEST); 272 - let body: Value = res.json().await.expect("Invalid JSON"); 273 - assert_eq!(body["error"], "TokenRequired"); 274 } 275 276 #[tokio::test] 277 - async fn test_update_email_with_valid_token() { 278 let client = common::client(); 279 let base_url = common::base_url().await; 280 let pool = get_pool().await; 281 - let handle = format!("emailup-valid-{}", uuid::Uuid::new_v4()); 282 let email = format!("{}@example.com", handle); 283 - let (access_jwt, did) = create_verified_account(&client, &base_url, &handle, &email).await; 284 - let new_email = format!("valid_{}@example.com", handle); 285 let res = client 286 .post(format!( 287 - "{}/xrpc/com.atproto.server.requestEmailUpdate", 288 base_url 289 )) 290 - .bearer_auth(&access_jwt) 291 - .json(&json!({"email": new_email})) 292 .send() 293 .await 294 - .expect("Failed to request email update"); 295 assert_eq!(res.status(), StatusCode::OK); 296 - let code = get_email_update_token(&pool, &did).await; 297 let res = client 298 - .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) 299 .bearer_auth(&access_jwt) 300 .json(&json!({ 301 - "email": new_email, 302 "token": code 303 })) 304 .send() 305 .await 306 - .expect("Failed to update email"); 307 - assert_eq!(res.status(), StatusCode::OK); 308 - let user = sqlx::query!("SELECT email FROM users WHERE did = $1", did) 309 - .fetch_one(&pool) 310 - .await 311 - .expect("User not found"); 312 - assert_eq!(user.email, Some(new_email)); 313 } 314 315 #[tokio::test] 316 - async fn test_update_email_invalid_token() { 317 let client = common::client(); 318 let base_url = common::base_url().await; 319 - let handle = format!("emailup-badtok-{}", uuid::Uuid::new_v4()); 320 let email = format!("{}@example.com", handle); 321 - let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await; 322 - let new_email = format!("badtok_{}@example.com", handle); 323 let res = client 324 .post(format!( 325 - "{}/xrpc/com.atproto.server.requestEmailUpdate", 326 base_url 327 )) 328 - .bearer_auth(&access_jwt) 329 - .json(&json!({"email": new_email})) 330 .send() 331 .await 332 - .expect("Failed to request email update"); 333 assert_eq!(res.status(), StatusCode::OK); 334 let res = client 335 - .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) 336 .bearer_auth(&access_jwt) 337 .json(&json!({ 338 - "email": new_email, 339 - "token": "wrong-token-12345" 340 })) 341 .send() 342 .await 343 - .expect("Failed to attempt email update"); 344 assert_eq!(res.status(), StatusCode::BAD_REQUEST); 345 let body: Value = res.json().await.expect("Invalid JSON"); 346 assert_eq!(body["error"], "InvalidToken"); 347 } 348 349 #[tokio::test] 350 - async fn test_update_email_already_taken() { 351 let client = common::client(); 352 let base_url = common::base_url().await; 353 - let handle1 = format!("emailup-dup1-{}", uuid::Uuid::new_v4()); 354 - let email1 = format!("{}@example.com", handle1); 355 - let (_, _) = create_verified_account(&client, &base_url, &handle1, &email1).await; 356 - let handle2 = format!("emailup-dup2-{}", uuid::Uuid::new_v4()); 357 - let email2 = format!("{}@example.com", handle2); 358 - let (access_jwt2, _) = create_verified_account(&client, &base_url, &handle2, &email2).await; 359 let res = client 360 - .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) 361 - .bearer_auth(&access_jwt2) 362 - .json(&json!({ "email": email1 })) 363 .send() 364 .await 365 - .expect("Failed to attempt email update"); 366 - assert_eq!(res.status(), StatusCode::BAD_REQUEST); 367 let body: Value = res.json().await.expect("Invalid JSON"); 368 - assert!( 369 - body["error"] == "TokenRequired" 370 - || body["message"] 371 - .as_str() 372 - .unwrap_or("") 373 - .contains("already in use") 374 - || body["error"] == "InvalidRequest" 375 ); 376 - } 377 378 - #[tokio::test] 379 - async fn test_update_email_no_auth() { 380 - let client = common::client(); 381 - let base_url = common::base_url().await; 382 let res = client 383 .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) 384 - .json(&json!({ "email": "test@example.com" })) 385 .send() 386 .await 387 - .expect("Failed to send request"); 388 - assert_eq!(res.status(), StatusCode::UNAUTHORIZED); 389 - let body: Value = res.json().await.expect("Invalid JSON"); 390 - assert_eq!(body["error"], "AuthenticationRequired"); 391 } 392 393 #[tokio::test] 394 - async fn test_update_email_invalid_format() { 395 let client = common::client(); 396 let base_url = common::base_url().await; 397 - let handle = format!("emailup-fmt-{}", uuid::Uuid::new_v4()); 398 - let email = format!("{}@example.com", handle); 399 - let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await; 400 let res = client 401 .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) 402 - .bearer_auth(&access_jwt) 403 - .json(&json!({ "email": "not-an-email" })) 404 .send() 405 .await 406 - .expect("Failed to send request"); 407 assert_eq!(res.status(), StatusCode::BAD_REQUEST); 408 let body: Value = res.json().await.expect("Invalid JSON"); 409 - assert_eq!(body["error"], "InvalidEmail"); 410 }
··· 63 } 64 65 #[tokio::test] 66 + async fn test_request_email_update_returns_token_required() { 67 + let client = common::client(); 68 + let base_url = common::base_url().await; 69 + let handle = format!("emailreq-{}", uuid::Uuid::new_v4()); 70 + let email = format!("{}@example.com", handle); 71 + let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await; 72 + 73 + let res = client 74 + .post(format!( 75 + "{}/xrpc/com.atproto.server.requestEmailUpdate", 76 + base_url 77 + )) 78 + .bearer_auth(&access_jwt) 79 + .send() 80 + .await 81 + .expect("Failed to request email update"); 82 + assert_eq!(res.status(), StatusCode::OK); 83 + let body: Value = res.json().await.expect("Invalid JSON"); 84 + assert_eq!(body["tokenRequired"], true); 85 + } 86 + 87 + #[tokio::test] 88 + async fn test_update_email_flow_success() { 89 let client = common::client(); 90 let base_url = common::base_url().await; 91 let pool = get_pool().await; ··· 93 let email = format!("{}@example.com", handle); 94 let (access_jwt, did) = create_verified_account(&client, &base_url, &handle, &email).await; 95 let new_email = format!("new_{}@example.com", handle); 96 + 97 let res = client 98 .post(format!( 99 "{}/xrpc/com.atproto.server.requestEmailUpdate", 100 base_url 101 )) 102 .bearer_auth(&access_jwt) 103 .send() 104 .await 105 .expect("Failed to request email update"); ··· 108 assert_eq!(body["tokenRequired"], true); 109 110 let code = get_email_update_token(&pool, &did).await; 111 + 112 let res = client 113 + .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) 114 .bearer_auth(&access_jwt) 115 .json(&json!({ 116 "email": new_email, ··· 118 })) 119 .send() 120 .await 121 + .expect("Failed to update email"); 122 assert_eq!(res.status(), StatusCode::OK); 123 + 124 + let user_email: Option<String> = sqlx::query_scalar!("SELECT email FROM users WHERE did = $1", did) 125 .fetch_one(&pool) 126 .await 127 .expect("User not found"); 128 + assert_eq!(user_email, Some(new_email)); 129 } 130 131 #[tokio::test] 132 + async fn test_update_email_requires_token_when_verified() { 133 let client = common::client(); 134 let base_url = common::base_url().await; 135 + let handle = format!("emailup-direct-{}", uuid::Uuid::new_v4()); 136 + let email = format!("{}@example.com", handle); 137 + let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await; 138 + let new_email = format!("direct_{}@example.com", handle); 139 + 140 let res = client 141 + .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) 142 + .bearer_auth(&access_jwt) 143 + .json(&json!({ "email": new_email })) 144 .send() 145 .await 146 + .expect("Failed to update email"); 147 assert_eq!(res.status(), StatusCode::BAD_REQUEST); 148 let body: Value = res.json().await.expect("Invalid JSON"); 149 + assert_eq!(body["error"], "TokenRequired"); 150 } 151 152 #[tokio::test] 153 + async fn test_update_email_same_email_noop() { 154 let client = common::client(); 155 let base_url = common::base_url().await; 156 + let handle = format!("emailup-same-{}", uuid::Uuid::new_v4()); 157 let email = format!("{}@example.com", handle); 158 let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await; 159 + 160 let res = client 161 + .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) 162 .bearer_auth(&access_jwt) 163 + .json(&json!({ "email": email })) 164 .send() 165 .await 166 + .expect("Failed to update email"); 167 + assert_eq!( 168 + res.status(), 169 + StatusCode::OK, 170 + "Updating to same email should succeed as no-op" 171 + ); 172 } 173 174 #[tokio::test] 175 + async fn test_update_email_invalid_token() { 176 let client = common::client(); 177 let base_url = common::base_url().await; 178 + let handle = format!("emailup-badtok-{}", uuid::Uuid::new_v4()); 179 let email = format!("{}@example.com", handle); 180 + let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await; 181 + let new_email = format!("badtok_{}@example.com", handle); 182 + 183 let res = client 184 .post(format!( 185 "{}/xrpc/com.atproto.server.requestEmailUpdate", 186 base_url 187 )) 188 .bearer_auth(&access_jwt) 189 .send() 190 .await 191 .expect("Failed to request email update"); 192 assert_eq!(res.status(), StatusCode::OK); 193 + 194 let res = client 195 + .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) 196 .bearer_auth(&access_jwt) 197 .json(&json!({ 198 + "email": new_email, 199 + "token": "wrong-token-12345" 200 })) 201 .send() 202 .await 203 + .expect("Failed to attempt email update"); 204 assert_eq!(res.status(), StatusCode::BAD_REQUEST); 205 let body: Value = res.json().await.expect("Invalid JSON"); 206 + assert_eq!(body["error"], "InvalidToken"); 207 } 208 209 #[tokio::test] 210 + async fn test_update_email_no_auth() { 211 let client = common::client(); 212 let base_url = common::base_url().await; 213 + 214 let res = client 215 .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) 216 + .json(&json!({ "email": "test@example.com" })) 217 .send() 218 .await 219 + .expect("Failed to send request"); 220 + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); 221 let body: Value = res.json().await.expect("Invalid JSON"); 222 + assert_eq!(body["error"], "AuthenticationRequired"); 223 } 224 225 #[tokio::test] 226 + async fn test_update_email_invalid_format() { 227 let client = common::client(); 228 let base_url = common::base_url().await; 229 + let handle = format!("emailup-fmt-{}", uuid::Uuid::new_v4()); 230 let email = format!("{}@example.com", handle); 231 let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await; 232 + 233 let res = client 234 .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) 235 .bearer_auth(&access_jwt) 236 + .json(&json!({ "email": "not-an-email" })) 237 .send() 238 .await 239 + .expect("Failed to send request"); 240 + assert_eq!(res.status(), StatusCode::BAD_REQUEST); 241 } 242 243 #[tokio::test] 244 + async fn test_confirm_email_confirms_existing_email() { 245 let client = common::client(); 246 let base_url = common::base_url().await; 247 + let pool = get_pool().await; 248 + let handle = format!("emailconfirm-{}", uuid::Uuid::new_v4()); 249 let email = format!("{}@example.com", handle); 250 + 251 let res = client 252 .post(format!( 253 + "{}/xrpc/com.atproto.server.createAccount", 254 base_url 255 )) 256 + .json(&json!({ 257 + "handle": handle, 258 + "email": email, 259 + "password": "Testpass123!" 260 + })) 261 .send() 262 .await 263 + .expect("Failed to create account"); 264 assert_eq!(res.status(), StatusCode::OK); 265 + let body: Value = res.json().await.expect("Invalid JSON"); 266 + let did = body["did"].as_str().expect("No did").to_string(); 267 + let access_jwt = body["accessJwt"].as_str().expect("No accessJwt").to_string(); 268 + 269 + let body_text: String = sqlx::query_scalar!( 270 + "SELECT body FROM comms_queue WHERE user_id = (SELECT id FROM users WHERE did = $1) AND comms_type = 'email_verification' ORDER BY created_at DESC LIMIT 1", 271 + did 272 + ) 273 + .fetch_one(&pool) 274 + .await 275 + .expect("Verification email not found"); 276 + 277 + let code = body_text 278 + .lines() 279 + .find(|line| line.trim().starts_with("MX") && line.contains('-')) 280 + .map(|s| s.trim().to_string()) 281 + .unwrap_or_default(); 282 + 283 let res = client 284 + .post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url)) 285 .bearer_auth(&access_jwt) 286 + .json(&json!({ 287 + "email": email, 288 + "token": code 289 + })) 290 .send() 291 .await 292 + .expect("Failed to confirm email"); 293 + assert_eq!(res.status(), StatusCode::OK); 294 + 295 + let verified: bool = sqlx::query_scalar!( 296 + "SELECT email_verified FROM users WHERE did = $1", 297 + did 298 + ) 299 + .fetch_one(&pool) 300 + .await 301 + .expect("User not found"); 302 + assert!(verified); 303 } 304 305 #[tokio::test] 306 + async fn test_confirm_email_rejects_wrong_email() { 307 let client = common::client(); 308 let base_url = common::base_url().await; 309 let pool = get_pool().await; 310 + let handle = format!("emailconf-wrong-{}", uuid::Uuid::new_v4()); 311 let email = format!("{}@example.com", handle); 312 + 313 let res = client 314 .post(format!( 315 + "{}/xrpc/com.atproto.server.createAccount", 316 base_url 317 )) 318 + .json(&json!({ 319 + "handle": handle, 320 + "email": email, 321 + "password": "Testpass123!" 322 + })) 323 .send() 324 .await 325 + .expect("Failed to create account"); 326 assert_eq!(res.status(), StatusCode::OK); 327 + let body: Value = res.json().await.expect("Invalid JSON"); 328 + let did = body["did"].as_str().expect("No did").to_string(); 329 + let access_jwt = body["accessJwt"].as_str().expect("No accessJwt").to_string(); 330 + 331 + let body_text: String = sqlx::query_scalar!( 332 + "SELECT body FROM comms_queue WHERE user_id = (SELECT id FROM users WHERE did = $1) AND comms_type = 'email_verification' ORDER BY created_at DESC LIMIT 1", 333 + did 334 + ) 335 + .fetch_one(&pool) 336 + .await 337 + .expect("Verification email not found"); 338 + 339 + let code = body_text 340 + .lines() 341 + .find(|line| line.trim().starts_with("MX") && line.contains('-')) 342 + .map(|s| s.trim().to_string()) 343 + .unwrap_or_default(); 344 + 345 let res = client 346 + .post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url)) 347 .bearer_auth(&access_jwt) 348 .json(&json!({ 349 + "email": "different@example.com", 350 "token": code 351 })) 352 .send() 353 .await 354 + .expect("Failed to confirm email"); 355 + assert_eq!(res.status(), StatusCode::BAD_REQUEST); 356 + let body: Value = res.json().await.expect("Invalid JSON"); 357 + assert_eq!(body["error"], "InvalidEmail"); 358 } 359 360 #[tokio::test] 361 + async fn test_confirm_email_invalid_token() { 362 let client = common::client(); 363 let base_url = common::base_url().await; 364 + let handle = format!("emailconf-inv-{}", uuid::Uuid::new_v4()); 365 let email = format!("{}@example.com", handle); 366 + 367 let res = client 368 .post(format!( 369 + "{}/xrpc/com.atproto.server.createAccount", 370 base_url 371 )) 372 + .json(&json!({ 373 + "handle": handle, 374 + "email": email, 375 + "password": "Testpass123!" 376 + })) 377 .send() 378 .await 379 + .expect("Failed to create account"); 380 assert_eq!(res.status(), StatusCode::OK); 381 + let body: Value = res.json().await.expect("Invalid JSON"); 382 + let access_jwt = body["accessJwt"].as_str().expect("No accessJwt").to_string(); 383 + 384 let res = client 385 + .post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url)) 386 .bearer_auth(&access_jwt) 387 .json(&json!({ 388 + "email": email, 389 + "token": "wrong-token" 390 })) 391 .send() 392 .await 393 + .expect("Failed to confirm email"); 394 assert_eq!(res.status(), StatusCode::BAD_REQUEST); 395 let body: Value = res.json().await.expect("Invalid JSON"); 396 assert_eq!(body["error"], "InvalidToken"); 397 } 398 399 #[tokio::test] 400 + async fn test_unverified_account_can_update_email_without_token() { 401 let client = common::client(); 402 let base_url = common::base_url().await; 403 + let pool = get_pool().await; 404 + let handle = format!("emailup-unverified-{}", uuid::Uuid::new_v4()); 405 + let email = format!("{}@example.com", handle); 406 + 407 let res = client 408 + .post(format!( 409 + "{}/xrpc/com.atproto.server.createAccount", 410 + base_url 411 + )) 412 + .json(&json!({ 413 + "handle": handle, 414 + "email": email, 415 + "password": "Testpass123!" 416 + })) 417 .send() 418 .await 419 + .expect("Failed to create account"); 420 + assert_eq!(res.status(), StatusCode::OK); 421 let body: Value = res.json().await.expect("Invalid JSON"); 422 + let did = body["did"].as_str().expect("No did").to_string(); 423 + let access_jwt = body["accessJwt"].as_str().expect("No accessJwt").to_string(); 424 + 425 + let res = client 426 + .post(format!( 427 + "{}/xrpc/com.atproto.server.requestEmailUpdate", 428 + base_url 429 + )) 430 + .bearer_auth(&access_jwt) 431 + .send() 432 + .await 433 + .expect("Failed to request email update"); 434 + assert_eq!(res.status(), StatusCode::OK); 435 + let body: Value = res.json().await.expect("Invalid JSON"); 436 + assert_eq!( 437 + body["tokenRequired"], false, 438 + "Unverified account should not require token" 439 ); 440 441 + let new_email = format!("new_{}@example.com", handle); 442 let res = client 443 .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) 444 + .bearer_auth(&access_jwt) 445 + .json(&json!({ "email": new_email })) 446 .send() 447 .await 448 + .expect("Failed to update email"); 449 + assert_eq!( 450 + res.status(), 451 + StatusCode::OK, 452 + "Unverified account should be able to update email without token" 453 + ); 454 + 455 + let user_email: Option<String> = 456 + sqlx::query_scalar!("SELECT email FROM users WHERE did = $1", did) 457 + .fetch_one(&pool) 458 + .await 459 + .expect("User not found"); 460 + assert_eq!(user_email, Some(new_email)); 461 } 462 463 #[tokio::test] 464 + async fn test_update_email_taken_by_another_user() { 465 let client = common::client(); 466 let base_url = common::base_url().await; 467 + let pool = get_pool().await; 468 + 469 + let handle1 = format!("emailup-dup1-{}", uuid::Uuid::new_v4()); 470 + let email1 = format!("{}@example.com", handle1); 471 + let (_, _) = create_verified_account(&client, &base_url, &handle1, &email1).await; 472 + 473 + let handle2 = format!("emailup-dup2-{}", uuid::Uuid::new_v4()); 474 + let email2 = format!("{}@example.com", handle2); 475 + let (access_jwt2, did2) = create_verified_account(&client, &base_url, &handle2, &email2).await; 476 + 477 + let res = client 478 + .post(format!( 479 + "{}/xrpc/com.atproto.server.requestEmailUpdate", 480 + base_url 481 + )) 482 + .bearer_auth(&access_jwt2) 483 + .send() 484 + .await 485 + .expect("Failed to request email update"); 486 + assert_eq!(res.status(), StatusCode::OK); 487 + 488 + let code = get_email_update_token(&pool, &did2).await; 489 + 490 let res = client 491 .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) 492 + .bearer_auth(&access_jwt2) 493 + .json(&json!({ 494 + "email": email1, 495 + "token": code 496 + })) 497 .send() 498 .await 499 + .expect("Failed to update email"); 500 assert_eq!(res.status(), StatusCode::BAD_REQUEST); 501 let body: Value = res.json().await.expect("Invalid JSON"); 502 + assert_eq!(body["error"], "InvalidRequest"); 503 + assert!(body["message"] 504 + .as_str() 505 + .unwrap_or("") 506 + .contains("already in use")); 507 }