Email conf. vs ref

This commit is contained in:
lewis
2025-12-29 18:08:12 +02:00
parent c302d2aea1
commit 5aceed2ab3
17 changed files with 818 additions and 608 deletions

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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 },
});
},

View File

@@ -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",

View File

@@ -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?",

View File

@@ -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": "として行動",

View File

@@ -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": "로 활동",

View File

@@ -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",

View File

@@ -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": "查看所有委托活动",

View File

@@ -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
/>
</div>
<div class="actions">
<button type="submit" disabled={emailLoading || !emailToken}>
{emailLoading ? $_('settings.updating') : $_('settings.confirmEmailChange')}
</button>
<button type="button" class="secondary" onclick={() => { emailTokenRequired = false; emailToken = '' }}>
{$_('common.cancel')}
</button>
</div>
</form>
{:else}
<form onsubmit={handleRequestEmailUpdate}>
<div class="field">
<label for="new-email">{$_('settings.newEmail')}</label>
<input
@@ -266,10 +251,19 @@
required
/>
</div>
<button type="submit" disabled={emailLoading || !newEmail}>
{emailLoading ? $_('settings.requesting') : $_('settings.changeEmailButton')}
</button>
<div class="actions">
<button type="submit" disabled={emailLoading || !emailToken || !newEmail}>
{emailLoading ? $_('settings.updating') : $_('settings.confirmEmailChange')}
</button>
<button type="button" class="secondary" onclick={() => { emailTokenRequired = false; emailToken = ''; newEmail = '' }}>
{$_('common.cancel')}
</button>
</div>
</form>
{:else}
<button onclick={handleRequestEmailUpdate} disabled={emailLoading}>
{emailLoading ? $_('settings.requesting') : $_('settings.changeEmailButton')}
</button>
{/if}
</section>
<section>

View File

@@ -13,9 +13,10 @@
channel: string
}
type VerificationMode = 'signup' | 'token'
type VerificationMode = 'signup' | 'token' | 'email-update'
let mode = $state<VerificationMode>('signup')
let newEmail = $state('')
let pendingVerification = $state<PendingVerification | null>(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}
<div class="success-container">
<h1>{$_('verify.verified')}</h1>
{#if successPurpose === 'migration' || successPurpose === 'signup'}
{#if successPurpose === 'email-update'}
<p class="subtitle">{$_('verify.emailUpdated')}</p>
<p class="info-text">{$_('verify.emailUpdatedInfo')}</p>
<div class="actions">
<a href="#/settings" class="btn">{$_('verify.backToSettings')}</a>
</div>
{:else if successPurpose === 'migration' || successPurpose === 'signup'}
<p class="subtitle">{$_('verify.channelVerified', { values: { channel: channelLabel(successChannel || '') } })}</p>
<p class="info-text">{$_('verify.canNowSignIn')}</p>
<div class="actions">
@@ -213,6 +252,58 @@
</div>
{/if}
</div>
{:else if mode === 'email-update'}
<h1>{$_('verify.emailUpdateTitle')}</h1>
<p class="subtitle">{$_('verify.emailUpdateSubtitle')}</p>
{#if !auth.session}
<div class="message warning">{$_('verify.emailUpdateRequiresAuth')}</div>
<div class="actions">
<a href="#/login" class="btn">{$_('verify.signIn')}</a>
</div>
{:else}
{#if error}
<div class="message error">{error}</div>
{/if}
<form onsubmit={(e) => { e.preventDefault(); handleEmailUpdate(); }}>
<div class="field">
<label for="new-email">{$_('verify.newEmailLabel')}</label>
<input
id="new-email"
type="email"
bind:value={newEmail}
placeholder={$_('verify.newEmailPlaceholder')}
disabled={submitting}
required
autocomplete="email"
/>
</div>
<div class="field">
<label for="verification-code">{$_('verify.codeLabel')}</label>
<input
id="verification-code"
type="text"
bind:value={verificationCode}
placeholder={$_('verify.codePlaceholder')}
disabled={submitting}
required
autocomplete="off"
class="token-input"
/>
<p class="field-help">{$_('verify.emailUpdateCodeHelp')}</p>
</div>
<button type="submit" disabled={submitting || !verificationCode.trim() || !newEmail.trim()}>
{submitting ? $_('verify.updating') : $_('verify.updateEmail')}
</button>
</form>
<p class="link-text">
<a href="#/settings">{$_('verify.backToSettings')}</a>
</p>
{/if}
{:else if mode === 'token'}
<h1>{$_('verify.tokenTitle')}</h1>
<p class="subtitle">{$_('verify.tokenSubtitle')}</p>

View File

@@ -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<AppState>,
headers: axum::http::HeaderMap,
Json(input): Json<RequestEmailUpdateInput>,
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",
&current_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<AppState>,
headers: axum::http::HeaderMap,
auth: BearerAuth,
Json(input): Json<ConfirmEmailInput>,
) -> 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<UpdateEmailInput>,
) -> 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",
&current_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::postgres::PgQueryResult, sqlx::Error> = 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,

View File

@@ -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};

View File

@@ -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<Uuid, sqlx::Error> {
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,

View File

@@ -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<String> = 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<String> =
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"));
}