diff --git a/.sqlx/query-92d601a6ea9ca3bcbafc228b258ede6948c18f2c824be8dcce434041d386e439.json b/.sqlx/query-92d601a6ea9ca3bcbafc228b258ede6948c18f2c824be8dcce434041d386e439.json new file mode 100644 index 0000000..395a4bd --- /dev/null +++ b/.sqlx/query-92d601a6ea9ca3bcbafc228b258ede6948c18f2c824be8dcce434041d386e439.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET email_verified = TRUE, updated_at = NOW() WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "92d601a6ea9ca3bcbafc228b258ede6948c18f2c824be8dcce434041d386e439" +} diff --git a/.sqlx/query-ad9d1f4dbd7075a15733e2366db78fb42554ba52b985068318ff3af0e4ad81a6.json b/.sqlx/query-ad9d1f4dbd7075a15733e2366db78fb42554ba52b985068318ff3af0e4ad81a6.json new file mode 100644 index 0000000..bf89755 --- /dev/null +++ b/.sqlx/query-ad9d1f4dbd7075a15733e2366db78fb42554ba52b985068318ff3af0e4ad81a6.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET email = $1, email_verified = FALSE, updated_at = NOW() WHERE id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "ad9d1f4dbd7075a15733e2366db78fb42554ba52b985068318ff3af0e4ad81a6" +} diff --git a/.sqlx/query-d6ec64a262936b8def9e5cad7d021df408b3a9b80fce5a7446647fbf276092ca.json b/.sqlx/query-d6ec64a262936b8def9e5cad7d021df408b3a9b80fce5a7446647fbf276092ca.json deleted file mode 100644 index fdc3811..0000000 --- a/.sqlx/query-d6ec64a262936b8def9e5cad7d021df408b3a9b80fce5a7446647fbf276092ca.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT id, email FROM users WHERE did = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "email", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - true - ] - }, - "hash": "d6ec64a262936b8def9e5cad7d021df408b3a9b80fce5a7446647fbf276092ca" -} diff --git a/.sqlx/query-e1d6f474116c4eade83f39956a7ce32a175f8cdfce0d30bbb2cf155aa11bc840.json b/.sqlx/query-e1d6f474116c4eade83f39956a7ce32a175f8cdfce0d30bbb2cf155aa11bc840.json new file mode 100644 index 0000000..5dc1aac --- /dev/null +++ b/.sqlx/query-e1d6f474116c4eade83f39956a7ce32a175f8cdfce0d30bbb2cf155aa11bc840.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, handle, email, email_verified FROM users WHERE did = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "handle", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "email_verified", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + true, + false + ] + }, + "hash": "e1d6f474116c4eade83f39956a7ce32a175f8cdfce0d30bbb2cf155aa11bc840" +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 178b050..0f2136a 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -279,12 +279,10 @@ export const api = { async requestEmailUpdate( token: string, - email: string, ): Promise<{ tokenRequired: boolean }> { return xrpc("com.atproto.server.requestEmailUpdate", { method: "POST", token, - body: { email }, }); }, diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index d05f57c..826b3e8 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -234,6 +234,7 @@ "deleting": "Deleting...", "messages": { "emailCodeSent": "Verification code sent to your notification channel", + "emailCodeSentToCurrent": "Verification code sent to your current email address", "emailUpdated": "Email updated successfully", "emailUpdateFailed": "Failed to update email", "handleUpdated": "Handle updated successfully", @@ -659,6 +660,7 @@ "codeResent": "Verification code resent!", "codeResentDetail": "Verification code sent! Check your inbox.", "backToLogin": "Back to Login", + "backToSettings": "Back to Settings", "verifyingAccount": "Verifying account: @{handle}", "startOver": "Start over with a different account", "noPending": "No pending verification found.", @@ -671,7 +673,18 @@ "continue": "Continue", "identifierLabel": "Email or Identifier", "identifierPlaceholder": "you@example.com", - "identifierHelp": "The email address or identifier the code was sent to" + "identifierHelp": "The email address or identifier the code was sent to", + "emailUpdateTitle": "Update Email Address", + "emailUpdateSubtitle": "Enter your new email address and the verification code sent to your current email.", + "emailUpdateRequiresAuth": "You must be signed in to update your email address.", + "emailUpdateFailed": "Failed to update email address", + "emailUpdateCodeHelp": "The code was sent to your current email address", + "newEmailLabel": "New Email Address", + "newEmailPlaceholder": "new@example.com", + "updateEmail": "Update Email", + "updating": "Updating...", + "emailUpdated": "Your email has been updated successfully.", + "emailUpdatedInfo": "You may need to verify your new email address." }, "resetPassword": { "title": "Reset Password", diff --git a/frontend/src/locales/fi.json b/frontend/src/locales/fi.json index 7060827..b3bd448 100644 --- a/frontend/src/locales/fi.json +++ b/frontend/src/locales/fi.json @@ -234,6 +234,7 @@ "deleting": "Poistetaan...", "messages": { "emailCodeSent": "Vahvistuskoodi lähetetty ilmoituskanavallesi", + "emailCodeSentToCurrent": "Vahvistuskoodi lähetetty nykyiseen sähköpostiosoitteeseesi", "emailUpdated": "Sähköposti päivitetty", "emailUpdateFailed": "Sähköpostin päivitys epäonnistui", "handleUpdated": "Käyttäjänimi päivitetty", @@ -671,7 +672,19 @@ "noPending": "Odottavaa vahvistusta ei löytynyt.", "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.", "createAccount": "Luo tili", - "signIn": "Kirjaudu sisään" + "signIn": "Kirjaudu sisään", + "backToSettings": "Takaisin asetuksiin", + "emailUpdateCodeHelp": "Koodi lähetettiin nykyiseen sähköpostiosoitteeseesi", + "emailUpdateFailed": "Sähköpostiosoitteen päivitys epäonnistui", + "emailUpdateRequiresAuth": "Sinun on kirjauduttava sisään päivittääksesi sähköpostiosoitteesi.", + "emailUpdateSubtitle": "Syötä uusi sähköpostiosoitteesi ja nykyiseen sähköpostiisi lähetetty vahvistuskoodi.", + "emailUpdateTitle": "Päivitä sähköpostiosoite", + "emailUpdated": "Sähköpostiosoitteesi on päivitetty.", + "emailUpdatedInfo": "Sinun on ehkä vahvistettava uusi sähköpostiosoitteesi.", + "newEmailLabel": "Uusi sähköpostiosoite", + "newEmailPlaceholder": "uusi@esimerkki.fi", + "updateEmail": "Päivitä sähköposti", + "updating": "Päivitetään..." }, "resetPassword": { "title": "Palauta salasana", @@ -754,26 +767,10 @@ "verificationMethod": "Vahvistusmenetelmä", "email": "Sähköpostiosoite", "emailPlaceholder": "sinä@esimerkki.fi", - "discord": "Discord", - "discordId": "Discord-käyttäjätunnus", - "discordIdPlaceholder": "Discord-käyttäjätunnuksesi", - "discordIdHint": "Numeerinen Discord-käyttäjätunnuksesi (ota Kehittäjätila käyttöön löytääksesi sen)", - "telegram": "Telegram", - "telegramUsername": "Telegram-käyttäjänimi", - "telegramUsernamePlaceholder": "@käyttäjänimesi", - "signal": "Signal", - "signalNumber": "Signal-puhelinnumero", - "signalNumberPlaceholder": "+358401234567", - "signalNumberHint": "Sisällytä maakoodi (esim. +358 Suomelle)", "inviteCode": "Kutsukoodi", "inviteCodePlaceholder": "Syötä kutsukoodisi", - "inviteCodeRequired": "vaaditaan", - "didWebDescription": "Käytä DID-identiteettiä, jota isännöidään omalla verkkotunnuksellasi.", - "didWebToggle": "Käytä ulkoista did:web", "externalDid": "Sinun did:web", "externalDidPlaceholder": "did:web:verkkotunnuksesi.fi", - "dnsVerificationInstructions": "Vahvistaaksesi verkkotunnuksesi, lisää tämä TXT-tietue:", - "copyDid": "Kopioi DID", "createButton": "Luo tili", "creating": "Luodaan...", "alreadyHaveAccount": "Onko sinulla jo tili?", diff --git a/frontend/src/locales/ja.json b/frontend/src/locales/ja.json index edef215..7f9cc1e 100644 --- a/frontend/src/locales/ja.json +++ b/frontend/src/locales/ja.json @@ -234,6 +234,7 @@ "deleting": "削除中...", "messages": { "emailCodeSent": "通知チャンネルに確認コードを送信しました", + "emailCodeSentToCurrent": "現在のメールアドレスに確認コードを送信しました", "emailUpdated": "メールを更新しました", "emailUpdateFailed": "メールの更新に失敗しました", "handleUpdated": "ハンドルを更新しました", @@ -671,7 +672,19 @@ "noPending": "保留中の確認が見つかりません。", "noPendingInfo": "最近アカウントを作成して確認が必要な場合は、新しいアカウントを作成する必要があります。すでにアカウントを確認した場合は、サインインできます。", "createAccount": "アカウントを作成", - "signIn": "サインイン" + "signIn": "サインイン", + "backToSettings": "設定に戻る", + "emailUpdateCodeHelp": "コードは現在のメールアドレスに送信されました", + "emailUpdateFailed": "メールアドレスの更新に失敗しました", + "emailUpdateRequiresAuth": "メールアドレスを更新するにはサインインが必要です。", + "emailUpdateSubtitle": "新しいメールアドレスと、現在のメールに送信された確認コードを入力してください。", + "emailUpdateTitle": "メールアドレスの更新", + "emailUpdated": "メールアドレスが正常に更新されました。", + "emailUpdatedInfo": "新しいメールアドレスの確認が必要な場合があります。", + "newEmailLabel": "新しいメールアドレス", + "newEmailPlaceholder": "new@example.com", + "updateEmail": "メールを更新", + "updating": "更新中..." }, "resetPassword": { "title": "パスワードリセット", @@ -754,26 +767,10 @@ "verificationMethod": "確認方法", "email": "メールアドレス", "emailPlaceholder": "you@example.com", - "discord": "Discord", - "discordId": "Discord ユーザー ID", - "discordIdPlaceholder": "Discord ユーザー ID", - "discordIdHint": "数値の Discord ユーザー ID(開発者モードを有効にして確認)", - "telegram": "Telegram", - "telegramUsername": "Telegram ユーザー名", - "telegramUsernamePlaceholder": "@yourusername", - "signal": "Signal", - "signalNumber": "Signal 電話番号", - "signalNumberPlaceholder": "+81XXXXXXXXXX", - "signalNumberHint": "国番号を含めてください(例: 日本は +81)", "inviteCode": "招待コード", "inviteCodePlaceholder": "招待コードを入力", - "inviteCodeRequired": "必須", - "didWebDescription": "独自ドメインでホストされる DID アイデンティティを使用します。", - "didWebToggle": "外部 did:web を使用", "externalDid": "あなたの did:web", "externalDidPlaceholder": "did:web:yourdomain.com", - "dnsVerificationInstructions": "ドメインを確認するには、この TXT レコードを追加してください:", - "copyDid": "DID をコピー", "createButton": "アカウントを作成", "creating": "作成中...", "alreadyHaveAccount": "すでにアカウントをお持ちですか?", @@ -896,32 +893,15 @@ "delegation": { "title": "アカウント委任", "controllers": "コントローラー", - "controllersDescription": "コントローラーはあなたのアカウントの管理者として行動できます。あなたが許可した操作を実行し、あなたの代わりに投稿を作成し、リポジトリを変更できます。", "controlledAccounts": "管理アカウント", - "controlledAccountsDescription": "これらはあなたがコントローラーとして追加されているアカウントです。これらのアカウントで許可されたアクションを実行できます。", "noControllers": "コントローラーはまだいません", "noControlledAccounts": "管理アカウントはありません", "addController": "コントローラーを追加", - "revokeAccess": "アクセスを取り消す", - "revokeConfirm": "このコントローラーのアクセスを取り消しますか?あなたのアカウントで操作できなくなります。", "handle": "ハンドル", - "handlePlaceholder": "@user.bsky.social", "did": "DID", - "didPlaceholder": "did:plc:...", - "scopes": "権限レベル", "scopeOwner": "オーナー", - "scopeOwnerDesc": "完全な管理(すべてのアクションを実行可能)", - "scopeAdmin": "管理者", - "scopeAdminDesc": "投稿、アプリパスワード、設定の管理", - "scopeEditor": "編集者", - "scopeEditorDesc": "投稿、いいね、フォローの作成・管理", "scopeViewer": "閲覧者", - "scopeViewerDesc": "リポジトリと設定の読み取り専用アクセス", "scopeCustom": "カスタム", - "scopeCustomDesc": "個別の権限を選択", - "grantedAt": "許可日時", - "expiresAt": "有効期限", - "noExpiration": "無期限", "actAs": "として行動", "auditLog": "監査ログ", "auditLogTitle": "委任監査ログ", @@ -944,13 +924,7 @@ "showing": "{start}~{end} / {total}件", "refresh": "更新", "failedToLoadAuditLog": "監査ログの読み込みに失敗しました", - "addControllerTitle": "コントローラーを追加", - "addControllerDescription": "このアカウントに対して指定した権限で操作できるユーザーを追加します。", - "controllerIdentifier": "コントローラーのハンドルまたはDID", - "selectScopes": "権限レベルを選択", - "add": "追加", "adding": "追加中...", - "cancel": "キャンセル", "accessLevel": "アクセスレベル", "addControllerButton": "+ コントローラーを追加", "auditLogDesc": "すべての委任アクティビティを表示", @@ -974,7 +948,8 @@ "remove": "削除", "removeConfirm": "このコントローラーを削除しますか?", "viewAuditLog": "監査ログを表示", - "yourAccessLevel": "あなたのアクセスレベル" + "yourAccessLevel": "あなたのアクセスレベル", + "accountCreated": "委任アカウントを作成しました: {handle}" }, "actAs": { "title": "として行動", diff --git a/frontend/src/locales/ko.json b/frontend/src/locales/ko.json index e71bcad..0e45776 100644 --- a/frontend/src/locales/ko.json +++ b/frontend/src/locales/ko.json @@ -234,6 +234,7 @@ "deleting": "삭제 중...", "messages": { "emailCodeSent": "알림 채널로 인증 코드를 보냈습니다", + "emailCodeSentToCurrent": "현재 이메일 주소로 인증 코드를 보냈습니다", "emailUpdated": "이메일이 업데이트되었습니다", "emailUpdateFailed": "이메일 업데이트에 실패했습니다", "handleUpdated": "핸들이 업데이트되었습니다", @@ -671,7 +672,19 @@ "noPending": "보류 중인 인증이 없습니다.", "noPendingInfo": "최근에 계정을 만들고 인증이 필요한 경우 새 계정을 만들어야 합니다. 이미 계정을 인증한 경우 로그인할 수 있습니다.", "createAccount": "계정 만들기", - "signIn": "로그인" + "signIn": "로그인", + "backToSettings": "설정으로 돌아가기", + "emailUpdateCodeHelp": "코드가 현재 이메일 주소로 전송되었습니다", + "emailUpdateFailed": "이메일 주소 업데이트 실패", + "emailUpdateRequiresAuth": "이메일 주소를 업데이트하려면 로그인해야 합니다.", + "emailUpdateSubtitle": "새 이메일 주소와 현재 이메일로 전송된 인증 코드를 입력하세요.", + "emailUpdateTitle": "이메일 주소 업데이트", + "emailUpdated": "이메일 주소가 성공적으로 업데이트되었습니다.", + "emailUpdatedInfo": "새 이메일 주소를 인증해야 할 수 있습니다.", + "newEmailLabel": "새 이메일 주소", + "newEmailPlaceholder": "new@example.com", + "updateEmail": "이메일 업데이트", + "updating": "업데이트 중..." }, "resetPassword": { "title": "비밀번호 재설정", @@ -754,26 +767,10 @@ "verificationMethod": "인증 방법", "email": "이메일 주소", "emailPlaceholder": "you@example.com", - "discord": "Discord", - "discordId": "Discord 사용자 ID", - "discordIdPlaceholder": "Discord 사용자 ID", - "discordIdHint": "숫자 Discord 사용자 ID (개발자 모드를 활성화하여 찾기)", - "telegram": "Telegram", - "telegramUsername": "Telegram 사용자 이름", - "telegramUsernamePlaceholder": "@yourusername", - "signal": "Signal", - "signalNumber": "Signal 전화번호", - "signalNumberPlaceholder": "+821012345678", - "signalNumberHint": "국가 코드 포함 (예: 한국 +82)", "inviteCode": "초대 코드", "inviteCodePlaceholder": "초대 코드 입력", - "inviteCodeRequired": "필수", - "didWebDescription": "자체 도메인에서 호스팅되는 DID 아이덴티티를 사용합니다.", - "didWebToggle": "외부 did:web 사용", "externalDid": "귀하의 did:web", "externalDidPlaceholder": "did:web:yourdomain.com", - "dnsVerificationInstructions": "도메인을 인증하려면 이 TXT 레코드를 추가하세요:", - "copyDid": "DID 복사", "createButton": "계정 만들기", "creating": "생성 중...", "alreadyHaveAccount": "이미 계정이 있으신가요?", @@ -896,32 +893,15 @@ "delegation": { "title": "계정 위임", "controllers": "컨트롤러", - "controllersDescription": "컨트롤러는 귀하의 계정 관리자로서 행동할 수 있습니다. 귀하가 허용한 작업을 수행하고, 귀하를 대신하여 게시물을 생성하고, 저장소를 수정할 수 있습니다.", "controlledAccounts": "관리 계정", - "controlledAccountsDescription": "귀하가 컨트롤러로 추가된 계정들입니다. 이 계정들에서 허용된 작업을 수행할 수 있습니다.", "noControllers": "아직 컨트롤러가 없습니다", "noControlledAccounts": "관리 계정이 없습니다", "addController": "컨트롤러 추가", - "revokeAccess": "액세스 취소", - "revokeConfirm": "이 컨트롤러의 액세스를 취소하시겠습니까? 귀하의 계정에서 더 이상 작업을 수행할 수 없습니다.", "handle": "핸들", - "handlePlaceholder": "@user.bsky.social", "did": "DID", - "didPlaceholder": "did:plc:...", - "scopes": "권한 수준", "scopeOwner": "소유자", - "scopeOwnerDesc": "전체 관리(모든 작업 수행 가능)", - "scopeAdmin": "관리자", - "scopeAdminDesc": "게시물, 앱 비밀번호, 설정 관리", - "scopeEditor": "편집자", - "scopeEditorDesc": "게시물, 좋아요, 팔로우 생성 및 관리", "scopeViewer": "뷰어", - "scopeViewerDesc": "저장소 및 설정 읽기 전용 액세스", "scopeCustom": "사용자 정의", - "scopeCustomDesc": "개별 권한 선택", - "grantedAt": "허용 일시", - "expiresAt": "만료", - "noExpiration": "무기한", "actAs": "로 활동", "auditLog": "감사 로그", "auditLogTitle": "위임 감사 로그", @@ -944,13 +924,7 @@ "showing": "{start}~{end} / {total}개", "refresh": "새로고침", "failedToLoadAuditLog": "감사 로그를 불러오지 못했습니다", - "addControllerTitle": "컨트롤러 추가", - "addControllerDescription": "이 계정에서 지정된 권한으로 작업할 수 있는 사용자를 추가합니다.", - "controllerIdentifier": "컨트롤러 핸들 또는 DID", - "selectScopes": "권한 수준 선택", - "add": "추가", "adding": "추가 중...", - "cancel": "취소", "accessLevel": "액세스 수준", "addControllerButton": "+ 컨트롤러 추가", "auditLogDesc": "모든 위임 활동 보기", @@ -974,7 +948,8 @@ "remove": "제거", "removeConfirm": "이 컨트롤러를 제거하시겠습니까?", "viewAuditLog": "감사 로그 보기", - "yourAccessLevel": "귀하의 액세스 수준" + "yourAccessLevel": "귀하의 액세스 수준", + "accountCreated": "위임 계정이 생성되었습니다: {handle}" }, "actAs": { "title": "로 활동", diff --git a/frontend/src/locales/sv.json b/frontend/src/locales/sv.json index cc0bb30..8a8c8ac 100644 --- a/frontend/src/locales/sv.json +++ b/frontend/src/locales/sv.json @@ -234,6 +234,7 @@ "deleting": "Raderar...", "messages": { "emailCodeSent": "Verifieringskod skickad till din meddelandekanal", + "emailCodeSentToCurrent": "Verifieringskod skickad till din nuvarande e-postadress", "emailUpdated": "E-post uppdaterad", "emailUpdateFailed": "Kunde inte uppdatera e-post", "handleUpdated": "Användarnamn uppdaterat", @@ -671,7 +672,19 @@ "noPending": "Ingen väntande verifiering hittades.", "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.", "createAccount": "Skapa konto", - "signIn": "Logga in" + "signIn": "Logga in", + "backToSettings": "Tillbaka till inställningar", + "emailUpdateCodeHelp": "Koden skickades till din nuvarande e-postadress", + "emailUpdateFailed": "Kunde inte uppdatera e-postadress", + "emailUpdateRequiresAuth": "Du måste vara inloggad för att uppdatera din e-postadress.", + "emailUpdateSubtitle": "Ange din nya e-postadress och verifieringskoden som skickades till din nuvarande e-post.", + "emailUpdateTitle": "Uppdatera e-postadress", + "emailUpdated": "Din e-postadress har uppdaterats.", + "emailUpdatedInfo": "Du kan behöva verifiera din nya e-postadress.", + "newEmailLabel": "Ny e-postadress", + "newEmailPlaceholder": "ny@exempel.se", + "updateEmail": "Uppdatera e-post", + "updating": "Uppdaterar..." }, "resetPassword": { "title": "Återställ lösenord", @@ -754,26 +767,10 @@ "verificationMethod": "Verifieringsmetod", "email": "E-postadress", "emailPlaceholder": "du@exempel.se", - "discord": "Discord", - "discordId": "Discord användar-ID", - "discordIdPlaceholder": "Ditt Discord användar-ID", - "discordIdHint": "Ditt numeriska Discord användar-ID (aktivera Utvecklarläge för att hitta det)", - "telegram": "Telegram", - "telegramUsername": "Telegram-användarnamn", - "telegramUsernamePlaceholder": "@dittanvändarnamn", - "signal": "Signal", - "signalNumber": "Signal-telefonnummer", - "signalNumberPlaceholder": "+46701234567", - "signalNumberHint": "Inkludera landskod (t.ex. +46 för Sverige)", "inviteCode": "Inbjudningskod", "inviteCodePlaceholder": "Ange din inbjudningskod", - "inviteCodeRequired": "krävs", - "didWebDescription": "Använd en DID-identitet som är lagrad på din egen domän.", - "didWebToggle": "Använd extern did:web", "externalDid": "Din did:web", "externalDidPlaceholder": "did:web:dindomän.se", - "dnsVerificationInstructions": "För att verifiera din domän, lägg till denna TXT-post:", - "copyDid": "Kopiera DID", "createButton": "Skapa konto", "creating": "Skapar...", "alreadyHaveAccount": "Har du redan ett konto?", @@ -896,32 +893,15 @@ "delegation": { "title": "Kontodelegering", "controllers": "Kontrollanter", - "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.", "controlledAccounts": "Kontrollerade konton", - "controlledAccountsDescription": "Detta är konton där du har lagts till som kontrollant. Du kan utföra tillåtna åtgärder på dessa konton.", "noControllers": "Inga kontrollanter ännu", "noControlledAccounts": "Inga kontrollerade konton", "addController": "Lägg till kontrollant", - "revokeAccess": "Återkalla åtkomst", - "revokeConfirm": "Återkalla denna kontrollants åtkomst? De kommer inte längre kunna utföra åtgärder på ditt konto.", "handle": "Användarnamn", - "handlePlaceholder": "@user.bsky.social", "did": "DID", - "didPlaceholder": "did:plc:...", - "scopes": "Behörighetsnivå", "scopeOwner": "Ägare", - "scopeOwnerDesc": "Fullständig kontroll (kan utföra alla åtgärder)", - "scopeAdmin": "Administratör", - "scopeAdminDesc": "Hantera inlägg, applösenord, inställningar", - "scopeEditor": "Redaktör", - "scopeEditorDesc": "Skapa och hantera inlägg, gillningar, följningar", "scopeViewer": "Läsare", - "scopeViewerDesc": "Endast läsåtkomst till dataförvaring och inställningar", "scopeCustom": "Anpassad", - "scopeCustomDesc": "Välj individuella behörigheter", - "grantedAt": "Beviljad", - "expiresAt": "Upphör", - "noExpiration": "Ingen utgång", "actAs": "Agera som", "auditLog": "Granskningslogg", "auditLogTitle": "Delegerings-granskningslogg", @@ -944,13 +924,7 @@ "showing": "{start}–{end} av {total}", "refresh": "Uppdatera", "failedToLoadAuditLog": "Kunde inte ladda granskningsloggen", - "addControllerTitle": "Lägg till kontrollant", - "addControllerDescription": "Lägg till en användare som kan utföra åtgärder på detta konto med specificerade behörigheter.", - "controllerIdentifier": "Kontrollantens användarnamn eller DID", - "selectScopes": "Välj behörighetsnivå", - "add": "Lägg till", "adding": "Lägger till...", - "cancel": "Avbryt", "accessLevel": "Åtkomstnivå", "addControllerButton": "+ Lägg till kontrollant", "auditLogDesc": "Visa all delegeringsaktivitet", @@ -974,7 +948,8 @@ "remove": "Ta bort", "removeConfirm": "Vill du ta bort denna kontrollant?", "viewAuditLog": "Visa granskningslogg", - "yourAccessLevel": "Din åtkomstnivå" + "yourAccessLevel": "Din åtkomstnivå", + "accountCreated": "Skapade delegerat konto: {handle}" }, "actAs": { "title": "Agera som", diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json index c048f2e..3d7cc98 100644 --- a/frontend/src/locales/zh.json +++ b/frontend/src/locales/zh.json @@ -234,6 +234,7 @@ "deleting": "删除中...", "messages": { "emailCodeSent": "验证码已发送到您的通知渠道", + "emailCodeSentToCurrent": "验证码已发送到您当前的邮箱地址", "emailUpdated": "邮箱更新成功", "emailUpdateFailed": "邮箱更新失败", "handleUpdated": "用户名更新成功", @@ -671,7 +672,19 @@ "continue": "继续", "identifierLabel": "邮箱或标识符", "identifierPlaceholder": "you@example.com", - "identifierHelp": "接收验证码的邮箱地址或标识符" + "identifierHelp": "接收验证码的邮箱地址或标识符", + "backToSettings": "返回设置", + "emailUpdateCodeHelp": "验证码已发送到您当前的邮箱地址", + "emailUpdateFailed": "更新邮箱地址失败", + "emailUpdateRequiresAuth": "您需要登录才能更新邮箱地址。", + "emailUpdateSubtitle": "输入您的新邮箱地址和发送到当前邮箱的验证码。", + "emailUpdateTitle": "更新邮箱地址", + "emailUpdated": "您的邮箱地址已成功更新。", + "emailUpdatedInfo": "您可能需要验证新的邮箱地址。", + "newEmailLabel": "新邮箱地址", + "newEmailPlaceholder": "new@example.com", + "updateEmail": "更新邮箱", + "updating": "更新中..." }, "resetPassword": { "title": "重置密码", @@ -821,7 +834,8 @@ "passkeysNotSupported": "此浏览器不支持通行密钥。请使用其他浏览器或使用密码注册。", "passkeyCancelled": "通行密钥创建已取消", "passkeyFailed": "通行密钥注册失败" - } + }, + "didWebWarning1Detail": "您的身份将是 {did}。" }, "trustedDevices": { "title": "受信任设备", @@ -879,32 +893,15 @@ "delegation": { "title": "账户委托", "controllers": "控制者", - "controllersDescription": "控制者可以作为您账户的管理员。他们可以执行您允许的操作,代表您发布帖子,以及修改您的数据仓库。", "controlledAccounts": "受控账户", - "controlledAccountsDescription": "这些是您被添加为控制者的账户。您可以在这些账户上执行允许的操作。", "noControllers": "暂无控制者", "noControlledAccounts": "无受控账户", "addController": "添加控制者", - "revokeAccess": "撤销访问", - "revokeConfirm": "撤销此控制者的访问权限?他们将无法再在您的账户上执行操作。", "handle": "用户名", - "handlePlaceholder": "@user.bsky.social", "did": "DID", - "didPlaceholder": "did:plc:...", - "scopes": "权限级别", "scopeOwner": "所有者", - "scopeOwnerDesc": "完全控制(可执行所有操作)", - "scopeAdmin": "管理员", - "scopeAdminDesc": "管理帖子、应用专用密码、设置", - "scopeEditor": "编辑者", - "scopeEditorDesc": "创建和管理帖子、点赞、关注", "scopeViewer": "查看者", - "scopeViewerDesc": "只读访问数据仓库和设置", "scopeCustom": "自定义", - "scopeCustomDesc": "选择单独的权限", - "grantedAt": "授权时间", - "expiresAt": "过期时间", - "noExpiration": "永不过期", "actAs": "代理操作", "auditLog": "审计日志", "auditLogTitle": "委托审计日志", @@ -928,13 +925,7 @@ "showing": "{start}–{end} / 共{total}条", "refresh": "刷新", "failedToLoadAuditLog": "加载审计日志失败", - "addControllerTitle": "添加控制者", - "addControllerDescription": "添加一个可以在此账户上执行指定权限操作的用户。", - "controllerIdentifier": "控制者用户名或 DID", - "selectScopes": "选择权限级别", - "add": "添加", "adding": "添加中...", - "cancel": "取消", "accessLevel": "访问级别", "addControllerButton": "+ 添加控制者", "auditLogDesc": "查看所有委托活动", diff --git a/frontend/src/routes/Settings.svelte b/frontend/src/routes/Settings.svelte index f9ed5d2..875e2d9 100644 --- a/frontend/src/routes/Settings.svelte +++ b/frontend/src/routes/Settings.svelte @@ -56,21 +56,17 @@ if (message?.text === text) message = null }, 5000) } - async function handleRequestEmailUpdate(e: Event) { - e.preventDefault() - if (!auth.session || !newEmail) return + async function handleRequestEmailUpdate() { + if (!auth.session) return emailLoading = true message = null try { - const result = await api.requestEmailUpdate(auth.session.accessJwt, newEmail) + const result = await api.requestEmailUpdate(auth.session.accessJwt) emailTokenRequired = result.tokenRequired if (emailTokenRequired) { - showMessage('success', $_('settings.messages.emailCodeSent')) + showMessage('success', $_('settings.messages.emailCodeSentToCurrent')) } else { - await api.updateEmail(auth.session.accessJwt, newEmail) - await refreshSession() - showMessage('success', $_('settings.messages.emailUpdated')) - newEmail = '' + emailTokenRequired = true } } catch (e) { showMessage('error', e instanceof ApiError ? e.message : $_('settings.messages.emailUpdateFailed')) @@ -244,17 +240,6 @@ required /> -
- - -
- - {:else} -
- +
+ + +
+ {:else} + {/if}
diff --git a/frontend/src/routes/Verify.svelte b/frontend/src/routes/Verify.svelte index ae58394..55c8978 100644 --- a/frontend/src/routes/Verify.svelte +++ b/frontend/src/routes/Verify.svelte @@ -13,9 +13,10 @@ channel: string } - type VerificationMode = 'signup' | 'token' + type VerificationMode = 'signup' | 'token' | 'email-update' let mode = $state('signup') + let newEmail = $state('') let pendingVerification = $state(null) let verificationCode = $state('') let identifier = $state('') @@ -50,7 +51,12 @@ onMount(async () => { const params = parseQueryParams() - if (params.token) { + if (params.type === 'email-update') { + mode = 'email-update' + if (params.token) { + verificationCode = params.token + } + } else if (params.token) { mode = 'token' verificationCode = params.token if (params.identifier) { @@ -134,6 +140,33 @@ } } + async function handleEmailUpdate() { + if (!verificationCode.trim() || !newEmail.trim()) return + + if (!auth.session) { + error = $_('verify.emailUpdateRequiresAuth') + return + } + + submitting = true + error = null + + try { + await api.updateEmail(auth.session.accessJwt, newEmail.trim(), verificationCode.trim()) + success = true + successPurpose = 'email-update' + successChannel = 'email' + } catch (e: any) { + if (e instanceof ApiError) { + error = e.message + } else { + error = $_('verify.emailUpdateFailed') + } + } finally { + submitting = false + } + } + async function handleResendCode() { if (mode === 'signup') { if (!pendingVerification || resendingCode) return @@ -198,7 +231,13 @@ {:else if success}

{$_('verify.verified')}

- {#if successPurpose === 'migration' || successPurpose === 'signup'} + {#if successPurpose === 'email-update'} +

{$_('verify.emailUpdated')}

+

{$_('verify.emailUpdatedInfo')}

+ + {:else if successPurpose === 'migration' || successPurpose === 'signup'}

{$_('verify.channelVerified', { values: { channel: channelLabel(successChannel || '') } })}

{$_('verify.canNowSignIn')}

@@ -213,6 +252,58 @@
{/if}
+ {:else if mode === 'email-update'} +

{$_('verify.emailUpdateTitle')}

+

{$_('verify.emailUpdateSubtitle')}

+ + {#if !auth.session} +
{$_('verify.emailUpdateRequiresAuth')}
+ + {:else} + {#if error} +
{error}
+ {/if} + +
{ e.preventDefault(); handleEmailUpdate(); }}> +
+ + +
+ +
+ + +

{$_('verify.emailUpdateCodeHelp')}

+
+ + +
+ + + {/if} {:else if mode === 'token'}

{$_('verify.tokenTitle')}

{$_('verify.tokenSubtitle')}

diff --git a/src/api/server/email.rs b/src/api/server/email.rs index 95e5a10..c9a99a5 100644 --- a/src/api/server/email.rs +++ b/src/api/server/email.rs @@ -1,4 +1,5 @@ use crate::api::ApiError; +use crate::auth::BearerAuth; use crate::state::{AppState, RateLimitKind}; use axum::{ Json, @@ -10,16 +11,10 @@ use serde::Deserialize; use serde_json::json; use tracing::{error, info, warn}; -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct RequestEmailUpdateInput { - pub email: String, -} - pub async fn request_email_update( State(state): State, headers: axum::http::HeaderMap, - Json(input): Json, + auth: BearerAuth, ) -> Response { let client_ip = crate::rate_limit::extract_client_ip(&headers, None); if !state @@ -37,41 +32,33 @@ pub async fn request_email_update( .into_response(); } - let token = match crate::auth::extract_bearer_token_from_header( - headers.get("Authorization").and_then(|h| h.to_str().ok()), - ) { - Some(t) => t, - None => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } - }; - - let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; - let auth_user = match auth_result { - Ok(user) => user, - Err(e) => return ApiError::from(e).into_response(), - }; - if let Err(e) = crate::auth::scope_check::check_account_scope( - auth_user.is_oauth, - auth_user.scope.as_deref(), + auth.0.is_oauth, + auth.0.scope.as_deref(), crate::oauth::scopes::AccountAttr::Email, crate::oauth::scopes::AccountAction::Manage, ) { return e; } - let did = auth_user.did.clone(); - let user = match sqlx::query!("SELECT id, handle, email FROM users WHERE did = $1", did) - .fetch_optional(&state.db) - .await + let did = auth.0.did.clone(); + let user = match sqlx::query!( + "SELECT id, handle, email, email_verified FROM users WHERE did = $1", + did + ) + .fetch_optional(&state.db) + .await { Ok(Some(row)) => row, - _ => { + Ok(None) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRequest", "message": "account not found"})), + ) + .into_response(); + } + Err(e) => { + error!("DB error: {:?}", e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError"})), @@ -80,59 +67,44 @@ pub async fn request_email_update( } }; - let user_id = user.id; - let handle = user.handle; - let current_email = user.email; - let email = input.email.trim().to_lowercase(); + let current_email: String = match user.email { + Some(e) => e, + None => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRequest", "message": "account does not have an email address"})), + ) + .into_response(); + } + }; - if !crate::api::validation::is_valid_email(&email) { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidEmail", "message": "Invalid email format"})), + let token_required = user.email_verified; + + if token_required { + let code = crate::auth::verification_token::generate_channel_update_token( + &did, + "email_update", + ¤t_email.to_lowercase(), + ); + let formatted_code = + crate::auth::verification_token::format_token_for_display(&code); + + let hostname = + std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + if let Err(e) = crate::comms::enqueue_email_update_token( + &state.db, + user.id, + &formatted_code, + &hostname, ) - .into_response(); + .await + { + warn!("Failed to enqueue email update notification: {:?}", e); + } } - if current_email.as_ref().map(|e| e.to_lowercase()) == Some(email.clone()) { - return (StatusCode::OK, Json(json!({ "tokenRequired": false }))).into_response(); - } - - let exists = sqlx::query!( - "SELECT 1 as one FROM users WHERE LOWER(email) = $1 AND id != $2", - email, - user_id - ) - .fetch_optional(&state.db) - .await; - - if let Ok(Some(_)) = exists { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "EmailTaken", "message": "Email already taken"})), - ) - .into_response(); - } - - if let Err(e) = crate::api::notification_prefs::request_channel_verification( - &state.db, - user_id, - &did, - "email", - &email, - Some(&handle), - ) - .await - { - error!("Failed to request email verification: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } - - info!("Email update requested for user {}", user_id); - (StatusCode::OK, Json(json!({ "tokenRequired": true }))).into_response() + info!("Email update requested for user {}", user.id); + (StatusCode::OK, Json(json!({ "tokenRequired": token_required }))).into_response() } #[derive(Deserialize)] @@ -145,11 +117,12 @@ pub struct ConfirmEmailInput { pub async fn confirm_email( State(state): State, headers: axum::http::HeaderMap, + auth: BearerAuth, Json(input): Json, ) -> Response { let client_ip = crate::rate_limit::extract_client_ip(&headers, None); if !state - .check_rate_limit(RateLimitKind::AppPassword, &client_ip) + .check_rate_limit(RateLimitKind::EmailUpdate, &client_ip) .await { warn!(ip = %client_ip, "Confirm email rate limit exceeded"); @@ -163,41 +136,33 @@ pub async fn confirm_email( .into_response(); } - let token = match crate::auth::extract_bearer_token_from_header( - headers.get("Authorization").and_then(|h| h.to_str().ok()), - ) { - Some(t) => t, - None => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } - }; - - let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; - let auth_user = match auth_result { - Ok(user) => user, - Err(e) => return ApiError::from(e).into_response(), - }; - if let Err(e) = crate::auth::scope_check::check_account_scope( - auth_user.is_oauth, - auth_user.scope.as_deref(), + auth.0.is_oauth, + auth.0.scope.as_deref(), crate::oauth::scopes::AccountAttr::Email, crate::oauth::scopes::AccountAction::Manage, ) { return e; } - let did = auth_user.did; - let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) - .fetch_one(&state.db) - .await + let did = auth.0.did; + let user = match sqlx::query!( + "SELECT id, email, email_verified FROM users WHERE did = $1", + did + ) + .fetch_optional(&state.db) + .await { - Ok(id) => id, - Err(_) => { + Ok(Some(row)) => row, + Ok(None) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "AccountNotFound", "message": "user not found"})), + ) + .into_response(); + } + Err(e) => { + error!("DB error: {:?}", e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError"})), @@ -206,14 +171,37 @@ pub async fn confirm_email( } }; - let email = input.email.trim().to_lowercase(); + let current_email = match &user.email { + Some(e) => e.to_lowercase(), + None => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidEmail", "message": "account does not have an email address"})), + ) + .into_response(); + } + }; + + let provided_email = input.email.trim().to_lowercase(); + if provided_email != current_email { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidEmail", "message": "invalid email"})), + ) + .into_response(); + } + + if user.email_verified { + return (StatusCode::OK, Json(json!({}))).into_response(); + } + let confirmation_code = crate::auth::verification_token::normalize_token_input(input.token.trim()); - let verified = crate::auth::verification_token::verify_channel_update_token( + let verified = crate::auth::verification_token::verify_signup_token( &confirmation_code, "email", - &email, + &provided_email, ); match verified { @@ -245,25 +233,14 @@ pub async fn confirm_email( } let update = sqlx::query!( - "UPDATE users SET email = $1, email_verified = TRUE, updated_at = NOW() WHERE id = $2", - email, - user_id + "UPDATE users SET email_verified = TRUE, updated_at = NOW() WHERE id = $1", + user.id ) .execute(&state.db) .await; if let Err(e) = update { - error!("DB error finalizing email update: {:?}", e); - if e.as_database_error() - .map(|db_err| db_err.is_unique_violation()) - .unwrap_or(false) - { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "EmailTaken", "message": "Email already taken"})), - ) - .into_response(); - } + error!("DB error confirming email: {:?}", e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError"})), @@ -271,7 +248,7 @@ pub async fn confirm_email( .into_response(); } - info!("Email updated for user {}", user_id); + info!("Email confirmed for user {}", user.id); (StatusCode::OK, Json(json!({}))).into_response() } @@ -289,7 +266,7 @@ pub async fn update_email( headers: axum::http::HeaderMap, Json(input): Json, ) -> Response { - let token = match crate::auth::extract_bearer_token_from_header( + let bearer_token = match crate::auth::extract_bearer_token_from_header( headers.get("Authorization").and_then(|h| h.to_str().ok()), ) { Some(t) => t, @@ -302,7 +279,7 @@ pub async fn update_email( } }; - let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; + let auth_result = crate::auth::validate_bearer_token(&state.db, &bearer_token).await; let auth_user = match auth_result { Ok(user) => user, Err(e) => return ApiError::from(e).into_response(), @@ -318,12 +295,23 @@ pub async fn update_email( } let did = auth_user.did; - let user = match sqlx::query!("SELECT id, email FROM users WHERE did = $1", did) - .fetch_optional(&state.db) - .await + let user = match sqlx::query!( + "SELECT id, email, email_verified FROM users WHERE did = $1", + did + ) + .fetch_optional(&state.db) + .await { Ok(Some(row)) => row, - _ => { + Ok(None) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRequest", "message": "account not found"})), + ) + .into_response(); + } + Err(e) => { + error!("DB error: {:?}", e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError"})), @@ -333,13 +321,17 @@ pub async fn update_email( }; let user_id = user.id; - let current_email = user.email; + let current_email = user.email.clone(); + let email_verified = user.email_verified; let new_email = input.email.trim().to_lowercase(); if !crate::api::validation::is_valid_email(&new_email) { return ( StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidEmail", "message": "Invalid email format"})), + Json(json!({ + "error": "InvalidRequest", + "message": "This email address is not supported, please use a different email." + })), ) .into_response(); } @@ -350,48 +342,58 @@ pub async fn update_email( return (StatusCode::OK, Json(json!({}))).into_response(); } - let confirmation_token = match &input.token { - Some(t) => crate::auth::verification_token::normalize_token_input(t.trim()), - None => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "TokenRequired", "message": "Token required. Call requestEmailUpdate first."})), - ) - .into_response(); - } - }; - - let verified = crate::auth::verification_token::verify_channel_update_token( - &confirmation_token, - "email", - &new_email, - ); - - match verified { - Ok(token_data) => { - if token_data.did != did { + if email_verified { + let confirmation_token = match &input.token { + Some(t) => crate::auth::verification_token::normalize_token_input(t.trim()), + None => { return ( StatusCode::BAD_REQUEST, - Json( - json!({"error": "InvalidToken", "message": "Token does not match account"}), - ), + Json(json!({ + "error": "TokenRequired", + "message": "confirmation token required" + })), + ) + .into_response(); + } + }; + + let current_email_lower = current_email + .as_ref() + .map(|e| e.to_lowercase()) + .unwrap_or_default(); + + let verified = crate::auth::verification_token::verify_channel_update_token( + &confirmation_token, + "email_update", + ¤t_email_lower, + ); + + match verified { + Ok(token_data) => { + if token_data.did != did { + return ( + StatusCode::BAD_REQUEST, + Json( + json!({"error": "InvalidToken", "message": "Token does not match account"}), + ), + ) + .into_response(); + } + } + Err(crate::auth::verification_token::VerifyError::Expired) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "ExpiredToken", "message": "Token has expired"})), + ) + .into_response(); + } + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidToken", "message": "Invalid token"})), ) .into_response(); } - } - Err(crate::auth::verification_token::VerifyError::Expired) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "ExpiredToken", "message": "Token has expired"})), - ) - .into_response(); - } - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidToken", "message": "Invalid token"})), - ) - .into_response(); } } @@ -406,13 +408,16 @@ pub async fn update_email( if let Ok(Some(_)) = exists { return ( StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "Email already in use"})), + Json(json!({ + "error": "InvalidRequest", + "message": "This email address is already in use, please use a different email." + })), ) .into_response(); } - let update = sqlx::query!( - "UPDATE users SET email = $1, email_verified = TRUE, updated_at = NOW() WHERE id = $2", + let update: Result = sqlx::query!( + "UPDATE users SET email = $1, email_verified = FALSE, updated_at = NOW() WHERE id = $2", new_email, user_id ) @@ -420,14 +425,17 @@ pub async fn update_email( .await; if let Err(e) = update { - error!("DB error finalizing email update: {:?}", e); + error!("DB error updating email: {:?}", e); if e.as_database_error() - .map(|db_err| db_err.is_unique_violation()) + .map(|db_err: &dyn sqlx::error::DatabaseError| db_err.is_unique_violation()) .unwrap_or(false) { return ( StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "Email already in use"})), + Json(json!({ + "error": "InvalidRequest", + "message": "This email address is already in use, please use a different email." + })), ) .into_response(); } @@ -438,6 +446,23 @@ pub async fn update_email( .into_response(); } + let verification_token = + crate::auth::verification_token::generate_signup_token(&did, "email", &new_email); + let formatted_token = + crate::auth::verification_token::format_token_for_display(&verification_token); + if let Err(e) = crate::comms::enqueue_signup_verification( + &state.db, + user_id, + "email", + &new_email, + &formatted_token, + None, + ) + .await + { + warn!("Failed to send verification email to new address: {:?}", e); + } + match sqlx::query!( "INSERT INTO account_preferences (user_id, name, value_json) VALUES ($1, 'email_auth_factor', $2) ON CONFLICT (user_id, name) DO UPDATE SET value_json = $2", user_id, diff --git a/src/comms/mod.rs b/src/comms/mod.rs index bed85f8..bd26464 100644 --- a/src/comms/mod.rs +++ b/src/comms/mod.rs @@ -10,9 +10,9 @@ pub use sender::{ pub use service::{ CommsService, channel_display_name, enqueue_2fa_code, enqueue_account_deletion, enqueue_comms, - enqueue_email_update, enqueue_migration_verification, enqueue_passkey_recovery, - enqueue_password_reset, enqueue_plc_operation, enqueue_signup_verification, enqueue_welcome, - queue_legacy_login_notification, + enqueue_email_update, enqueue_email_update_token, enqueue_migration_verification, + enqueue_passkey_recovery, enqueue_password_reset, enqueue_plc_operation, + enqueue_signup_verification, enqueue_welcome, queue_legacy_login_notification, }; pub use types::{CommsChannel, CommsStatus, CommsType, NewComms, QueuedComms}; diff --git a/src/comms/service.rs b/src/comms/service.rs index 358212f..80d53e6 100644 --- a/src/comms/service.rs +++ b/src/comms/service.rs @@ -380,6 +380,44 @@ pub async fn enqueue_email_update( .await } +pub async fn enqueue_email_update_token( + db: &PgPool, + user_id: Uuid, + code: &str, + hostname: &str, +) -> Result { + let prefs = get_user_comms_prefs(db, user_id).await?; + let strings = get_strings(&prefs.locale); + let current_email = prefs.email.clone().unwrap_or_default(); + let verify_page = format!("https://{}/#/verify?type=email-update", hostname); + let verify_link = format!( + "https://{}/#/verify?type=email-update&token={}", + hostname, + urlencoding::encode(code) + ); + let body = format_message( + strings.email_update_body, + &[ + ("handle", &prefs.handle), + ("code", code), + ("verify_page", &verify_page), + ("verify_link", &verify_link), + ], + ); + let subject = format_message(strings.email_update_subject, &[("hostname", hostname)]); + enqueue_comms( + db, + NewComms::email( + user_id, + super::types::CommsType::EmailUpdate, + current_email, + subject, + body, + ), + ) + .await +} + pub async fn enqueue_account_deletion( db: &PgPool, user_id: Uuid, diff --git a/tests/email_update.rs b/tests/email_update.rs index 8651002..d2ab608 100644 --- a/tests/email_update.rs +++ b/tests/email_update.rs @@ -63,7 +63,29 @@ async fn create_verified_account( } #[tokio::test] -async fn test_email_update_flow_success() { +async fn test_request_email_update_returns_token_required() { + let client = common::client(); + let base_url = common::base_url().await; + let handle = format!("emailreq-{}", uuid::Uuid::new_v4()); + let email = format!("{}@example.com", handle); + let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await; + + let res = client + .post(format!( + "{}/xrpc/com.atproto.server.requestEmailUpdate", + base_url + )) + .bearer_auth(&access_jwt) + .send() + .await + .expect("Failed to request email update"); + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Invalid JSON"); + assert_eq!(body["tokenRequired"], true); +} + +#[tokio::test] +async fn test_update_email_flow_success() { let client = common::client(); let base_url = common::base_url().await; let pool = get_pool().await; @@ -71,13 +93,13 @@ async fn test_email_update_flow_success() { let email = format!("{}@example.com", handle); let (access_jwt, did) = create_verified_account(&client, &base_url, &handle, &email).await; let new_email = format!("new_{}@example.com", handle); + let res = client .post(format!( "{}/xrpc/com.atproto.server.requestEmailUpdate", base_url )) .bearer_auth(&access_jwt) - .json(&json!({"email": new_email})) .send() .await .expect("Failed to request email update"); @@ -86,8 +108,9 @@ async fn test_email_update_flow_success() { assert_eq!(body["tokenRequired"], true); let code = get_email_update_token(&pool, &did).await; + let res = client - .post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url)) + .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) .bearer_auth(&access_jwt) .json(&json!({ "email": new_email, @@ -95,120 +118,25 @@ async fn test_email_update_flow_success() { })) .send() .await - .expect("Failed to confirm email"); + .expect("Failed to update email"); assert_eq!(res.status(), StatusCode::OK); - let user = sqlx::query!("SELECT email FROM users WHERE did = $1", did) + + let user_email: Option = sqlx::query_scalar!("SELECT email FROM users WHERE did = $1", did) .fetch_one(&pool) .await .expect("User not found"); - assert_eq!(user.email, Some(new_email)); + assert_eq!(user_email, Some(new_email)); } #[tokio::test] -async fn test_request_email_update_taken_email() { - let client = common::client(); - let base_url = common::base_url().await; - let handle1 = format!("emailup-taken1-{}", uuid::Uuid::new_v4()); - let email1 = format!("{}@example.com", handle1); - let (_, _) = create_verified_account(&client, &base_url, &handle1, &email1).await; - let handle2 = format!("emailup-taken2-{}", uuid::Uuid::new_v4()); - let email2 = format!("{}@example.com", handle2); - let (access_jwt2, _) = create_verified_account(&client, &base_url, &handle2, &email2).await; - let res = client - .post(format!( - "{}/xrpc/com.atproto.server.requestEmailUpdate", - base_url - )) - .bearer_auth(&access_jwt2) - .json(&json!({"email": email1})) - .send() - .await - .expect("Failed to request email update"); - assert_eq!(res.status(), StatusCode::BAD_REQUEST); - let body: Value = res.json().await.expect("Invalid JSON"); - assert_eq!(body["error"], "EmailTaken"); -} - -#[tokio::test] -async fn test_confirm_email_invalid_token() { - let client = common::client(); - let base_url = common::base_url().await; - let handle = format!("emailup-inv-{}", uuid::Uuid::new_v4()); - let email = format!("{}@example.com", handle); - let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await; - let new_email = format!("new_{}@example.com", handle); - let res = client - .post(format!( - "{}/xrpc/com.atproto.server.requestEmailUpdate", - base_url - )) - .bearer_auth(&access_jwt) - .json(&json!({"email": new_email})) - .send() - .await - .expect("Failed to request email update"); - assert_eq!(res.status(), StatusCode::OK); - let res = client - .post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url)) - .bearer_auth(&access_jwt) - .json(&json!({ - "email": new_email, - "token": "wrong-token" - })) - .send() - .await - .expect("Failed to confirm email"); - assert_eq!(res.status(), StatusCode::BAD_REQUEST); - let body: Value = res.json().await.expect("Invalid JSON"); - assert_eq!(body["error"], "InvalidToken"); -} - -#[tokio::test] -async fn test_confirm_email_wrong_email() { - let client = common::client(); - let base_url = common::base_url().await; - let pool = get_pool().await; - let handle = format!("emailup-wrong-{}", uuid::Uuid::new_v4()); - let email = format!("{}@example.com", handle); - let (access_jwt, did) = create_verified_account(&client, &base_url, &handle, &email).await; - let new_email = format!("new_{}@example.com", handle); - let res = client - .post(format!( - "{}/xrpc/com.atproto.server.requestEmailUpdate", - base_url - )) - .bearer_auth(&access_jwt) - .json(&json!({"email": new_email})) - .send() - .await - .expect("Failed to request email update"); - assert_eq!(res.status(), StatusCode::OK); - let code = get_email_update_token(&pool, &did).await; - let res = client - .post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url)) - .bearer_auth(&access_jwt) - .json(&json!({ - "email": "another_random@example.com", - "token": code - })) - .send() - .await - .expect("Failed to confirm email"); - assert_eq!(res.status(), StatusCode::BAD_REQUEST); - let body: Value = res.json().await.expect("Invalid JSON"); - assert!( - body["message"].as_str().unwrap().contains("mismatch") || body["error"] == "InvalidToken" - ); -} - -#[tokio::test] -async fn test_update_email_requires_token() { +async fn test_update_email_requires_token_when_verified() { let client = common::client(); let base_url = common::base_url().await; let handle = format!("emailup-direct-{}", uuid::Uuid::new_v4()); let email = format!("{}@example.com", handle); let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await; let new_email = format!("direct_{}@example.com", handle); + let res = client .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) .bearer_auth(&access_jwt) @@ -228,6 +156,7 @@ async fn test_update_email_same_email_noop() { let handle = format!("emailup-same-{}", uuid::Uuid::new_v4()); let email = format!("{}@example.com", handle); let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await; + let res = client .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) .bearer_auth(&access_jwt) @@ -242,76 +171,6 @@ async fn test_update_email_same_email_noop() { ); } -#[tokio::test] -async fn test_update_email_requires_token_after_pending() { - let client = common::client(); - let base_url = common::base_url().await; - let handle = format!("emailup-token-{}", uuid::Uuid::new_v4()); - let email = format!("{}@example.com", handle); - let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await; - let new_email = format!("pending_{}@example.com", handle); - let res = client - .post(format!( - "{}/xrpc/com.atproto.server.requestEmailUpdate", - base_url - )) - .bearer_auth(&access_jwt) - .json(&json!({"email": new_email})) - .send() - .await - .expect("Failed to request email update"); - assert_eq!(res.status(), StatusCode::OK); - let res = client - .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) - .bearer_auth(&access_jwt) - .json(&json!({ "email": new_email })) - .send() - .await - .expect("Failed to attempt email update"); - assert_eq!(res.status(), StatusCode::BAD_REQUEST); - let body: Value = res.json().await.expect("Invalid JSON"); - assert_eq!(body["error"], "TokenRequired"); -} - -#[tokio::test] -async fn test_update_email_with_valid_token() { - let client = common::client(); - let base_url = common::base_url().await; - let pool = get_pool().await; - let handle = format!("emailup-valid-{}", uuid::Uuid::new_v4()); - let email = format!("{}@example.com", handle); - let (access_jwt, did) = create_verified_account(&client, &base_url, &handle, &email).await; - let new_email = format!("valid_{}@example.com", handle); - let res = client - .post(format!( - "{}/xrpc/com.atproto.server.requestEmailUpdate", - base_url - )) - .bearer_auth(&access_jwt) - .json(&json!({"email": new_email})) - .send() - .await - .expect("Failed to request email update"); - assert_eq!(res.status(), StatusCode::OK); - let code = get_email_update_token(&pool, &did).await; - let res = client - .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) - .bearer_auth(&access_jwt) - .json(&json!({ - "email": new_email, - "token": code - })) - .send() - .await - .expect("Failed to update email"); - assert_eq!(res.status(), StatusCode::OK); - let user = sqlx::query!("SELECT email FROM users WHERE did = $1", did) - .fetch_one(&pool) - .await - .expect("User not found"); - assert_eq!(user.email, Some(new_email)); -} - #[tokio::test] async fn test_update_email_invalid_token() { let client = common::client(); @@ -320,17 +179,18 @@ async fn test_update_email_invalid_token() { let email = format!("{}@example.com", handle); let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await; let new_email = format!("badtok_{}@example.com", handle); + let res = client .post(format!( "{}/xrpc/com.atproto.server.requestEmailUpdate", base_url )) .bearer_auth(&access_jwt) - .json(&json!({"email": new_email})) .send() .await .expect("Failed to request email update"); assert_eq!(res.status(), StatusCode::OK); + let res = client .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) .bearer_auth(&access_jwt) @@ -346,39 +206,11 @@ async fn test_update_email_invalid_token() { assert_eq!(body["error"], "InvalidToken"); } -#[tokio::test] -async fn test_update_email_already_taken() { - let client = common::client(); - let base_url = common::base_url().await; - let handle1 = format!("emailup-dup1-{}", uuid::Uuid::new_v4()); - let email1 = format!("{}@example.com", handle1); - let (_, _) = create_verified_account(&client, &base_url, &handle1, &email1).await; - let handle2 = format!("emailup-dup2-{}", uuid::Uuid::new_v4()); - let email2 = format!("{}@example.com", handle2); - let (access_jwt2, _) = create_verified_account(&client, &base_url, &handle2, &email2).await; - let res = client - .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) - .bearer_auth(&access_jwt2) - .json(&json!({ "email": email1 })) - .send() - .await - .expect("Failed to attempt email update"); - assert_eq!(res.status(), StatusCode::BAD_REQUEST); - let body: Value = res.json().await.expect("Invalid JSON"); - assert!( - body["error"] == "TokenRequired" - || body["message"] - .as_str() - .unwrap_or("") - .contains("already in use") - || body["error"] == "InvalidRequest" - ); -} - #[tokio::test] async fn test_update_email_no_auth() { let client = common::client(); let base_url = common::base_url().await; + let res = client .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) .json(&json!({ "email": "test@example.com" })) @@ -397,6 +229,7 @@ async fn test_update_email_invalid_format() { let handle = format!("emailup-fmt-{}", uuid::Uuid::new_v4()); let email = format!("{}@example.com", handle); let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await; + let res = client .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) .bearer_auth(&access_jwt) @@ -405,6 +238,270 @@ async fn test_update_email_invalid_format() { .await .expect("Failed to send request"); assert_eq!(res.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_confirm_email_confirms_existing_email() { + let client = common::client(); + let base_url = common::base_url().await; + let pool = get_pool().await; + let handle = format!("emailconfirm-{}", uuid::Uuid::new_v4()); + let email = format!("{}@example.com", handle); + + let res = client + .post(format!( + "{}/xrpc/com.atproto.server.createAccount", + base_url + )) + .json(&json!({ + "handle": handle, + "email": email, + "password": "Testpass123!" + })) + .send() + .await + .expect("Failed to create account"); + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Invalid JSON"); + let did = body["did"].as_str().expect("No did").to_string(); + let access_jwt = body["accessJwt"].as_str().expect("No accessJwt").to_string(); + + let body_text: String = sqlx::query_scalar!( + "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", + did + ) + .fetch_one(&pool) + .await + .expect("Verification email not found"); + + let code = body_text + .lines() + .find(|line| line.trim().starts_with("MX") && line.contains('-')) + .map(|s| s.trim().to_string()) + .unwrap_or_default(); + + let res = client + .post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url)) + .bearer_auth(&access_jwt) + .json(&json!({ + "email": email, + "token": code + })) + .send() + .await + .expect("Failed to confirm email"); + assert_eq!(res.status(), StatusCode::OK); + + let verified: bool = sqlx::query_scalar!( + "SELECT email_verified FROM users WHERE did = $1", + did + ) + .fetch_one(&pool) + .await + .expect("User not found"); + assert!(verified); +} + +#[tokio::test] +async fn test_confirm_email_rejects_wrong_email() { + let client = common::client(); + let base_url = common::base_url().await; + let pool = get_pool().await; + let handle = format!("emailconf-wrong-{}", uuid::Uuid::new_v4()); + let email = format!("{}@example.com", handle); + + let res = client + .post(format!( + "{}/xrpc/com.atproto.server.createAccount", + base_url + )) + .json(&json!({ + "handle": handle, + "email": email, + "password": "Testpass123!" + })) + .send() + .await + .expect("Failed to create account"); + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Invalid JSON"); + let did = body["did"].as_str().expect("No did").to_string(); + let access_jwt = body["accessJwt"].as_str().expect("No accessJwt").to_string(); + + let body_text: String = sqlx::query_scalar!( + "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", + did + ) + .fetch_one(&pool) + .await + .expect("Verification email not found"); + + let code = body_text + .lines() + .find(|line| line.trim().starts_with("MX") && line.contains('-')) + .map(|s| s.trim().to_string()) + .unwrap_or_default(); + + let res = client + .post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url)) + .bearer_auth(&access_jwt) + .json(&json!({ + "email": "different@example.com", + "token": code + })) + .send() + .await + .expect("Failed to confirm email"); + assert_eq!(res.status(), StatusCode::BAD_REQUEST); let body: Value = res.json().await.expect("Invalid JSON"); assert_eq!(body["error"], "InvalidEmail"); } + +#[tokio::test] +async fn test_confirm_email_invalid_token() { + let client = common::client(); + let base_url = common::base_url().await; + let handle = format!("emailconf-inv-{}", uuid::Uuid::new_v4()); + let email = format!("{}@example.com", handle); + + let res = client + .post(format!( + "{}/xrpc/com.atproto.server.createAccount", + base_url + )) + .json(&json!({ + "handle": handle, + "email": email, + "password": "Testpass123!" + })) + .send() + .await + .expect("Failed to create account"); + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Invalid JSON"); + let access_jwt = body["accessJwt"].as_str().expect("No accessJwt").to_string(); + + let res = client + .post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url)) + .bearer_auth(&access_jwt) + .json(&json!({ + "email": email, + "token": "wrong-token" + })) + .send() + .await + .expect("Failed to confirm email"); + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let body: Value = res.json().await.expect("Invalid JSON"); + assert_eq!(body["error"], "InvalidToken"); +} + +#[tokio::test] +async fn test_unverified_account_can_update_email_without_token() { + let client = common::client(); + let base_url = common::base_url().await; + let pool = get_pool().await; + let handle = format!("emailup-unverified-{}", uuid::Uuid::new_v4()); + let email = format!("{}@example.com", handle); + + let res = client + .post(format!( + "{}/xrpc/com.atproto.server.createAccount", + base_url + )) + .json(&json!({ + "handle": handle, + "email": email, + "password": "Testpass123!" + })) + .send() + .await + .expect("Failed to create account"); + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Invalid JSON"); + let did = body["did"].as_str().expect("No did").to_string(); + let access_jwt = body["accessJwt"].as_str().expect("No accessJwt").to_string(); + + let res = client + .post(format!( + "{}/xrpc/com.atproto.server.requestEmailUpdate", + base_url + )) + .bearer_auth(&access_jwt) + .send() + .await + .expect("Failed to request email update"); + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Invalid JSON"); + assert_eq!( + body["tokenRequired"], false, + "Unverified account should not require token" + ); + + let new_email = format!("new_{}@example.com", handle); + let res = client + .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) + .bearer_auth(&access_jwt) + .json(&json!({ "email": new_email })) + .send() + .await + .expect("Failed to update email"); + assert_eq!( + res.status(), + StatusCode::OK, + "Unverified account should be able to update email without token" + ); + + let user_email: Option = + sqlx::query_scalar!("SELECT email FROM users WHERE did = $1", did) + .fetch_one(&pool) + .await + .expect("User not found"); + assert_eq!(user_email, Some(new_email)); +} + +#[tokio::test] +async fn test_update_email_taken_by_another_user() { + let client = common::client(); + let base_url = common::base_url().await; + let pool = get_pool().await; + + let handle1 = format!("emailup-dup1-{}", uuid::Uuid::new_v4()); + let email1 = format!("{}@example.com", handle1); + let (_, _) = create_verified_account(&client, &base_url, &handle1, &email1).await; + + let handle2 = format!("emailup-dup2-{}", uuid::Uuid::new_v4()); + let email2 = format!("{}@example.com", handle2); + let (access_jwt2, did2) = create_verified_account(&client, &base_url, &handle2, &email2).await; + + let res = client + .post(format!( + "{}/xrpc/com.atproto.server.requestEmailUpdate", + base_url + )) + .bearer_auth(&access_jwt2) + .send() + .await + .expect("Failed to request email update"); + assert_eq!(res.status(), StatusCode::OK); + + let code = get_email_update_token(&pool, &did2).await; + + let res = client + .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) + .bearer_auth(&access_jwt2) + .json(&json!({ + "email": email1, + "token": code + })) + .send() + .await + .expect("Failed to update email"); + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let body: Value = res.json().await.expect("Invalid JSON"); + assert_eq!(body["error"], "InvalidRequest"); + assert!(body["message"] + .as_str() + .unwrap_or("") + .contains("already in use")); +}