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 279 280 280 async requestEmailUpdate( 281 281 token: string, 282 - email: string, 283 282 ): Promise<{ tokenRequired: boolean }> { 284 283 return xrpc("com.atproto.server.requestEmailUpdate", { 285 284 method: "POST", 286 285 token, 287 - body: { email }, 288 286 }); 289 287 }, 290 288
+14 -1
frontend/src/locales/en.json
··· 234 234 "deleting": "Deleting...", 235 235 "messages": { 236 236 "emailCodeSent": "Verification code sent to your notification channel", 237 + "emailCodeSentToCurrent": "Verification code sent to your current email address", 237 238 "emailUpdated": "Email updated successfully", 238 239 "emailUpdateFailed": "Failed to update email", 239 240 "handleUpdated": "Handle updated successfully", ··· 659 660 "codeResent": "Verification code resent!", 660 661 "codeResentDetail": "Verification code sent! Check your inbox.", 661 662 "backToLogin": "Back to Login", 663 + "backToSettings": "Back to Settings", 662 664 "verifyingAccount": "Verifying account: @{handle}", 663 665 "startOver": "Start over with a different account", 664 666 "noPending": "No pending verification found.", ··· 671 673 "continue": "Continue", 672 674 "identifierLabel": "Email or Identifier", 673 675 "identifierPlaceholder": "you@example.com", 674 - "identifierHelp": "The email address or identifier the code was sent to" 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." 675 688 }, 676 689 "resetPassword": { 677 690 "title": "Reset Password",
+14 -17
frontend/src/locales/fi.json
··· 234 234 "deleting": "Poistetaan...", 235 235 "messages": { 236 236 "emailCodeSent": "Vahvistuskoodi lähetetty ilmoituskanavallesi", 237 + "emailCodeSentToCurrent": "Vahvistuskoodi lähetetty nykyiseen sähköpostiosoitteeseesi", 237 238 "emailUpdated": "Sähköposti päivitetty", 238 239 "emailUpdateFailed": "Sähköpostin päivitys epäonnistui", 239 240 "handleUpdated": "Käyttäjänimi päivitetty", ··· 671 672 "noPending": "Odottavaa vahvistusta ei löytynyt.", 672 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.", 673 674 "createAccount": "Luo tili", 674 - "signIn": "Kirjaudu sisään" 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..." 675 688 }, 676 689 "resetPassword": { 677 690 "title": "Palauta salasana", ··· 754 767 "verificationMethod": "Vahvistusmenetelmä", 755 768 "email": "Sähköpostiosoite", 756 769 "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 770 "inviteCode": "Kutsukoodi", 769 771 "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 772 "externalDid": "Sinun did:web", 774 773 "externalDidPlaceholder": "did:web:verkkotunnuksesi.fi", 775 - "dnsVerificationInstructions": "Vahvistaaksesi verkkotunnuksesi, lisää tämä TXT-tietue:", 776 - "copyDid": "Kopioi DID", 777 774 "createButton": "Luo tili", 778 775 "creating": "Luodaan...", 779 776 "alreadyHaveAccount": "Onko sinulla jo tili?",
+16 -41
frontend/src/locales/ja.json
··· 234 234 "deleting": "削除中...", 235 235 "messages": { 236 236 "emailCodeSent": "通知チャンネルに確認コードを送信しました", 237 + "emailCodeSentToCurrent": "現在のメールアドレスに確認コードを送信しました", 237 238 "emailUpdated": "メールを更新しました", 238 239 "emailUpdateFailed": "メールの更新に失敗しました", 239 240 "handleUpdated": "ハンドルを更新しました", ··· 671 672 "noPending": "保留中の確認が見つかりません。", 672 673 "noPendingInfo": "最近アカウントを作成して確認が必要な場合は、新しいアカウントを作成する必要があります。すでにアカウントを確認した場合は、サインインできます。", 673 674 "createAccount": "アカウントを作成", 674 - "signIn": "サインイン" 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": "更新中..." 675 688 }, 676 689 "resetPassword": { 677 690 "title": "パスワードリセット", ··· 754 767 "verificationMethod": "確認方法", 755 768 "email": "メールアドレス", 756 769 "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 770 "inviteCode": "招待コード", 769 771 "inviteCodePlaceholder": "招待コードを入力", 770 - "inviteCodeRequired": "必須", 771 - "didWebDescription": "独自ドメインでホストされる DID アイデンティティを使用します。", 772 - "didWebToggle": "外部 did:web を使用", 773 772 "externalDid": "あなたの did:web", 774 773 "externalDidPlaceholder": "did:web:yourdomain.com", 775 - "dnsVerificationInstructions": "ドメインを確認するには、この TXT レコードを追加してください:", 776 - "copyDid": "DID をコピー", 777 774 "createButton": "アカウントを作成", 778 775 "creating": "作成中...", 779 776 "alreadyHaveAccount": "すでにアカウントをお持ちですか?", ··· 896 893 "delegation": { 897 894 "title": "アカウント委任", 898 895 "controllers": "コントローラー", 899 - "controllersDescription": "コントローラーはあなたのアカウントの管理者として行動できます。あなたが許可した操作を実行し、あなたの代わりに投稿を作成し、リポジトリを変更できます。", 900 896 "controlledAccounts": "管理アカウント", 901 - "controlledAccountsDescription": "これらはあなたがコントローラーとして追加されているアカウントです。これらのアカウントで許可されたアクションを実行できます。", 902 897 "noControllers": "コントローラーはまだいません", 903 898 "noControlledAccounts": "管理アカウントはありません", 904 899 "addController": "コントローラーを追加", 905 - "revokeAccess": "アクセスを取り消す", 906 - "revokeConfirm": "このコントローラーのアクセスを取り消しますか?あなたのアカウントで操作できなくなります。", 907 900 "handle": "ハンドル", 908 - "handlePlaceholder": "@user.bsky.social", 909 901 "did": "DID", 910 - "didPlaceholder": "did:plc:...", 911 - "scopes": "権限レベル", 912 902 "scopeOwner": "オーナー", 913 - "scopeOwnerDesc": "完全な管理(すべてのアクションを実行可能)", 914 - "scopeAdmin": "管理者", 915 - "scopeAdminDesc": "投稿、アプリパスワード、設定の管理", 916 - "scopeEditor": "編集者", 917 - "scopeEditorDesc": "投稿、いいね、フォローの作成・管理", 918 903 "scopeViewer": "閲覧者", 919 - "scopeViewerDesc": "リポジトリと設定の読み取り専用アクセス", 920 904 "scopeCustom": "カスタム", 921 - "scopeCustomDesc": "個別の権限を選択", 922 - "grantedAt": "許可日時", 923 - "expiresAt": "有効期限", 924 - "noExpiration": "無期限", 925 905 "actAs": "として行動", 926 906 "auditLog": "監査ログ", 927 907 "auditLogTitle": "委任監査ログ", ··· 944 924 "showing": "{start}~{end} / {total}件", 945 925 "refresh": "更新", 946 926 "failedToLoadAuditLog": "監査ログの読み込みに失敗しました", 947 - "addControllerTitle": "コントローラーを追加", 948 - "addControllerDescription": "このアカウントに対して指定した権限で操作できるユーザーを追加します。", 949 - "controllerIdentifier": "コントローラーのハンドルまたはDID", 950 - "selectScopes": "権限レベルを選択", 951 - "add": "追加", 952 927 "adding": "追加中...", 953 - "cancel": "キャンセル", 954 928 "accessLevel": "アクセスレベル", 955 929 "addControllerButton": "+ コントローラーを追加", 956 930 "auditLogDesc": "すべての委任アクティビティを表示", ··· 974 948 "remove": "削除", 975 949 "removeConfirm": "このコントローラーを削除しますか?", 976 950 "viewAuditLog": "監査ログを表示", 977 - "yourAccessLevel": "あなたのアクセスレベル" 951 + "yourAccessLevel": "あなたのアクセスレベル", 952 + "accountCreated": "委任アカウントを作成しました: {handle}" 978 953 }, 979 954 "actAs": { 980 955 "title": "として行動",
+16 -41
frontend/src/locales/ko.json
··· 234 234 "deleting": "삭제 중...", 235 235 "messages": { 236 236 "emailCodeSent": "알림 채널로 인증 코드를 보냈습니다", 237 + "emailCodeSentToCurrent": "현재 이메일 주소로 인증 코드를 보냈습니다", 237 238 "emailUpdated": "이메일이 업데이트되었습니다", 238 239 "emailUpdateFailed": "이메일 업데이트에 실패했습니다", 239 240 "handleUpdated": "핸들이 업데이트되었습니다", ··· 671 672 "noPending": "보류 중인 인증이 없습니다.", 672 673 "noPendingInfo": "최근에 계정을 만들고 인증이 필요한 경우 새 계정을 만들어야 합니다. 이미 계정을 인증한 경우 로그인할 수 있습니다.", 673 674 "createAccount": "계정 만들기", 674 - "signIn": "로그인" 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": "업데이트 중..." 675 688 }, 676 689 "resetPassword": { 677 690 "title": "비밀번호 재설정", ··· 754 767 "verificationMethod": "인증 방법", 755 768 "email": "이메일 주소", 756 769 "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 770 "inviteCode": "초대 코드", 769 771 "inviteCodePlaceholder": "초대 코드 입력", 770 - "inviteCodeRequired": "필수", 771 - "didWebDescription": "자체 도메인에서 호스팅되는 DID 아이덴티티를 사용합니다.", 772 - "didWebToggle": "외부 did:web 사용", 773 772 "externalDid": "귀하의 did:web", 774 773 "externalDidPlaceholder": "did:web:yourdomain.com", 775 - "dnsVerificationInstructions": "도메인을 인증하려면 이 TXT 레코드를 추가하세요:", 776 - "copyDid": "DID 복사", 777 774 "createButton": "계정 만들기", 778 775 "creating": "생성 중...", 779 776 "alreadyHaveAccount": "이미 계정이 있으신가요?", ··· 896 893 "delegation": { 897 894 "title": "계정 위임", 898 895 "controllers": "컨트롤러", 899 - "controllersDescription": "컨트롤러는 귀하의 계정 관리자로서 행동할 수 있습니다. 귀하가 허용한 작업을 수행하고, 귀하를 대신하여 게시물을 생성하고, 저장소를 수정할 수 있습니다.", 900 896 "controlledAccounts": "관리 계정", 901 - "controlledAccountsDescription": "귀하가 컨트롤러로 추가된 계정들입니다. 이 계정들에서 허용된 작업을 수행할 수 있습니다.", 902 897 "noControllers": "아직 컨트롤러가 없습니다", 903 898 "noControlledAccounts": "관리 계정이 없습니다", 904 899 "addController": "컨트롤러 추가", 905 - "revokeAccess": "액세스 취소", 906 - "revokeConfirm": "이 컨트롤러의 액세스를 취소하시겠습니까? 귀하의 계정에서 더 이상 작업을 수행할 수 없습니다.", 907 900 "handle": "핸들", 908 - "handlePlaceholder": "@user.bsky.social", 909 901 "did": "DID", 910 - "didPlaceholder": "did:plc:...", 911 - "scopes": "권한 수준", 912 902 "scopeOwner": "소유자", 913 - "scopeOwnerDesc": "전체 관리(모든 작업 수행 가능)", 914 - "scopeAdmin": "관리자", 915 - "scopeAdminDesc": "게시물, 앱 비밀번호, 설정 관리", 916 - "scopeEditor": "편집자", 917 - "scopeEditorDesc": "게시물, 좋아요, 팔로우 생성 및 관리", 918 903 "scopeViewer": "뷰어", 919 - "scopeViewerDesc": "저장소 및 설정 읽기 전용 액세스", 920 904 "scopeCustom": "사용자 정의", 921 - "scopeCustomDesc": "개별 권한 선택", 922 - "grantedAt": "허용 일시", 923 - "expiresAt": "만료", 924 - "noExpiration": "무기한", 925 905 "actAs": "로 활동", 926 906 "auditLog": "감사 로그", 927 907 "auditLogTitle": "위임 감사 로그", ··· 944 924 "showing": "{start}~{end} / {total}개", 945 925 "refresh": "새로고침", 946 926 "failedToLoadAuditLog": "감사 로그를 불러오지 못했습니다", 947 - "addControllerTitle": "컨트롤러 추가", 948 - "addControllerDescription": "이 계정에서 지정된 권한으로 작업할 수 있는 사용자를 추가합니다.", 949 - "controllerIdentifier": "컨트롤러 핸들 또는 DID", 950 - "selectScopes": "권한 수준 선택", 951 - "add": "추가", 952 927 "adding": "추가 중...", 953 - "cancel": "취소", 954 928 "accessLevel": "액세스 수준", 955 929 "addControllerButton": "+ 컨트롤러 추가", 956 930 "auditLogDesc": "모든 위임 활동 보기", ··· 974 948 "remove": "제거", 975 949 "removeConfirm": "이 컨트롤러를 제거하시겠습니까?", 976 950 "viewAuditLog": "감사 로그 보기", 977 - "yourAccessLevel": "귀하의 액세스 수준" 951 + "yourAccessLevel": "귀하의 액세스 수준", 952 + "accountCreated": "위임 계정이 생성되었습니다: {handle}" 978 953 }, 979 954 "actAs": { 980 955 "title": "로 활동",
+16 -41
frontend/src/locales/sv.json
··· 234 234 "deleting": "Raderar...", 235 235 "messages": { 236 236 "emailCodeSent": "Verifieringskod skickad till din meddelandekanal", 237 + "emailCodeSentToCurrent": "Verifieringskod skickad till din nuvarande e-postadress", 237 238 "emailUpdated": "E-post uppdaterad", 238 239 "emailUpdateFailed": "Kunde inte uppdatera e-post", 239 240 "handleUpdated": "Användarnamn uppdaterat", ··· 671 672 "noPending": "Ingen väntande verifiering hittades.", 672 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.", 673 674 "createAccount": "Skapa konto", 674 - "signIn": "Logga in" 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..." 675 688 }, 676 689 "resetPassword": { 677 690 "title": "Återställ lösenord", ··· 754 767 "verificationMethod": "Verifieringsmetod", 755 768 "email": "E-postadress", 756 769 "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 770 "inviteCode": "Inbjudningskod", 769 771 "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 772 "externalDid": "Din did:web", 774 773 "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 774 "createButton": "Skapa konto", 778 775 "creating": "Skapar...", 779 776 "alreadyHaveAccount": "Har du redan ett konto?", ··· 896 893 "delegation": { 897 894 "title": "Kontodelegering", 898 895 "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 896 "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 897 "noControllers": "Inga kontrollanter ännu", 903 898 "noControlledAccounts": "Inga kontrollerade konton", 904 899 "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 900 "handle": "Användarnamn", 908 - "handlePlaceholder": "@user.bsky.social", 909 901 "did": "DID", 910 - "didPlaceholder": "did:plc:...", 911 - "scopes": "Behörighetsnivå", 912 902 "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 903 "scopeViewer": "Läsare", 919 - "scopeViewerDesc": "Endast läsåtkomst till dataförvaring och inställningar", 920 904 "scopeCustom": "Anpassad", 921 - "scopeCustomDesc": "Välj individuella behörigheter", 922 - "grantedAt": "Beviljad", 923 - "expiresAt": "Upphör", 924 - "noExpiration": "Ingen utgång", 925 905 "actAs": "Agera som", 926 906 "auditLog": "Granskningslogg", 927 907 "auditLogTitle": "Delegerings-granskningslogg", ··· 944 924 "showing": "{start}–{end} av {total}", 945 925 "refresh": "Uppdatera", 946 926 "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 927 "adding": "Lägger till...", 953 - "cancel": "Avbryt", 954 928 "accessLevel": "Åtkomstnivå", 955 929 "addControllerButton": "+ Lägg till kontrollant", 956 930 "auditLogDesc": "Visa all delegeringsaktivitet", ··· 974 948 "remove": "Ta bort", 975 949 "removeConfirm": "Vill du ta bort denna kontrollant?", 976 950 "viewAuditLog": "Visa granskningslogg", 977 - "yourAccessLevel": "Din åtkomstnivå" 951 + "yourAccessLevel": "Din åtkomstnivå", 952 + "accountCreated": "Skapade delegerat konto: {handle}" 978 953 }, 979 954 "actAs": { 980 955 "title": "Agera som",
+16 -25
frontend/src/locales/zh.json
··· 234 234 "deleting": "删除中...", 235 235 "messages": { 236 236 "emailCodeSent": "验证码已发送到您的通知渠道", 237 + "emailCodeSentToCurrent": "验证码已发送到您当前的邮箱地址", 237 238 "emailUpdated": "邮箱更新成功", 238 239 "emailUpdateFailed": "邮箱更新失败", 239 240 "handleUpdated": "用户名更新成功", ··· 671 672 "continue": "继续", 672 673 "identifierLabel": "邮箱或标识符", 673 674 "identifierPlaceholder": "you@example.com", 674 - "identifierHelp": "接收验证码的邮箱地址或标识符" 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": "更新中..." 675 688 }, 676 689 "resetPassword": { 677 690 "title": "重置密码", ··· 821 834 "passkeysNotSupported": "此浏览器不支持通行密钥。请使用其他浏览器或使用密码注册。", 822 835 "passkeyCancelled": "通行密钥创建已取消", 823 836 "passkeyFailed": "通行密钥注册失败" 824 - } 837 + }, 838 + "didWebWarning1Detail": "您的身份将是 {did}。" 825 839 }, 826 840 "trustedDevices": { 827 841 "title": "受信任设备", ··· 879 893 "delegation": { 880 894 "title": "账户委托", 881 895 "controllers": "控制者", 882 - "controllersDescription": "控制者可以作为您账户的管理员。他们可以执行您允许的操作,代表您发布帖子,以及修改您的数据仓库。", 883 896 "controlledAccounts": "受控账户", 884 - "controlledAccountsDescription": "这些是您被添加为控制者的账户。您可以在这些账户上执行允许的操作。", 885 897 "noControllers": "暂无控制者", 886 898 "noControlledAccounts": "无受控账户", 887 899 "addController": "添加控制者", 888 - "revokeAccess": "撤销访问", 889 - "revokeConfirm": "撤销此控制者的访问权限?他们将无法再在您的账户上执行操作。", 890 900 "handle": "用户名", 891 - "handlePlaceholder": "@user.bsky.social", 892 901 "did": "DID", 893 - "didPlaceholder": "did:plc:...", 894 - "scopes": "权限级别", 895 902 "scopeOwner": "所有者", 896 - "scopeOwnerDesc": "完全控制(可执行所有操作)", 897 - "scopeAdmin": "管理员", 898 - "scopeAdminDesc": "管理帖子、应用专用密码、设置", 899 - "scopeEditor": "编辑者", 900 - "scopeEditorDesc": "创建和管理帖子、点赞、关注", 901 903 "scopeViewer": "查看者", 902 - "scopeViewerDesc": "只读访问数据仓库和设置", 903 904 "scopeCustom": "自定义", 904 - "scopeCustomDesc": "选择单独的权限", 905 - "grantedAt": "授权时间", 906 - "expiresAt": "过期时间", 907 - "noExpiration": "永不过期", 908 905 "actAs": "代理操作", 909 906 "auditLog": "审计日志", 910 907 "auditLogTitle": "委托审计日志", ··· 928 925 "showing": "{start}–{end} / 共{total}条", 929 926 "refresh": "刷新", 930 927 "failedToLoadAuditLog": "加载审计日志失败", 931 - "addControllerTitle": "添加控制者", 932 - "addControllerDescription": "添加一个可以在此账户上执行指定权限操作的用户。", 933 - "controllerIdentifier": "控制者用户名或 DID", 934 - "selectScopes": "选择权限级别", 935 - "add": "添加", 936 928 "adding": "添加中...", 937 - "cancel": "取消", 938 929 "accessLevel": "访问级别", 939 930 "addControllerButton": "+ 添加控制者", 940 931 "auditLogDesc": "查看所有委托活动",
+17 -23
frontend/src/routes/Settings.svelte
··· 56 56 if (message?.text === text) message = null 57 57 }, 5000) 58 58 } 59 - async function handleRequestEmailUpdate(e: Event) { 60 - e.preventDefault() 61 - if (!auth.session || !newEmail) return 59 + async function handleRequestEmailUpdate() { 60 + if (!auth.session) return 62 61 emailLoading = true 63 62 message = null 64 63 try { 65 - const result = await api.requestEmailUpdate(auth.session.accessJwt, newEmail) 64 + const result = await api.requestEmailUpdate(auth.session.accessJwt) 66 65 emailTokenRequired = result.tokenRequired 67 66 if (emailTokenRequired) { 68 - showMessage('success', $_('settings.messages.emailCodeSent')) 67 + showMessage('success', $_('settings.messages.emailCodeSentToCurrent')) 69 68 } else { 70 - await api.updateEmail(auth.session.accessJwt, newEmail) 71 - await refreshSession() 72 - showMessage('success', $_('settings.messages.emailUpdated')) 73 - newEmail = '' 69 + emailTokenRequired = true 74 70 } 75 71 } catch (e) { 76 72 showMessage('error', e instanceof ApiError ? e.message : $_('settings.messages.emailUpdateFailed')) ··· 244 240 required 245 241 /> 246 242 </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 243 <div class="field"> 259 244 <label for="new-email">{$_('settings.newEmail')}</label> 260 245 <input ··· 266 251 required 267 252 /> 268 253 </div> 269 - <button type="submit" disabled={emailLoading || !newEmail}> 270 - {emailLoading ? $_('settings.requesting') : $_('settings.changeEmailButton')} 271 - </button> 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> 272 262 </form> 263 + {:else} 264 + <button onclick={handleRequestEmailUpdate} disabled={emailLoading}> 265 + {emailLoading ? $_('settings.requesting') : $_('settings.changeEmailButton')} 266 + </button> 273 267 {/if} 274 268 </section> 275 269 <section>
+94 -3
frontend/src/routes/Verify.svelte
··· 13 13 channel: string 14 14 } 15 15 16 - type VerificationMode = 'signup' | 'token' 16 + type VerificationMode = 'signup' | 'token' | 'email-update' 17 17 18 18 let mode = $state<VerificationMode>('signup') 19 + let newEmail = $state('') 19 20 let pendingVerification = $state<PendingVerification | null>(null) 20 21 let verificationCode = $state('') 21 22 let identifier = $state('') ··· 50 51 onMount(async () => { 51 52 const params = parseQueryParams() 52 53 53 - if (params.token) { 54 + if (params.type === 'email-update') { 55 + mode = 'email-update' 56 + if (params.token) { 57 + verificationCode = params.token 58 + } 59 + } else if (params.token) { 54 60 mode = 'token' 55 61 verificationCode = params.token 56 62 if (params.identifier) { ··· 134 140 } 135 141 } 136 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 + 137 170 async function handleResendCode() { 138 171 if (mode === 'signup') { 139 172 if (!pendingVerification || resendingCode) return ··· 198 231 {:else if success} 199 232 <div class="success-container"> 200 233 <h1>{$_('verify.verified')}</h1> 201 - {#if successPurpose === 'migration' || successPurpose === 'signup'} 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'} 202 241 <p class="subtitle">{$_('verify.channelVerified', { values: { channel: channelLabel(successChannel || '') } })}</p> 203 242 <p class="info-text">{$_('verify.canNowSignIn')}</p> 204 243 <div class="actions"> ··· 213 252 </div> 214 253 {/if} 215 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} 216 307 {:else if mode === 'token'} 217 308 <h1>{$_('verify.tokenTitle')}</h1> 218 309 <p class="subtitle">{$_('verify.tokenSubtitle')}</p>
+200 -175
src/api/server/email.rs
··· 1 1 use crate::api::ApiError; 2 + use crate::auth::BearerAuth; 2 3 use crate::state::{AppState, RateLimitKind}; 3 4 use axum::{ 4 5 Json, ··· 10 11 use serde_json::json; 11 12 use tracing::{error, info, warn}; 12 13 13 - #[derive(Deserialize)] 14 - #[serde(rename_all = "camelCase")] 15 - pub struct RequestEmailUpdateInput { 16 - pub email: String, 17 - } 18 - 19 14 pub async fn request_email_update( 20 15 State(state): State<AppState>, 21 16 headers: axum::http::HeaderMap, 22 - Json(input): Json<RequestEmailUpdateInput>, 17 + auth: BearerAuth, 23 18 ) -> Response { 24 19 let client_ip = crate::rate_limit::extract_client_ip(&headers, None); 25 20 if !state ··· 37 32 .into_response(); 38 33 } 39 34 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 35 if let Err(e) = crate::auth::scope_check::check_account_scope( 60 - auth_user.is_oauth, 61 - auth_user.scope.as_deref(), 36 + auth.0.is_oauth, 37 + auth.0.scope.as_deref(), 62 38 crate::oauth::scopes::AccountAttr::Email, 63 39 crate::oauth::scopes::AccountAction::Manage, 64 40 ) { 65 41 return e; 66 42 } 67 43 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 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 72 51 { 73 52 Ok(Some(row)) => row, 74 - _ => { 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); 75 62 return ( 76 63 StatusCode::INTERNAL_SERVER_ERROR, 77 64 Json(json!({"error": "InternalError"})), ··· 80 67 } 81 68 }; 82 69 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(); 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 + }; 87 80 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 - } 81 + let token_required = user.email_verified; 95 82 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 - } 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); 115 91 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"})), 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, 130 99 ) 131 - .into_response(); 100 + .await 101 + { 102 + warn!("Failed to enqueue email update notification: {:?}", e); 103 + } 132 104 } 133 105 134 - info!("Email update requested for user {}", user_id); 135 - (StatusCode::OK, Json(json!({ "tokenRequired": true }))).into_response() 106 + info!("Email update requested for user {}", user.id); 107 + (StatusCode::OK, Json(json!({ "tokenRequired": token_required }))).into_response() 136 108 } 137 109 138 110 #[derive(Deserialize)] ··· 145 117 pub async fn confirm_email( 146 118 State(state): State<AppState>, 147 119 headers: axum::http::HeaderMap, 120 + auth: BearerAuth, 148 121 Json(input): Json<ConfirmEmailInput>, 149 122 ) -> Response { 150 123 let client_ip = crate::rate_limit::extract_client_ip(&headers, None); 151 124 if !state 152 - .check_rate_limit(RateLimitKind::AppPassword, &client_ip) 125 + .check_rate_limit(RateLimitKind::EmailUpdate, &client_ip) 153 126 .await 154 127 { 155 128 warn!(ip = %client_ip, "Confirm email rate limit exceeded"); ··· 163 136 .into_response(); 164 137 } 165 138 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 139 if let Err(e) = crate::auth::scope_check::check_account_scope( 186 - auth_user.is_oauth, 187 - auth_user.scope.as_deref(), 140 + auth.0.is_oauth, 141 + auth.0.scope.as_deref(), 188 142 crate::oauth::scopes::AccountAttr::Email, 189 143 crate::oauth::scopes::AccountAction::Manage, 190 144 ) { 191 145 return e; 192 146 } 193 147 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 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 198 155 { 199 - Ok(id) => id, 200 - Err(_) => { 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); 201 166 return ( 202 167 StatusCode::INTERNAL_SERVER_ERROR, 203 168 Json(json!({"error": "InternalError"})), ··· 206 171 } 207 172 }; 208 173 209 - let email = input.email.trim().to_lowercase(); 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 + 210 198 let confirmation_code = 211 199 crate::auth::verification_token::normalize_token_input(input.token.trim()); 212 200 213 - let verified = crate::auth::verification_token::verify_channel_update_token( 201 + let verified = crate::auth::verification_token::verify_signup_token( 214 202 &confirmation_code, 215 203 "email", 216 - &email, 204 + &provided_email, 217 205 ); 218 206 219 207 match verified { ··· 245 233 } 246 234 247 235 let update = sqlx::query!( 248 - "UPDATE users SET email = $1, email_verified = TRUE, updated_at = NOW() WHERE id = $2", 249 - email, 250 - user_id 236 + "UPDATE users SET email_verified = TRUE, updated_at = NOW() WHERE id = $1", 237 + user.id 251 238 ) 252 239 .execute(&state.db) 253 240 .await; 254 241 255 242 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 - } 243 + error!("DB error confirming email: {:?}", e); 267 244 return ( 268 245 StatusCode::INTERNAL_SERVER_ERROR, 269 246 Json(json!({"error": "InternalError"})), ··· 271 248 .into_response(); 272 249 } 273 250 274 - info!("Email updated for user {}", user_id); 251 + info!("Email confirmed for user {}", user.id); 275 252 (StatusCode::OK, Json(json!({}))).into_response() 276 253 } 277 254 ··· 289 266 headers: axum::http::HeaderMap, 290 267 Json(input): Json<UpdateEmailInput>, 291 268 ) -> Response { 292 - let token = match crate::auth::extract_bearer_token_from_header( 269 + let bearer_token = match crate::auth::extract_bearer_token_from_header( 293 270 headers.get("Authorization").and_then(|h| h.to_str().ok()), 294 271 ) { 295 272 Some(t) => t, ··· 302 279 } 303 280 }; 304 281 305 - let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; 282 + let auth_result = crate::auth::validate_bearer_token(&state.db, &bearer_token).await; 306 283 let auth_user = match auth_result { 307 284 Ok(user) => user, 308 285 Err(e) => return ApiError::from(e).into_response(), ··· 318 295 } 319 296 320 297 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 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 324 304 { 325 305 Ok(Some(row)) => row, 326 - _ => { 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); 327 315 return ( 328 316 StatusCode::INTERNAL_SERVER_ERROR, 329 317 Json(json!({"error": "InternalError"})), ··· 333 321 }; 334 322 335 323 let user_id = user.id; 336 - let current_email = user.email; 324 + let current_email = user.email.clone(); 325 + let email_verified = user.email_verified; 337 326 let new_email = input.email.trim().to_lowercase(); 338 327 339 328 if !crate::api::validation::is_valid_email(&new_email) { 340 329 return ( 341 330 StatusCode::BAD_REQUEST, 342 - Json(json!({"error": "InvalidEmail", "message": "Invalid email format"})), 331 + Json(json!({ 332 + "error": "InvalidRequest", 333 + "message": "This email address is not supported, please use a different email." 334 + })), 343 335 ) 344 336 .into_response(); 345 337 } ··· 350 342 return (StatusCode::OK, Json(json!({}))).into_response(); 351 343 } 352 344 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 - }; 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(); 363 364 364 - let verified = crate::auth::verification_token::verify_channel_update_token( 365 - &confirmation_token, 366 - "email", 367 - &new_email, 368 - ); 365 + let verified = crate::auth::verification_token::verify_channel_update_token( 366 + &confirmation_token, 367 + "email_update", 368 + &current_email_lower, 369 + ); 369 370 370 - match verified { 371 - Ok(token_data) => { 372 - if token_data.did != did { 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(_) => { 373 391 return ( 374 392 StatusCode::BAD_REQUEST, 375 - Json( 376 - json!({"error": "InvalidToken", "message": "Token does not match account"}), 377 - ), 393 + Json(json!({"error": "InvalidToken", "message": "Invalid token"})), 378 394 ) 379 395 .into_response(); 380 396 } 381 397 } 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 398 } 397 399 398 400 let exists = sqlx::query!( ··· 406 408 if let Ok(Some(_)) = exists { 407 409 return ( 408 410 StatusCode::BAD_REQUEST, 409 - Json(json!({"error": "InvalidRequest", "message": "Email already in use"})), 411 + Json(json!({ 412 + "error": "InvalidRequest", 413 + "message": "This email address is already in use, please use a different email." 414 + })), 410 415 ) 411 416 .into_response(); 412 417 } 413 418 414 - let update = sqlx::query!( 415 - "UPDATE users SET email = $1, email_verified = TRUE, updated_at = NOW() WHERE id = $2", 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", 416 421 new_email, 417 422 user_id 418 423 ) ··· 420 425 .await; 421 426 422 427 if let Err(e) = update { 423 - error!("DB error finalizing email update: {:?}", e); 428 + error!("DB error updating email: {:?}", e); 424 429 if e.as_database_error() 425 - .map(|db_err| db_err.is_unique_violation()) 430 + .map(|db_err: &dyn sqlx::error::DatabaseError| db_err.is_unique_violation()) 426 431 .unwrap_or(false) 427 432 { 428 433 return ( 429 434 StatusCode::BAD_REQUEST, 430 - Json(json!({"error": "InvalidRequest", "message": "Email already in use"})), 435 + Json(json!({ 436 + "error": "InvalidRequest", 437 + "message": "This email address is already in use, please use a different email." 438 + })), 431 439 ) 432 440 .into_response(); 433 441 } ··· 436 444 Json(json!({"error": "InternalError"})), 437 445 ) 438 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); 439 464 } 440 465 441 466 match sqlx::query!(
+3 -3
src/comms/mod.rs
··· 10 10 11 11 pub use service::{ 12 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, 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 16 }; 17 17 18 18 pub use types::{CommsChannel, CommsStatus, CommsType, NewComms, QueuedComms};
+38
src/comms/service.rs
··· 380 380 .await 381 381 } 382 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 + 383 421 pub async fn enqueue_account_deletion( 384 422 db: &PgPool, 385 423 user_id: Uuid,
+255 -158
tests/email_update.rs
··· 63 63 } 64 64 65 65 #[tokio::test] 66 - async fn test_email_update_flow_success() { 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() { 67 89 let client = common::client(); 68 90 let base_url = common::base_url().await; 69 91 let pool = get_pool().await; ··· 71 93 let email = format!("{}@example.com", handle); 72 94 let (access_jwt, did) = create_verified_account(&client, &base_url, &handle, &email).await; 73 95 let new_email = format!("new_{}@example.com", handle); 96 + 74 97 let res = client 75 98 .post(format!( 76 99 "{}/xrpc/com.atproto.server.requestEmailUpdate", 77 100 base_url 78 101 )) 79 102 .bearer_auth(&access_jwt) 80 - .json(&json!({"email": new_email})) 81 103 .send() 82 104 .await 83 105 .expect("Failed to request email update"); ··· 86 108 assert_eq!(body["tokenRequired"], true); 87 109 88 110 let code = get_email_update_token(&pool, &did).await; 111 + 89 112 let res = client 90 - .post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url)) 113 + .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) 91 114 .bearer_auth(&access_jwt) 92 115 .json(&json!({ 93 116 "email": new_email, ··· 95 118 })) 96 119 .send() 97 120 .await 98 - .expect("Failed to confirm email"); 121 + .expect("Failed to update email"); 99 122 assert_eq!(res.status(), StatusCode::OK); 100 - let user = sqlx::query!("SELECT email FROM users WHERE did = $1", did) 123 + 124 + let user_email: Option<String> = sqlx::query_scalar!("SELECT email FROM users WHERE did = $1", did) 101 125 .fetch_one(&pool) 102 126 .await 103 127 .expect("User not found"); 104 - assert_eq!(user.email, Some(new_email)); 128 + assert_eq!(user_email, Some(new_email)); 105 129 } 106 130 107 131 #[tokio::test] 108 - async fn test_request_email_update_taken_email() { 132 + async fn test_update_email_requires_token_when_verified() { 109 133 let client = common::client(); 110 134 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; 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 + 117 140 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})) 141 + .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) 142 + .bearer_auth(&access_jwt) 143 + .json(&json!({ "email": new_email })) 124 144 .send() 125 145 .await 126 - .expect("Failed to request email update"); 146 + .expect("Failed to update email"); 127 147 assert_eq!(res.status(), StatusCode::BAD_REQUEST); 128 148 let body: Value = res.json().await.expect("Invalid JSON"); 129 - assert_eq!(body["error"], "EmailTaken"); 149 + assert_eq!(body["error"], "TokenRequired"); 130 150 } 131 151 132 152 #[tokio::test] 133 - async fn test_confirm_email_invalid_token() { 153 + async fn test_update_email_same_email_noop() { 134 154 let client = common::client(); 135 155 let base_url = common::base_url().await; 136 - let handle = format!("emailup-inv-{}", uuid::Uuid::new_v4()); 156 + let handle = format!("emailup-same-{}", uuid::Uuid::new_v4()); 137 157 let email = format!("{}@example.com", handle); 138 158 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); 159 + 151 160 let res = client 152 - .post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url)) 161 + .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) 153 162 .bearer_auth(&access_jwt) 154 - .json(&json!({ 155 - "email": new_email, 156 - "token": "wrong-token" 157 - })) 163 + .json(&json!({ "email": email })) 158 164 .send() 159 165 .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"); 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 + ); 164 172 } 165 173 166 174 #[tokio::test] 167 - async fn test_confirm_email_wrong_email() { 175 + async fn test_update_email_invalid_token() { 168 176 let client = common::client(); 169 177 let base_url = common::base_url().await; 170 - let pool = get_pool().await; 171 - let handle = format!("emailup-wrong-{}", uuid::Uuid::new_v4()); 178 + let handle = format!("emailup-badtok-{}", uuid::Uuid::new_v4()); 172 179 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); 180 + let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await; 181 + let new_email = format!("badtok_{}@example.com", handle); 182 + 175 183 let res = client 176 184 .post(format!( 177 185 "{}/xrpc/com.atproto.server.requestEmailUpdate", 178 186 base_url 179 187 )) 180 188 .bearer_auth(&access_jwt) 181 - .json(&json!({"email": new_email})) 182 189 .send() 183 190 .await 184 191 .expect("Failed to request email update"); 185 192 assert_eq!(res.status(), StatusCode::OK); 186 - let code = get_email_update_token(&pool, &did).await; 193 + 187 194 let res = client 188 - .post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url)) 195 + .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) 189 196 .bearer_auth(&access_jwt) 190 197 .json(&json!({ 191 - "email": "another_random@example.com", 192 - "token": code 198 + "email": new_email, 199 + "token": "wrong-token-12345" 193 200 })) 194 201 .send() 195 202 .await 196 - .expect("Failed to confirm email"); 203 + .expect("Failed to attempt email update"); 197 204 assert_eq!(res.status(), StatusCode::BAD_REQUEST); 198 205 let body: Value = res.json().await.expect("Invalid JSON"); 199 - assert!( 200 - body["message"].as_str().unwrap().contains("mismatch") || body["error"] == "InvalidToken" 201 - ); 206 + assert_eq!(body["error"], "InvalidToken"); 202 207 } 203 208 204 209 #[tokio::test] 205 - async fn test_update_email_requires_token() { 210 + async fn test_update_email_no_auth() { 206 211 let client = common::client(); 207 212 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); 213 + 212 214 let res = client 213 215 .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) 214 - .bearer_auth(&access_jwt) 215 - .json(&json!({ "email": new_email })) 216 + .json(&json!({ "email": "test@example.com" })) 216 217 .send() 217 218 .await 218 - .expect("Failed to update email"); 219 - assert_eq!(res.status(), StatusCode::BAD_REQUEST); 219 + .expect("Failed to send request"); 220 + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); 220 221 let body: Value = res.json().await.expect("Invalid JSON"); 221 - assert_eq!(body["error"], "TokenRequired"); 222 + assert_eq!(body["error"], "AuthenticationRequired"); 222 223 } 223 224 224 225 #[tokio::test] 225 - async fn test_update_email_same_email_noop() { 226 + async fn test_update_email_invalid_format() { 226 227 let client = common::client(); 227 228 let base_url = common::base_url().await; 228 - let handle = format!("emailup-same-{}", uuid::Uuid::new_v4()); 229 + let handle = format!("emailup-fmt-{}", uuid::Uuid::new_v4()); 229 230 let email = format!("{}@example.com", handle); 230 231 let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await; 232 + 231 233 let res = client 232 234 .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) 233 235 .bearer_auth(&access_jwt) 234 - .json(&json!({ "email": email })) 236 + .json(&json!({ "email": "not-an-email" })) 235 237 .send() 236 238 .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 - ); 239 + .expect("Failed to send request"); 240 + assert_eq!(res.status(), StatusCode::BAD_REQUEST); 243 241 } 244 242 245 243 #[tokio::test] 246 - async fn test_update_email_requires_token_after_pending() { 244 + async fn test_confirm_email_confirms_existing_email() { 247 245 let client = common::client(); 248 246 let base_url = common::base_url().await; 249 - let handle = format!("emailup-token-{}", uuid::Uuid::new_v4()); 247 + let pool = get_pool().await; 248 + let handle = format!("emailconfirm-{}", uuid::Uuid::new_v4()); 250 249 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); 250 + 253 251 let res = client 254 252 .post(format!( 255 - "{}/xrpc/com.atproto.server.requestEmailUpdate", 253 + "{}/xrpc/com.atproto.server.createAccount", 256 254 base_url 257 255 )) 258 - .bearer_auth(&access_jwt) 259 - .json(&json!({"email": new_email})) 256 + .json(&json!({ 257 + "handle": handle, 258 + "email": email, 259 + "password": "Testpass123!" 260 + })) 260 261 .send() 261 262 .await 262 - .expect("Failed to request email update"); 263 + .expect("Failed to create account"); 263 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 + 264 283 let res = client 265 - .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) 284 + .post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url)) 266 285 .bearer_auth(&access_jwt) 267 - .json(&json!({ "email": new_email })) 286 + .json(&json!({ 287 + "email": email, 288 + "token": code 289 + })) 268 290 .send() 269 291 .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"); 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); 274 303 } 275 304 276 305 #[tokio::test] 277 - async fn test_update_email_with_valid_token() { 306 + async fn test_confirm_email_rejects_wrong_email() { 278 307 let client = common::client(); 279 308 let base_url = common::base_url().await; 280 309 let pool = get_pool().await; 281 - let handle = format!("emailup-valid-{}", uuid::Uuid::new_v4()); 310 + let handle = format!("emailconf-wrong-{}", uuid::Uuid::new_v4()); 282 311 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); 312 + 285 313 let res = client 286 314 .post(format!( 287 - "{}/xrpc/com.atproto.server.requestEmailUpdate", 315 + "{}/xrpc/com.atproto.server.createAccount", 288 316 base_url 289 317 )) 290 - .bearer_auth(&access_jwt) 291 - .json(&json!({"email": new_email})) 318 + .json(&json!({ 319 + "handle": handle, 320 + "email": email, 321 + "password": "Testpass123!" 322 + })) 292 323 .send() 293 324 .await 294 - .expect("Failed to request email update"); 325 + .expect("Failed to create account"); 295 326 assert_eq!(res.status(), StatusCode::OK); 296 - let code = get_email_update_token(&pool, &did).await; 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 + 297 345 let res = client 298 - .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) 346 + .post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url)) 299 347 .bearer_auth(&access_jwt) 300 348 .json(&json!({ 301 - "email": new_email, 349 + "email": "different@example.com", 302 350 "token": code 303 351 })) 304 352 .send() 305 353 .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)); 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"); 313 358 } 314 359 315 360 #[tokio::test] 316 - async fn test_update_email_invalid_token() { 361 + async fn test_confirm_email_invalid_token() { 317 362 let client = common::client(); 318 363 let base_url = common::base_url().await; 319 - let handle = format!("emailup-badtok-{}", uuid::Uuid::new_v4()); 364 + let handle = format!("emailconf-inv-{}", uuid::Uuid::new_v4()); 320 365 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); 366 + 323 367 let res = client 324 368 .post(format!( 325 - "{}/xrpc/com.atproto.server.requestEmailUpdate", 369 + "{}/xrpc/com.atproto.server.createAccount", 326 370 base_url 327 371 )) 328 - .bearer_auth(&access_jwt) 329 - .json(&json!({"email": new_email})) 372 + .json(&json!({ 373 + "handle": handle, 374 + "email": email, 375 + "password": "Testpass123!" 376 + })) 330 377 .send() 331 378 .await 332 - .expect("Failed to request email update"); 379 + .expect("Failed to create account"); 333 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 + 334 384 let res = client 335 - .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) 385 + .post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url)) 336 386 .bearer_auth(&access_jwt) 337 387 .json(&json!({ 338 - "email": new_email, 339 - "token": "wrong-token-12345" 388 + "email": email, 389 + "token": "wrong-token" 340 390 })) 341 391 .send() 342 392 .await 343 - .expect("Failed to attempt email update"); 393 + .expect("Failed to confirm email"); 344 394 assert_eq!(res.status(), StatusCode::BAD_REQUEST); 345 395 let body: Value = res.json().await.expect("Invalid JSON"); 346 396 assert_eq!(body["error"], "InvalidToken"); 347 397 } 348 398 349 399 #[tokio::test] 350 - async fn test_update_email_already_taken() { 400 + async fn test_unverified_account_can_update_email_without_token() { 351 401 let client = common::client(); 352 402 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; 403 + let pool = get_pool().await; 404 + let handle = format!("emailup-unverified-{}", uuid::Uuid::new_v4()); 405 + let email = format!("{}@example.com", handle); 406 + 359 407 let res = client 360 - .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) 361 - .bearer_auth(&access_jwt2) 362 - .json(&json!({ "email": email1 })) 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 + })) 363 417 .send() 364 418 .await 365 - .expect("Failed to attempt email update"); 366 - assert_eq!(res.status(), StatusCode::BAD_REQUEST); 419 + .expect("Failed to create account"); 420 + assert_eq!(res.status(), StatusCode::OK); 367 421 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" 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" 375 439 ); 376 - } 377 440 378 - #[tokio::test] 379 - async fn test_update_email_no_auth() { 380 - let client = common::client(); 381 - let base_url = common::base_url().await; 441 + let new_email = format!("new_{}@example.com", handle); 382 442 let res = client 383 443 .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) 384 - .json(&json!({ "email": "test@example.com" })) 444 + .bearer_auth(&access_jwt) 445 + .json(&json!({ "email": new_email })) 385 446 .send() 386 447 .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"); 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)); 391 461 } 392 462 393 463 #[tokio::test] 394 - async fn test_update_email_invalid_format() { 464 + async fn test_update_email_taken_by_another_user() { 395 465 let client = common::client(); 396 466 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; 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 + 400 490 let res = client 401 491 .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) 402 - .bearer_auth(&access_jwt) 403 - .json(&json!({ "email": "not-an-email" })) 492 + .bearer_auth(&access_jwt2) 493 + .json(&json!({ 494 + "email": email1, 495 + "token": code 496 + })) 404 497 .send() 405 498 .await 406 - .expect("Failed to send request"); 499 + .expect("Failed to update email"); 407 500 assert_eq!(res.status(), StatusCode::BAD_REQUEST); 408 501 let body: Value = res.json().await.expect("Invalid JSON"); 409 - assert_eq!(body["error"], "InvalidEmail"); 502 + assert_eq!(body["error"], "InvalidRequest"); 503 + assert!(body["message"] 504 + .as_str() 505 + .unwrap_or("") 506 + .contains("already in use")); 410 507 }