Our Personal Data Server from scratch! tranquil.farm
pds rust database fun oauth atproto
238
fork

Configure Feed

Select the types of activity you want to include in your feed.

Email conf. vs ref

+768 -558
+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 }