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}
+
+
+
+
+ {$_('verify.backToSettings')}
+
+ {/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"));
+}