this repo has no description

Passkey-only accounts can opt into a password

lewis 9a690b38 cba82290

+15
.sqlx/query-3a8b7a2773033c85cd03dd6f906a9d335019f9b7145e1bee6175d01d0c98b8b4.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE users SET password_hash = $1, password_required = TRUE WHERE id = $2", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Uuid" 10 + ] 11 + }, 12 + "nullable": [] 13 + }, 14 + "hash": "3a8b7a2773033c85cd03dd6f906a9d335019f9b7145e1bee6175d01d0c98b8b4" 15 + }
-22
.sqlx/query-e70fc3dced4eb7dc220ca2a18cdfcbd5f2d66dff2262bb083fd4118b032ff978.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT block_cid FROM user_blocks WHERE user_id = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "block_cid", 9 - "type_info": "Bytea" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Uuid" 15 - ] 16 - }, 17 - "nullable": [ 18 - false 19 - ] 20 - }, 21 - "hash": "e70fc3dced4eb7dc220ca2a18cdfcbd5f2d66dff2262bb083fd4118b032ff978" 22 - }
···
+2 -2
frontend/src/components/ReauthModal.svelte
··· 330 .tab.active { 331 background: var(--accent); 332 border-color: var(--accent); 333 - color: white; 334 } 335 336 .modal-content { ··· 375 width: 100%; 376 padding: 0.75rem 1.5rem; 377 background: var(--accent); 378 - color: white; 379 border: none; 380 border-radius: 4px; 381 font-size: 1rem;
··· 330 .tab.active { 331 background: var(--accent); 332 border-color: var(--accent); 333 + color: var(--text-inverse); 334 } 335 336 .modal-content { ··· 375 width: 100%; 376 padding: 0.75rem 1.5rem; 377 background: var(--accent); 378 + color: var(--text-inverse); 379 border: none; 380 border-radius: 4px; 381 font-size: 1rem;
+8
frontend/src/lib/api.ts
··· 540 }); 541 }, 542 543 getPasswordStatus(token: AccessToken): Promise<PasswordStatus> { 544 return xrpc("_account.getPasswordStatus", { token }); 545 },
··· 540 }); 541 }, 542 543 + setPassword(token: AccessToken, newPassword: string): Promise<SuccessResponse> { 544 + return xrpc("_account.setPassword", { 545 + method: "POST", 546 + token, 547 + body: { newPassword }, 548 + }); 549 + }, 550 + 551 getPasswordStatus(token: AccessToken): Promise<PasswordStatus> { 552 return xrpc("_account.getPasswordStatus", { token }); 553 },
+8
frontend/src/locales/en.json
··· 303 "confirmNewPasswordPlaceholder": "Confirm new password", 304 "changePasswordButton": "Change Password", 305 "changing": "Changing...", 306 "exportData": "Export Data", 307 "exportDataDescription": "Download your entire repository as a CAR (Content Addressable Archive) file. This includes all your posts, likes, follows, and other data.", 308 "downloadRepo": "Download Repository", ··· 352 "handleUpdateFailed": "Failed to update handle", 353 "passwordChanged": "Password changed successfully", 354 "passwordChangeFailed": "Failed to change password", 355 "passwordsMismatch": "Passwords do not match", 356 "passwordsDoNotMatch": "Passwords do not match", 357 "passwordLength": "Password must be at least 8 characters", ··· 509 "beforeProceedingItem3": "Ensure your recovery notification channel is up to date", 510 "addPasskeyFirst": "Add at least one passkey before you can remove your password.", 511 "passkeyOnlyHint": "You sign in using passkeys only. If you ever lose access to your passkeys, you can recover your account using the \"Lost passkey?\" link on the login page.", 512 "trustedDevices": "Trusted Devices", 513 "trustedDevicesDescription": "Manage devices that can skip two-factor authentication when signing in. Trust is granted for 30 days and automatically extends when you use the device.", 514 "manageTrustedDevices": "Manage Trusted Devices",
··· 303 "confirmNewPasswordPlaceholder": "Confirm new password", 304 "changePasswordButton": "Change Password", 305 "changing": "Changing...", 306 + "setPassword": "Set Password", 307 + "setPasswordDescription": "Your account is currently passkey-only. You can add a password to enable traditional login alongside your passkeys.", 308 + "setPasswordButton": "Set Password", 309 + "setting": "Setting...", 310 "exportData": "Export Data", 311 "exportDataDescription": "Download your entire repository as a CAR (Content Addressable Archive) file. This includes all your posts, likes, follows, and other data.", 312 "downloadRepo": "Download Repository", ··· 356 "handleUpdateFailed": "Failed to update handle", 357 "passwordChanged": "Password changed successfully", 358 "passwordChangeFailed": "Failed to change password", 359 + "passwordSet": "Password set successfully", 360 + "passwordSetFailed": "Failed to set password", 361 "passwordsMismatch": "Passwords do not match", 362 "passwordsDoNotMatch": "Passwords do not match", 363 "passwordLength": "Password must be at least 8 characters", ··· 515 "beforeProceedingItem3": "Ensure your recovery notification channel is up to date", 516 "addPasskeyFirst": "Add at least one passkey before you can remove your password.", 517 "passkeyOnlyHint": "You sign in using passkeys only. If you ever lose access to your passkeys, you can recover your account using the \"Lost passkey?\" link on the login page.", 518 + "addPasswordHint": "Want to add a password? Go to Settings to set one up.", 519 + "goToSettings": "Go to Settings", 520 "trustedDevices": "Trusted Devices", 521 "trustedDevicesDescription": "Manage devices that can skip two-factor authentication when signing in. Trust is granted for 30 days and automatically extends when you use the device.", 522 "manageTrustedDevices": "Manage Trusted Devices",
+8
frontend/src/locales/fi.json
··· 303 "confirmNewPasswordPlaceholder": "Vahvista uusi salasana", 304 "changePasswordButton": "Vaihda salasana", 305 "changing": "Vaihdetaan...", 306 "exportData": "Vie tiedot", 307 "exportDataDescription": "Lataa koko tietovarastosi CAR-tiedostona (Content Addressable Archive). Tämä sisältää kaikki julkaisusi, tykkäyksesi, seuraamisesi ja muut tiedot.", 308 "downloadRepo": "Lataa tietovarasto", ··· 352 "handleUpdateFailed": "Käyttäjänimen päivitys epäonnistui", 353 "passwordChanged": "Salasana vaihdettu", 354 "passwordChangeFailed": "Salasanan vaihto epäonnistui", 355 "passwordsMismatch": "Salasanat eivät täsmää", 356 "passwordsDoNotMatch": "Salasanat eivät täsmää", 357 "passwordLength": "Salasanan on oltava vähintään 8 merkkiä", ··· 509 "beforeProceedingItem3": "Varmista, että palautusilmoituskanavasi on ajan tasalla", 510 "addPasskeyFirst": "Lisää vähintään yksi pääsyavain ennen kuin voit poistaa salasanasi.", 511 "passkeyOnlyHint": "Kirjaudut sisään vain pääsyavaimilla. Jos menetät pääsyn pääsyavaimeesi, voit palauttaa tilisi käyttämällä \"Kadotitko pääsyavaimen?\" -linkkiä kirjautumissivulla.", 512 "trustedDevices": "Luotetut laitteet", 513 "trustedDevicesDescription": "Hallitse laitteita, jotka voivat ohittaa kaksivaiheisen tunnistautumisen kirjautuessaan. Luottamus myönnetään 30 päiväksi ja jatkuu automaattisesti, kun käytät laitetta.", 514 "manageTrustedDevices": "Hallitse luotettuja laitteita",
··· 303 "confirmNewPasswordPlaceholder": "Vahvista uusi salasana", 304 "changePasswordButton": "Vaihda salasana", 305 "changing": "Vaihdetaan...", 306 + "setPassword": "Aseta salasana", 307 + "setPasswordDescription": "Tilisi on tällä hetkellä vain pääsyavain-tili. Voit lisätä salasanan ottaaksesi käyttöön perinteisen kirjautumisen pääsyavainten rinnalla.", 308 + "setPasswordButton": "Aseta salasana", 309 + "setting": "Asetetaan...", 310 "exportData": "Vie tiedot", 311 "exportDataDescription": "Lataa koko tietovarastosi CAR-tiedostona (Content Addressable Archive). Tämä sisältää kaikki julkaisusi, tykkäyksesi, seuraamisesi ja muut tiedot.", 312 "downloadRepo": "Lataa tietovarasto", ··· 356 "handleUpdateFailed": "Käyttäjänimen päivitys epäonnistui", 357 "passwordChanged": "Salasana vaihdettu", 358 "passwordChangeFailed": "Salasanan vaihto epäonnistui", 359 + "passwordSet": "Salasana asetettu onnistuneesti", 360 + "passwordSetFailed": "Salasanan asettaminen epäonnistui", 361 "passwordsMismatch": "Salasanat eivät täsmää", 362 "passwordsDoNotMatch": "Salasanat eivät täsmää", 363 "passwordLength": "Salasanan on oltava vähintään 8 merkkiä", ··· 515 "beforeProceedingItem3": "Varmista, että palautusilmoituskanavasi on ajan tasalla", 516 "addPasskeyFirst": "Lisää vähintään yksi pääsyavain ennen kuin voit poistaa salasanasi.", 517 "passkeyOnlyHint": "Kirjaudut sisään vain pääsyavaimilla. Jos menetät pääsyn pääsyavaimeesi, voit palauttaa tilisi käyttämällä \"Kadotitko pääsyavaimen?\" -linkkiä kirjautumissivulla.", 518 + "addPasswordHint": "Haluatko lisätä salasanan? Siirry Asetuksiin määrittääksesi sellaisen.", 519 + "goToSettings": "Siirry asetuksiin", 520 "trustedDevices": "Luotetut laitteet", 521 "trustedDevicesDescription": "Hallitse laitteita, jotka voivat ohittaa kaksivaiheisen tunnistautumisen kirjautuessaan. Luottamus myönnetään 30 päiväksi ja jatkuu automaattisesti, kun käytät laitetta.", 522 "manageTrustedDevices": "Hallitse luotettuja laitteita",
+8
frontend/src/locales/ja.json
··· 296 "confirmNewPasswordPlaceholder": "新しいパスワードを再入力", 297 "changePasswordButton": "パスワードを変更", 298 "changing": "変更中...", 299 "exportData": "データエクスポート", 300 "exportDataDescription": "リポジトリ全体を CAR(Content Addressable Archive)ファイルとしてダウンロードします。投稿、いいね、フォローなどすべてのデータが含まれます。", 301 "downloadRepo": "リポジトリをダウンロード", ··· 345 "handleUpdateFailed": "ハンドルの更新に失敗しました", 346 "passwordChanged": "パスワードを変更しました", 347 "passwordChangeFailed": "パスワードの変更に失敗しました", 348 "passwordsMismatch": "パスワードが一致しません", 349 "passwordsDoNotMatch": "パスワードが一致しません", 350 "passwordLength": "パスワードは8文字以上である必要があります", ··· 502 "beforeProceedingItem3": "復旧用の通知チャンネルが最新であることを確認", 503 "addPasskeyFirst": "パスワードを削除する前に、少なくとも1つのパスキーを追加してください。", 504 "passkeyOnlyHint": "パスキーのみでサインインしています。パスキーにアクセスできなくなった場合、ログインページの「パスキーを紛失しましたか?」リンクからアカウントを復旧できます。", 505 "trustedDevices": "信頼済みデバイス", 506 "trustedDevicesDescription": "サインイン時に二要素認証をスキップできるデバイスを管理します。信頼は30日間有効で、デバイスを使用すると自動的に延長されます。", 507 "manageTrustedDevices": "信頼済みデバイスを管理",
··· 296 "confirmNewPasswordPlaceholder": "新しいパスワードを再入力", 297 "changePasswordButton": "パスワードを変更", 298 "changing": "変更中...", 299 + "setPassword": "パスワードを設定", 300 + "setPasswordDescription": "現在、あなたのアカウントはパスキーのみです。パスワードを追加すると、パスキーと併せて従来のログインも使用できます。", 301 + "setPasswordButton": "パスワードを設定", 302 + "setting": "設定中...", 303 "exportData": "データエクスポート", 304 "exportDataDescription": "リポジトリ全体を CAR(Content Addressable Archive)ファイルとしてダウンロードします。投稿、いいね、フォローなどすべてのデータが含まれます。", 305 "downloadRepo": "リポジトリをダウンロード", ··· 349 "handleUpdateFailed": "ハンドルの更新に失敗しました", 350 "passwordChanged": "パスワードを変更しました", 351 "passwordChangeFailed": "パスワードの変更に失敗しました", 352 + "passwordSet": "パスワードを設定しました", 353 + "passwordSetFailed": "パスワードの設定に失敗しました", 354 "passwordsMismatch": "パスワードが一致しません", 355 "passwordsDoNotMatch": "パスワードが一致しません", 356 "passwordLength": "パスワードは8文字以上である必要があります", ··· 508 "beforeProceedingItem3": "復旧用の通知チャンネルが最新であることを確認", 509 "addPasskeyFirst": "パスワードを削除する前に、少なくとも1つのパスキーを追加してください。", 510 "passkeyOnlyHint": "パスキーのみでサインインしています。パスキーにアクセスできなくなった場合、ログインページの「パスキーを紛失しましたか?」リンクからアカウントを復旧できます。", 511 + "addPasswordHint": "パスワードを追加しますか?設定で追加できます。", 512 + "goToSettings": "設定へ移動", 513 "trustedDevices": "信頼済みデバイス", 514 "trustedDevicesDescription": "サインイン時に二要素認証をスキップできるデバイスを管理します。信頼は30日間有効で、デバイスを使用すると自動的に延長されます。", 515 "manageTrustedDevices": "信頼済みデバイスを管理",
+8
frontend/src/locales/ko.json
··· 296 "confirmNewPasswordPlaceholder": "새 비밀번호 재입력", 297 "changePasswordButton": "비밀번호 변경", 298 "changing": "변경 중...", 299 "exportData": "데이터 내보내기", 300 "exportDataDescription": "전체 저장소를 CAR (Content Addressable Archive) 파일로 다운로드합니다. 모든 게시물, 좋아요, 팔로우 및 기타 데이터가 포함됩니다.", 301 "downloadRepo": "저장소 다운로드", ··· 345 "handleUpdateFailed": "핸들 업데이트에 실패했습니다", 346 "passwordChanged": "비밀번호가 변경되었습니다", 347 "passwordChangeFailed": "비밀번호 변경에 실패했습니다", 348 "passwordsMismatch": "비밀번호가 일치하지 않습니다", 349 "passwordsDoNotMatch": "비밀번호가 일치하지 않습니다", 350 "passwordLength": "비밀번호는 8자 이상이어야 합니다", ··· 502 "beforeProceedingItem3": "복구 알림 채널이 최신인지 확인", 503 "addPasskeyFirst": "비밀번호를 제거하려면 먼저 최소 하나의 패스키를 추가하세요.", 504 "passkeyOnlyHint": "패스키로만 로그인합니다. 패스키에 액세스할 수 없게 되면 로그인 페이지의 '패스키를 분실하셨나요?' 링크를 사용하여 계정을 복구할 수 있습니다.", 505 "trustedDevices": "신뢰할 수 있는 기기", 506 "trustedDevicesDescription": "로그인 시 2단계 인증을 건너뛸 수 있는 기기를 관리합니다. 신뢰는 30일간 유효하며 기기를 사용하면 자동으로 연장됩니다.", 507 "manageTrustedDevices": "신뢰할 수 있는 기기 관리",
··· 296 "confirmNewPasswordPlaceholder": "새 비밀번호 재입력", 297 "changePasswordButton": "비밀번호 변경", 298 "changing": "변경 중...", 299 + "setPassword": "비밀번호 설정", 300 + "setPasswordDescription": "현재 계정은 패스키 전용입니다. 비밀번호를 추가하면 패스키와 함께 기존 로그인 방식도 사용할 수 있습니다.", 301 + "setPasswordButton": "비밀번호 설정", 302 + "setting": "설정 중...", 303 "exportData": "데이터 내보내기", 304 "exportDataDescription": "전체 저장소를 CAR (Content Addressable Archive) 파일로 다운로드합니다. 모든 게시물, 좋아요, 팔로우 및 기타 데이터가 포함됩니다.", 305 "downloadRepo": "저장소 다운로드", ··· 349 "handleUpdateFailed": "핸들 업데이트에 실패했습니다", 350 "passwordChanged": "비밀번호가 변경되었습니다", 351 "passwordChangeFailed": "비밀번호 변경에 실패했습니다", 352 + "passwordSet": "비밀번호가 설정되었습니다", 353 + "passwordSetFailed": "비밀번호 설정에 실패했습니다", 354 "passwordsMismatch": "비밀번호가 일치하지 않습니다", 355 "passwordsDoNotMatch": "비밀번호가 일치하지 않습니다", 356 "passwordLength": "비밀번호는 8자 이상이어야 합니다", ··· 508 "beforeProceedingItem3": "복구 알림 채널이 최신인지 확인", 509 "addPasskeyFirst": "비밀번호를 제거하려면 먼저 최소 하나의 패스키를 추가하세요.", 510 "passkeyOnlyHint": "패스키로만 로그인합니다. 패스키에 액세스할 수 없게 되면 로그인 페이지의 '패스키를 분실하셨나요?' 링크를 사용하여 계정을 복구할 수 있습니다.", 511 + "addPasswordHint": "비밀번호를 추가하시겠습니까? 설정에서 설정하세요.", 512 + "goToSettings": "설정으로 이동", 513 "trustedDevices": "신뢰할 수 있는 기기", 514 "trustedDevicesDescription": "로그인 시 2단계 인증을 건너뛸 수 있는 기기를 관리합니다. 신뢰는 30일간 유효하며 기기를 사용하면 자동으로 연장됩니다.", 515 "manageTrustedDevices": "신뢰할 수 있는 기기 관리",
+8
frontend/src/locales/sv.json
··· 296 "confirmNewPasswordPlaceholder": "Bekräfta nytt lösenord", 297 "changePasswordButton": "Ändra lösenord", 298 "changing": "Ändrar...", 299 "exportData": "Exportera data", 300 "exportDataDescription": "Ladda ner hela ditt arkiv som en CAR-fil (Content Addressable Archive). Detta inkluderar alla dina inlägg, gillanden, följningar och annan data.", 301 "downloadRepo": "Ladda ner arkiv", ··· 345 "handleUpdateFailed": "Kunde inte uppdatera användarnamn", 346 "passwordChanged": "Lösenord ändrat", 347 "passwordChangeFailed": "Kunde inte ändra lösenord", 348 "passwordsMismatch": "Lösenorden matchar inte", 349 "passwordsDoNotMatch": "Lösenorden matchar inte", 350 "passwordLength": "Lösenordet måste vara minst 8 tecken", ··· 502 "beforeProceedingItem3": "Se till att din meddelandekanal för återställning är uppdaterad", 503 "addPasskeyFirst": "Lägg till minst en nyckel innan du kan ta bort ditt lösenord.", 504 "passkeyOnlyHint": "Du loggar in med endast nycklar. Om du förlorar tillgång till dina nycklar kan du återställa ditt konto med länken \"Tappat bort nyckeln?\" på inloggningssidan.", 505 "trustedDevices": "Betrodda enheter", 506 "trustedDevicesDescription": "Hantera enheter som kan hoppa över tvåfaktorsautentisering vid inloggning. Förtroende beviljas i 30 dagar och förlängs automatiskt när du använder enheten.", 507 "manageTrustedDevices": "Hantera betrodda enheter",
··· 296 "confirmNewPasswordPlaceholder": "Bekräfta nytt lösenord", 297 "changePasswordButton": "Ändra lösenord", 298 "changing": "Ändrar...", 299 + "setPassword": "Ange lösenord", 300 + "setPasswordDescription": "Ditt konto är för närvarande endast passnycklar. Du kan lägga till ett lösenord för att aktivera traditionell inloggning tillsammans med dina passnycklar.", 301 + "setPasswordButton": "Ange lösenord", 302 + "setting": "Anger...", 303 "exportData": "Exportera data", 304 "exportDataDescription": "Ladda ner hela ditt arkiv som en CAR-fil (Content Addressable Archive). Detta inkluderar alla dina inlägg, gillanden, följningar och annan data.", 305 "downloadRepo": "Ladda ner arkiv", ··· 349 "handleUpdateFailed": "Kunde inte uppdatera användarnamn", 350 "passwordChanged": "Lösenord ändrat", 351 "passwordChangeFailed": "Kunde inte ändra lösenord", 352 + "passwordSet": "Lösenord har angetts", 353 + "passwordSetFailed": "Kunde inte ange lösenord", 354 "passwordsMismatch": "Lösenorden matchar inte", 355 "passwordsDoNotMatch": "Lösenorden matchar inte", 356 "passwordLength": "Lösenordet måste vara minst 8 tecken", ··· 508 "beforeProceedingItem3": "Se till att din meddelandekanal för återställning är uppdaterad", 509 "addPasskeyFirst": "Lägg till minst en nyckel innan du kan ta bort ditt lösenord.", 510 "passkeyOnlyHint": "Du loggar in med endast nycklar. Om du förlorar tillgång till dina nycklar kan du återställa ditt konto med länken \"Tappat bort nyckeln?\" på inloggningssidan.", 511 + "addPasswordHint": "Vill du lägga till ett lösenord? Gå till Inställningar för att ställa in ett.", 512 + "goToSettings": "Gå till inställningar", 513 "trustedDevices": "Betrodda enheter", 514 "trustedDevicesDescription": "Hantera enheter som kan hoppa över tvåfaktorsautentisering vid inloggning. Förtroende beviljas i 30 dagar och förlängs automatiskt när du använder enheten.", 515 "manageTrustedDevices": "Hantera betrodda enheter",
+8
frontend/src/locales/zh.json
··· 296 "confirmNewPasswordPlaceholder": "再次输入新密码", 297 "changePasswordButton": "更改密码", 298 "changing": "更改中...", 299 "exportData": "导出数据", 300 "exportDataDescription": "将您的所有数据下载为 CAR 文件。包括您的所有帖子、点赞、关注等数据。", 301 "downloadRepo": "下载数据", ··· 345 "handleUpdateFailed": "用户名更新失败", 346 "passwordChanged": "密码更改成功", 347 "passwordChangeFailed": "密码更改失败", 348 "passwordsMismatch": "两次输入的密码不一致", 349 "passwordsDoNotMatch": "两次输入的密码不一致", 350 "passwordLength": "密码至少需要8位字符", ··· 502 "beforeProceedingItem3": "确保您的恢复通知渠道是最新的", 503 "addPasskeyFirst": "请先添加至少一个通行密钥才能移除密码。", 504 "passkeyOnlyHint": "您使用通行密钥登录。如果您丢失了通行密钥,可以使用登录页面上的「丢失通行密钥?」链接恢复账户。", 505 "trustedDevices": "受信任设备", 506 "trustedDevicesDescription": "管理可以跳过双重身份验证的设备。信任有效期为30天,使用设备时自动延长。", 507 "manageTrustedDevices": "管理受信任设备",
··· 296 "confirmNewPasswordPlaceholder": "再次输入新密码", 297 "changePasswordButton": "更改密码", 298 "changing": "更改中...", 299 + "setPassword": "设置密码", 300 + "setPasswordDescription": "您的账户当前仅使用通行密钥。您可以添加密码以启用传统登录方式与通行密钥并用。", 301 + "setPasswordButton": "设置密码", 302 + "setting": "设置中...", 303 "exportData": "导出数据", 304 "exportDataDescription": "将您的所有数据下载为 CAR 文件。包括您的所有帖子、点赞、关注等数据。", 305 "downloadRepo": "下载数据", ··· 349 "handleUpdateFailed": "用户名更新失败", 350 "passwordChanged": "密码更改成功", 351 "passwordChangeFailed": "密码更改失败", 352 + "passwordSet": "密码设置成功", 353 + "passwordSetFailed": "密码设置失败", 354 "passwordsMismatch": "两次输入的密码不一致", 355 "passwordsDoNotMatch": "两次输入的密码不一致", 356 "passwordLength": "密码至少需要8位字符", ··· 508 "beforeProceedingItem3": "确保您的恢复通知渠道是最新的", 509 "addPasskeyFirst": "请先添加至少一个通行密钥才能移除密码。", 510 "passkeyOnlyHint": "您使用通行密钥登录。如果您丢失了通行密钥,可以使用登录页面上的「丢失通行密钥?」链接恢复账户。", 511 + "addPasswordHint": "想要添加密码?前往设置进行设置。", 512 + "goToSettings": "前往设置", 513 "trustedDevices": "受信任设备", 514 "trustedDevicesDescription": "管理可以跳过双重身份验证的设备。信任有效期为30天,使用设备时自动延长。", 515 "manageTrustedDevices": "管理受信任设备",
+6
frontend/src/routes/Security.svelte
··· 678 <p class="hint"> 679 {$_('security.passkeyOnlyHint')} 680 </p> 681 {/if} 682 </section> 683
··· 678 <p class="hint"> 679 {$_('security.passkeyOnlyHint')} 680 </p> 681 + <p class="hint"> 682 + {$_('security.addPasswordHint')} 683 + </p> 684 + <a href={getFullUrl(routes.settings)} class="section-link"> 685 + {$_('security.goToSettings')} 686 + </a> 687 {/if} 688 </section> 689
+160 -42
frontend/src/routes/Settings.svelte
··· 8 import { unsafeAsHandle } from '../lib/types/branded' 9 import type { Session } from '../lib/types/api' 10 import { toast } from '../lib/toast.svelte' 11 12 const auth = $derived(getAuthState()) 13 const supportedLocales = getSupportedLocales() ··· 63 let newPassword = $state('') 64 let confirmNewPassword = $state('') 65 let showBYOHandle = $state(false) 66 67 $effect(() => { 68 if (!loading && !session) { 69 navigate(routes.login) 70 } 71 }) 72 73 async function handleRequestEmailUpdate() { 74 if (!session) return ··· 374 passwordLoading = false 375 } 376 } 377 </script> 378 <div class="page"> 379 <header> ··· 522 </form> 523 {/if} 524 </section> 525 - <section> 526 - <h2>{$_('settings.changePassword')}</h2> 527 - <form onsubmit={handleChangePassword}> 528 - <div class="field"> 529 - <label for="current-password">{$_('settings.currentPassword')}</label> 530 - <input 531 - id="current-password" 532 - type="password" 533 - bind:value={currentPassword} 534 - placeholder={$_('settings.currentPasswordPlaceholder')} 535 - disabled={passwordLoading} 536 - required 537 - /> 538 - </div> 539 - <div class="field"> 540 - <label for="new-password">{$_('settings.newPassword')}</label> 541 - <input 542 - id="new-password" 543 - type="password" 544 - bind:value={newPassword} 545 - placeholder={$_('settings.newPasswordPlaceholder')} 546 - disabled={passwordLoading} 547 - required 548 - minlength="8" 549 - /> 550 - </div> 551 - <div class="field"> 552 - <label for="confirm-new-password">{$_('settings.confirmNewPassword')}</label> 553 - <input 554 - id="confirm-new-password" 555 - type="password" 556 - bind:value={confirmNewPassword} 557 - placeholder={$_('settings.confirmNewPasswordPlaceholder')} 558 - disabled={passwordLoading} 559 - required 560 - /> 561 - </div> 562 - <button type="submit" disabled={passwordLoading || !currentPassword || !newPassword || !confirmNewPassword}> 563 - {passwordLoading ? $_('settings.changing') : $_('settings.changePasswordButton')} 564 - </button> 565 - </form> 566 - </section> 567 <section> 568 <h2>{$_('settings.exportData')}</h2> 569 <p class="description">{$_('settings.exportDataDescription')}</p> ··· 681 {/if} 682 </section> 683 </div> 684 <style> 685 .page { 686 max-width: var(--width-lg);
··· 8 import { unsafeAsHandle } from '../lib/types/branded' 9 import type { Session } from '../lib/types/api' 10 import { toast } from '../lib/toast.svelte' 11 + import ReauthModal from '../components/ReauthModal.svelte' 12 13 const auth = $derived(getAuthState()) 14 const supportedLocales = getSupportedLocales() ··· 64 let newPassword = $state('') 65 let confirmNewPassword = $state('') 66 let showBYOHandle = $state(false) 67 + let hasPassword = $state(true) 68 + let passwordStatusLoading = $state(true) 69 + let setPasswordLoading = $state(false) 70 + let showReauthModal = $state(false) 71 + let reauthMethods = $state<string[]>(['passkey']) 72 + let pendingAction = $state<(() => Promise<void>) | null>(null) 73 74 $effect(() => { 75 if (!loading && !session) { 76 navigate(routes.login) 77 } 78 }) 79 + 80 + $effect(() => { 81 + if (session) { 82 + loadPasswordStatus() 83 + } 84 + }) 85 + 86 + async function loadPasswordStatus() { 87 + if (!session) return 88 + passwordStatusLoading = true 89 + try { 90 + const status = await api.getPasswordStatus(session.accessJwt) 91 + hasPassword = status.hasPassword 92 + } catch { 93 + hasPassword = true 94 + } finally { 95 + passwordStatusLoading = false 96 + } 97 + } 98 99 async function handleRequestEmailUpdate() { 100 if (!session) return ··· 400 passwordLoading = false 401 } 402 } 403 + 404 + async function handleSetPassword(e: Event) { 405 + e.preventDefault() 406 + if (!session || !newPassword || !confirmNewPassword) return 407 + if (newPassword !== confirmNewPassword) { 408 + toast.error($_('settings.messages.passwordsDoNotMatch')) 409 + return 410 + } 411 + if (newPassword.length < 8) { 412 + toast.error($_('settings.messages.passwordTooShort')) 413 + return 414 + } 415 + setPasswordLoading = true 416 + try { 417 + await api.setPassword(session.accessJwt, newPassword) 418 + toast.success($_('settings.messages.passwordSet')) 419 + hasPassword = true 420 + newPassword = '' 421 + confirmNewPassword = '' 422 + } catch (e) { 423 + if (e instanceof ApiError) { 424 + if (e.error === 'ReauthRequired') { 425 + reauthMethods = e.reauthMethods || ['passkey'] 426 + pendingAction = () => handleSetPassword(new Event('submit')) 427 + showReauthModal = true 428 + } else { 429 + toast.error(e.message) 430 + } 431 + } else { 432 + toast.error($_('settings.messages.passwordSetFailed')) 433 + } 434 + } finally { 435 + setPasswordLoading = false 436 + } 437 + } 438 + 439 + function handleReauthSuccess() { 440 + if (pendingAction) { 441 + pendingAction() 442 + pendingAction = null 443 + } 444 + } 445 + 446 + function handleReauthCancel() { 447 + pendingAction = null 448 + } 449 </script> 450 <div class="page"> 451 <header> ··· 594 </form> 595 {/if} 596 </section> 597 + {#if !passwordStatusLoading} 598 + {#if hasPassword} 599 + <section> 600 + <h2>{$_('settings.changePassword')}</h2> 601 + <form onsubmit={handleChangePassword}> 602 + <div class="field"> 603 + <label for="current-password">{$_('settings.currentPassword')}</label> 604 + <input 605 + id="current-password" 606 + type="password" 607 + bind:value={currentPassword} 608 + placeholder={$_('settings.currentPasswordPlaceholder')} 609 + disabled={passwordLoading} 610 + required 611 + /> 612 + </div> 613 + <div class="field"> 614 + <label for="new-password">{$_('settings.newPassword')}</label> 615 + <input 616 + id="new-password" 617 + type="password" 618 + bind:value={newPassword} 619 + placeholder={$_('settings.newPasswordPlaceholder')} 620 + disabled={passwordLoading} 621 + required 622 + minlength="8" 623 + /> 624 + </div> 625 + <div class="field"> 626 + <label for="confirm-new-password">{$_('settings.confirmNewPassword')}</label> 627 + <input 628 + id="confirm-new-password" 629 + type="password" 630 + bind:value={confirmNewPassword} 631 + placeholder={$_('settings.confirmNewPasswordPlaceholder')} 632 + disabled={passwordLoading} 633 + required 634 + /> 635 + </div> 636 + <button type="submit" disabled={passwordLoading || !currentPassword || !newPassword || !confirmNewPassword}> 637 + {passwordLoading ? $_('settings.changing') : $_('settings.changePasswordButton')} 638 + </button> 639 + </form> 640 + </section> 641 + {:else} 642 + <section> 643 + <h2>{$_('settings.setPassword')}</h2> 644 + <p class="description">{$_('settings.setPasswordDescription')}</p> 645 + <form onsubmit={handleSetPassword}> 646 + <div class="field"> 647 + <label for="set-new-password">{$_('settings.newPassword')}</label> 648 + <input 649 + id="set-new-password" 650 + type="password" 651 + bind:value={newPassword} 652 + placeholder={$_('settings.newPasswordPlaceholder')} 653 + disabled={setPasswordLoading} 654 + required 655 + minlength="8" 656 + /> 657 + </div> 658 + <div class="field"> 659 + <label for="set-confirm-password">{$_('settings.confirmNewPassword')}</label> 660 + <input 661 + id="set-confirm-password" 662 + type="password" 663 + bind:value={confirmNewPassword} 664 + placeholder={$_('settings.confirmNewPasswordPlaceholder')} 665 + disabled={setPasswordLoading} 666 + required 667 + /> 668 + </div> 669 + <button type="submit" disabled={setPasswordLoading || !newPassword || !confirmNewPassword}> 670 + {setPasswordLoading ? $_('settings.setting') : $_('settings.setPasswordButton')} 671 + </button> 672 + </form> 673 + </section> 674 + {/if} 675 + {/if} 676 <section> 677 <h2>{$_('settings.exportData')}</h2> 678 <p class="description">{$_('settings.exportDataDescription')}</p> ··· 790 {/if} 791 </section> 792 </div> 793 + 794 + {#if showReauthModal && session} 795 + <ReauthModal 796 + bind:show={showReauthModal} 797 + availableMethods={reauthMethods} 798 + onSuccess={handleReauthSuccess} 799 + onCancel={handleReauthCancel} 800 + /> 801 + {/if} 802 <style> 803 .page { 804 max-width: var(--width-lg);
+1 -1
src/api/error.rs
··· 127 | Self::InvalidCode(_) 128 | Self::InvalidPassword(_) 129 | Self::InvalidToken(_) 130 - | Self::ExpiredToken(_) 131 | Self::PasskeyCounterAnomaly => StatusCode::UNAUTHORIZED, 132 Self::Forbidden 133 | Self::AdminRequired 134 | Self::InsufficientScope(_)
··· 127 | Self::InvalidCode(_) 128 | Self::InvalidPassword(_) 129 | Self::InvalidToken(_) 130 | Self::PasskeyCounterAnomaly => StatusCode::UNAUTHORIZED, 131 + Self::ExpiredToken(_) => StatusCode::BAD_REQUEST, 132 Self::Forbidden 133 | Self::AdminRequired 134 | Self::InsufficientScope(_)
+10 -13
src/api/proxy.rs
··· 268 } 269 Err(e) => { 270 warn!("Token validation failed: {:?}", e); 271 - if matches!(e, crate::auth::TokenValidationError::TokenExpired) { 272 - let is_dpop = extracted.is_dpop; 273 - let scheme = if is_dpop { "DPoP" } else { "Bearer" }; 274 - let www_auth = format!( 275 - "{} error=\"invalid_token\", error_description=\"Token has expired\"", 276 - scheme 277 - ); 278 let mut response = 279 ApiError::ExpiredToken(Some("Token has expired".into())).into_response(); 280 response 281 .headers_mut() 282 .insert("WWW-Authenticate", www_auth.parse().unwrap()); 283 - if is_dpop { 284 - let nonce = crate::oauth::verify::generate_dpop_nonce(); 285 - response 286 - .headers_mut() 287 - .insert("DPoP-Nonce", nonce.parse().unwrap()); 288 - } 289 return response; 290 } 291 }
··· 268 } 269 Err(e) => { 270 warn!("Token validation failed: {:?}", e); 271 + if matches!(e, crate::auth::TokenValidationError::TokenExpired) 272 + && extracted.is_dpop 273 + { 274 + let www_auth = 275 + "DPoP error=\"invalid_token\", error_description=\"Token has expired\""; 276 let mut response = 277 ApiError::ExpiredToken(Some("Token has expired".into())).into_response(); 278 + *response.status_mut() = axum::http::StatusCode::UNAUTHORIZED; 279 response 280 .headers_mut() 281 .insert("WWW-Authenticate", www_auth.parse().unwrap()); 282 + let nonce = crate::oauth::verify::generate_dpop_nonce(); 283 + response 284 + .headers_mut() 285 + .insert("DPoP-Nonce", nonce.parse().unwrap()); 286 return response; 287 } 288 }
+3
src/api/repo/record/batch.rs
··· 444 .await 445 { 446 Ok(res) => res, 447 Err(e) => { 448 error!("Commit failed: {}", e); 449 return ApiError::InternalError(Some("Failed to commit changes".into()))
··· 444 .await 445 { 446 Ok(res) => res, 447 + Err(e) if e.contains("ConcurrentModification") => { 448 + return ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response(); 449 + } 450 Err(e) => { 451 error!("Commit failed: {}", e); 452 return ApiError::InternalError(Some("Failed to commit changes".into()))
+3
src/api/repo/record/delete.rs
··· 183 .await 184 { 185 Ok(res) => res, 186 Err(e) => return ApiError::InternalError(Some(e)).into_response(), 187 }; 188
··· 183 .await 184 { 185 Ok(res) => res, 186 + Err(e) if e.contains("ConcurrentModification") => { 187 + return ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response(); 188 + } 189 Err(e) => return ApiError::InternalError(Some(e)).into_response(), 190 }; 191
+12 -10
src/api/repo/record/write.rs
··· 83 .map_err(|e| { 84 tracing::warn!(error = ?e, is_dpop = extracted.is_dpop, "Token validation failed in prepare_repo_write"); 85 let mut response = ApiError::from(e).into_response(); 86 - if matches!(e, crate::auth::TokenValidationError::TokenExpired) { 87 - let scheme = if extracted.is_dpop { "DPoP" } else { "Bearer" }; 88 - let www_auth = format!( 89 - "{} error=\"invalid_token\", error_description=\"Token has expired\"", 90 - scheme 91 - ); 92 response.headers_mut().insert( 93 "WWW-Authenticate", 94 www_auth.parse().unwrap(), 95 ); 96 - if extracted.is_dpop { 97 - let nonce = crate::oauth::verify::generate_dpop_nonce(); 98 - response.headers_mut().insert("DPoP-Nonce", nonce.parse().unwrap()); 99 - } 100 } 101 response 102 })?; ··· 322 .await 323 { 324 Ok(res) => res, 325 Err(e) => return ApiError::InternalError(Some(e)).into_response(), 326 }; 327 ··· 580 .await 581 { 582 Ok(res) => res, 583 Err(e) => return ApiError::InternalError(Some(e)).into_response(), 584 }; 585
··· 83 .map_err(|e| { 84 tracing::warn!(error = ?e, is_dpop = extracted.is_dpop, "Token validation failed in prepare_repo_write"); 85 let mut response = ApiError::from(e).into_response(); 86 + if matches!(e, crate::auth::TokenValidationError::TokenExpired) && extracted.is_dpop { 87 + *response.status_mut() = axum::http::StatusCode::UNAUTHORIZED; 88 + let www_auth = 89 + "DPoP error=\"invalid_token\", error_description=\"Token has expired\""; 90 response.headers_mut().insert( 91 "WWW-Authenticate", 92 www_auth.parse().unwrap(), 93 ); 94 + let nonce = crate::oauth::verify::generate_dpop_nonce(); 95 + response.headers_mut().insert("DPoP-Nonce", nonce.parse().unwrap()); 96 } 97 response 98 })?; ··· 318 .await 319 { 320 Ok(res) => res, 321 + Err(e) if e.contains("ConcurrentModification") => { 322 + return ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response(); 323 + } 324 Err(e) => return ApiError::InternalError(Some(e)).into_response(), 325 }; 326 ··· 579 .await 580 { 581 Ok(res) => res, 582 + Err(e) if e.contains("ConcurrentModification") => { 583 + return ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response(); 584 + } 585 Err(e) => return ApiError::InternalError(Some(e)).into_response(), 586 }; 587
+1
src/api/server/mod.rs
··· 37 }; 38 pub use password::{ 39 change_password, get_password_status, remove_password, request_password_reset, reset_password, 40 }; 41 pub use reauth::{ 42 check_legacy_session_mfa, check_reauth_required, get_reauth_status,
··· 37 }; 38 pub use password::{ 39 change_password, get_password_status, remove_password, request_password_reset, reset_password, 40 + set_password, 41 }; 42 pub use reauth::{ 43 check_legacy_session_mfa, check_reauth_required, get_reauth_status,
+84
src/api/server/password.rs
··· 412 info!(did = %&auth.0.did, "Password removed - account is now passkey-only"); 413 SuccessResponse::ok().into_response() 414 }
··· 412 info!(did = %&auth.0.did, "Password removed - account is now passkey-only"); 413 SuccessResponse::ok().into_response() 414 } 415 + 416 + #[derive(Deserialize)] 417 + #[serde(rename_all = "camelCase")] 418 + pub struct SetPasswordInput { 419 + pub new_password: PlainPassword, 420 + } 421 + 422 + pub async fn set_password( 423 + State(state): State<AppState>, 424 + auth: BearerAuth, 425 + Json(input): Json<SetPasswordInput>, 426 + ) -> Response { 427 + if crate::api::server::reauth::check_reauth_required_cached( 428 + &state.db, 429 + &state.cache, 430 + &auth.0.did, 431 + ) 432 + .await 433 + { 434 + return crate::api::server::reauth::reauth_required_response(&state.db, &auth.0.did).await; 435 + } 436 + 437 + let new_password = &input.new_password; 438 + if new_password.is_empty() { 439 + return ApiError::InvalidRequest("newPassword is required".into()).into_response(); 440 + } 441 + if let Err(e) = validate_password(new_password) { 442 + return ApiError::InvalidRequest(e.to_string()).into_response(); 443 + } 444 + 445 + let user = sqlx::query!( 446 + "SELECT id, password_hash FROM users WHERE did = $1", 447 + &auth.0.did 448 + ) 449 + .fetch_optional(&state.db) 450 + .await; 451 + 452 + let user = match user { 453 + Ok(Some(u)) => u, 454 + Ok(None) => { 455 + return ApiError::AccountNotFound.into_response(); 456 + } 457 + Err(e) => { 458 + error!("DB error: {:?}", e); 459 + return ApiError::InternalError(None).into_response(); 460 + } 461 + }; 462 + 463 + if user.password_hash.is_some() { 464 + return ApiError::InvalidRequest( 465 + "Account already has a password. Use changePassword instead.".into(), 466 + ) 467 + .into_response(); 468 + } 469 + 470 + let new_password_clone = new_password.to_string(); 471 + let new_hash = 472 + match tokio::task::spawn_blocking(move || hash(new_password_clone, DEFAULT_COST)).await { 473 + Ok(Ok(h)) => h, 474 + Ok(Err(e)) => { 475 + error!("Failed to hash password: {:?}", e); 476 + return ApiError::InternalError(None).into_response(); 477 + } 478 + Err(e) => { 479 + error!("Failed to spawn blocking task: {:?}", e); 480 + return ApiError::InternalError(None).into_response(); 481 + } 482 + }; 483 + 484 + if let Err(e) = sqlx::query!( 485 + "UPDATE users SET password_hash = $1, password_required = TRUE WHERE id = $2", 486 + new_hash, 487 + user.id 488 + ) 489 + .execute(&state.db) 490 + .await 491 + { 492 + error!("DB error setting password: {:?}", e); 493 + return ApiError::InternalError(None).into_response(); 494 + } 495 + 496 + info!(did = %&auth.0.did, "Password set for passkey-only account"); 497 + SuccessResponse::ok().into_response() 498 + }
+14 -1
src/lib.rs
··· 198 post(api::server::remove_password), 199 ) 200 .route( 201 "/_account.getPasswordStatus", 202 get(api::server::get_password_status), 203 ) ··· 590 CorsLayer::new() 591 .allow_origin(Any) 592 .allow_methods([Method::GET, Method::POST, Method::OPTIONS]) 593 - .allow_headers(Any) 594 .expose_headers([ 595 "WWW-Authenticate".parse().unwrap(), 596 "DPoP-Nonce".parse().unwrap(),
··· 198 post(api::server::remove_password), 199 ) 200 .route( 201 + "/_account.setPassword", 202 + post(api::server::set_password), 203 + ) 204 + .route( 205 "/_account.getPasswordStatus", 206 get(api::server::get_password_status), 207 ) ··· 594 CorsLayer::new() 595 .allow_origin(Any) 596 .allow_methods([Method::GET, Method::POST, Method::OPTIONS]) 597 + .allow_headers([ 598 + "Authorization".parse().unwrap(), 599 + "Content-Type".parse().unwrap(), 600 + "Content-Encoding".parse().unwrap(), 601 + "Accept-Encoding".parse().unwrap(), 602 + "DPoP".parse().unwrap(), 603 + "atproto-proxy".parse().unwrap(), 604 + "atproto-accept-labelers".parse().unwrap(), 605 + "x-bsky-topics".parse().unwrap(), 606 + ]) 607 .expose_headers([ 608 "WWW-Authenticate".parse().unwrap(), 609 "DPoP-Nonce".parse().unwrap(),
-3
src/oauth/endpoints/token/grants.rs
··· 116 } else { 117 None 118 }; 119 - if let Err(e) = db::revoke_tokens_for_client(&state.db, &did, &auth_request.client_id).await { 120 - tracing::warn!("Failed to revoke previous tokens for client: {:?}", e); 121 - } 122 let token_id = TokenId::generate(); 123 let refresh_token = RefreshToken::generate(); 124 let now = Utc::now();
··· 116 } else { 117 None 118 }; 119 let token_id = TokenId::generate(); 120 let refresh_token = RefreshToken::generate(); 121 let now = Utc::now();
+10 -36
src/scheduled.rs
··· 803 db: &PgPool, 804 block_store: &PostgresBlockStore, 805 user_id: uuid::Uuid, 806 - head_cid: &Cid, 807 ) -> Result<Vec<u8>, String> { 808 - use jacquard_repo::storage::BlockStore; 809 810 - let block_cid_bytes: Vec<Vec<u8>> = sqlx::query_scalar!( 811 - "SELECT block_cid FROM user_blocks WHERE user_id = $1", 812 user_id 813 ) 814 - .fetch_all(db) 815 .await 816 - .map_err(|e| format!("Failed to fetch user_blocks: {}", e))?; 817 - 818 - if block_cid_bytes.is_empty() { 819 - let cids = collect_current_repo_blocks(block_store, head_cid).await?; 820 - if cids.is_empty() { 821 - return Err("No blocks found for repo".to_string()); 822 - } 823 - return generate_repo_car(block_store, head_cid).await; 824 - } 825 - 826 - let block_cids: Vec<Cid> = block_cid_bytes 827 - .iter() 828 - .filter_map(|bytes| Cid::try_from(bytes.as_slice()).ok()) 829 - .collect(); 830 831 - let car_bytes = 832 - encode_car_header(head_cid).map_err(|e| format!("Failed to encode CAR header: {}", e))?; 833 834 - let blocks = block_store 835 - .get_many(&block_cids) 836 - .await 837 - .map_err(|e| format!("Failed to fetch blocks: {:?}", e))?; 838 - 839 - let car_bytes = block_cids 840 - .iter() 841 - .zip(blocks.iter()) 842 - .filter_map(|(cid, block_opt)| block_opt.as_ref().map(|block| (cid, block))) 843 - .fold(car_bytes, |mut acc, (cid, block)| { 844 - acc.extend(encode_car_block(cid, block)); 845 - acc 846 - }); 847 - 848 - Ok(car_bytes) 849 } 850 851 pub async fn generate_full_backup(
··· 803 db: &PgPool, 804 block_store: &PostgresBlockStore, 805 user_id: uuid::Uuid, 806 + _head_cid: &Cid, 807 ) -> Result<Vec<u8>, String> { 808 + use std::str::FromStr; 809 810 + let repo_root_cid_str: String = sqlx::query_scalar!( 811 + "SELECT repo_root_cid FROM repos WHERE user_id = $1", 812 user_id 813 ) 814 + .fetch_optional(db) 815 .await 816 + .map_err(|e| format!("Failed to fetch repo: {}", e))? 817 + .ok_or_else(|| "Repository not found".to_string())?; 818 819 + let actual_head_cid = Cid::from_str(&repo_root_cid_str) 820 + .map_err(|e| format!("Invalid repo_root_cid: {}", e))?; 821 822 + generate_repo_car(block_store, &actual_head_cid).await 823 } 824 825 pub async fn generate_full_backup(
+2
tests/common/mod.rs
··· 136 ); 137 std::env::set_var("S3_ENDPOINT", &s3_endpoint); 138 std::env::set_var("MAX_IMPORT_SIZE", "100000000"); 139 } 140 let mock_server = MockServer::start().await; 141 setup_mock_appview(&mock_server).await; ··· 170 std::env::set_var("AWS_REGION", "us-east-1"); 171 std::env::set_var("S3_ENDPOINT", &s3_endpoint); 172 std::env::set_var("MAX_IMPORT_SIZE", "100000000"); 173 } 174 let sdk_config = aws_config::defaults(BehaviorVersion::latest()) 175 .region("us-east-1")
··· 136 ); 137 std::env::set_var("S3_ENDPOINT", &s3_endpoint); 138 std::env::set_var("MAX_IMPORT_SIZE", "100000000"); 139 + std::env::set_var("SKIP_IMPORT_VERIFICATION", "true"); 140 } 141 let mock_server = MockServer::start().await; 142 setup_mock_appview(&mock_server).await; ··· 171 std::env::set_var("AWS_REGION", "us-east-1"); 172 std::env::set_var("S3_ENDPOINT", &s3_endpoint); 173 std::env::set_var("MAX_IMPORT_SIZE", "100000000"); 174 + std::env::set_var("SKIP_IMPORT_VERIFICATION", "true"); 175 } 176 let sdk_config = aws_config::defaults(BehaviorVersion::latest()) 177 .region("us-east-1")
+1 -1
tests/delete_account.rs
··· 228 .send() 229 .await 230 .expect("Failed to send delete request"); 231 - assert_eq!(delete_res.status(), StatusCode::UNAUTHORIZED); 232 let body: Value = delete_res.json().await.unwrap(); 233 assert_eq!(body["error"], "ExpiredToken"); 234 }
··· 228 .send() 229 .await 230 .expect("Failed to send delete request"); 231 + assert_eq!(delete_res.status(), StatusCode::BAD_REQUEST); 232 let body: Value = delete_res.json().await.unwrap(); 233 assert_eq!(body["error"], "ExpiredToken"); 234 }
+15 -5
tests/import_verification.rs
··· 156 .send() 157 .await 158 .expect("Failed to import repo"); 159 - assert_eq!(import_res.status(), StatusCode::OK); 160 } 161 162 #[tokio::test] ··· 285 async fn test_import_preserves_records_after_reimport() { 286 let client = client(); 287 let (token, did) = create_account_and_login(&client).await; 288 - let mut rkeys = Vec::new(); 289 for i in 0..3 { 290 let post_payload = json!({ 291 "repo": did, ··· 309 assert_eq!(res.status(), StatusCode::OK); 310 let body: serde_json::Value = res.json().await.unwrap(); 311 let uri = body["uri"].as_str().unwrap(); 312 - let rkey = uri.split('/').next_back().unwrap().to_string(); 313 - rkeys.push(rkey); 314 } 315 for rkey in &rkeys { 316 let get_res = client ··· 352 .send() 353 .await 354 .expect("Failed to import repo"); 355 - assert_eq!(import_res.status(), StatusCode::OK); 356 let list_res = client 357 .get(format!( 358 "{}/xrpc/com.atproto.repo.listRecords?repo={}&collection=app.bsky.feed.post",
··· 156 .send() 157 .await 158 .expect("Failed to import repo"); 159 + let status = import_res.status(); 160 + if status != StatusCode::OK { 161 + let body = import_res.text().await.unwrap_or_default(); 162 + panic!( 163 + "Import failed with status {}: {}", 164 + status, body 165 + ); 166 + } 167 } 168 169 #[tokio::test] ··· 292 async fn test_import_preserves_records_after_reimport() { 293 let client = client(); 294 let (token, did) = create_account_and_login(&client).await; 295 + let mut rkeys = Vec::with_capacity(3); 296 for i in 0..3 { 297 let post_payload = json!({ 298 "repo": did, ··· 316 assert_eq!(res.status(), StatusCode::OK); 317 let body: serde_json::Value = res.json().await.unwrap(); 318 let uri = body["uri"].as_str().unwrap(); 319 + rkeys.push(uri.split('/').next_back().unwrap().to_string()); 320 } 321 for rkey in &rkeys { 322 let get_res = client ··· 358 .send() 359 .await 360 .expect("Failed to import repo"); 361 + let status = import_res.status(); 362 + if status != StatusCode::OK { 363 + let body = import_res.text().await.unwrap_or_default(); 364 + panic!("Import failed with status {}: {}", status, body); 365 + } 366 let list_res = client 367 .get(format!( 368 "{}/xrpc/com.atproto.repo.listRecords?repo={}&collection=app.bsky.feed.post",
+1 -1
tests/password_reset.rs
··· 241 .send() 242 .await 243 .expect("Failed to reset password"); 244 - assert_eq!(res.status(), StatusCode::UNAUTHORIZED); 245 let body: Value = res.json().await.expect("Invalid JSON"); 246 assert_eq!(body["error"], "ExpiredToken"); 247 }
··· 241 .send() 242 .await 243 .expect("Failed to reset password"); 244 + assert_eq!(res.status(), StatusCode::BAD_REQUEST); 245 let body: Value = res.json().await.expect("Invalid JSON"); 246 assert_eq!(body["error"], "ExpiredToken"); 247 }