+15
.sqlx/query-3a8b7a2773033c85cd03dd6f906a9d335019f9b7145e1bee6175d01d0c98b8b4.json
+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
-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
+2
-2
frontend/src/components/ReauthModal.svelte
···
330
330
.tab.active {
331
331
background: var(--accent);
332
332
border-color: var(--accent);
333
-
color: white;
333
+
color: var(--text-inverse);
334
334
}
335
335
336
336
.modal-content {
···
375
375
width: 100%;
376
376
padding: 0.75rem 1.5rem;
377
377
background: var(--accent);
378
-
color: white;
378
+
color: var(--text-inverse);
379
379
border: none;
380
380
border-radius: 4px;
381
381
font-size: 1rem;
+8
frontend/src/lib/api.ts
+8
frontend/src/lib/api.ts
···
540
540
});
541
541
},
542
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
+
543
551
getPasswordStatus(token: AccessToken): Promise<PasswordStatus> {
544
552
return xrpc("_account.getPasswordStatus", { token });
545
553
},
+8
frontend/src/locales/en.json
+8
frontend/src/locales/en.json
···
303
303
"confirmNewPasswordPlaceholder": "Confirm new password",
304
304
"changePasswordButton": "Change Password",
305
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...",
306
310
"exportData": "Export Data",
307
311
"exportDataDescription": "Download your entire repository as a CAR (Content Addressable Archive) file. This includes all your posts, likes, follows, and other data.",
308
312
"downloadRepo": "Download Repository",
···
352
356
"handleUpdateFailed": "Failed to update handle",
353
357
"passwordChanged": "Password changed successfully",
354
358
"passwordChangeFailed": "Failed to change password",
359
+
"passwordSet": "Password set successfully",
360
+
"passwordSetFailed": "Failed to set password",
355
361
"passwordsMismatch": "Passwords do not match",
356
362
"passwordsDoNotMatch": "Passwords do not match",
357
363
"passwordLength": "Password must be at least 8 characters",
···
509
515
"beforeProceedingItem3": "Ensure your recovery notification channel is up to date",
510
516
"addPasskeyFirst": "Add at least one passkey before you can remove your password.",
511
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",
512
520
"trustedDevices": "Trusted Devices",
513
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.",
514
522
"manageTrustedDevices": "Manage Trusted Devices",
+8
frontend/src/locales/fi.json
+8
frontend/src/locales/fi.json
···
303
303
"confirmNewPasswordPlaceholder": "Vahvista uusi salasana",
304
304
"changePasswordButton": "Vaihda salasana",
305
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...",
306
310
"exportData": "Vie tiedot",
307
311
"exportDataDescription": "Lataa koko tietovarastosi CAR-tiedostona (Content Addressable Archive). Tämä sisältää kaikki julkaisusi, tykkäyksesi, seuraamisesi ja muut tiedot.",
308
312
"downloadRepo": "Lataa tietovarasto",
···
352
356
"handleUpdateFailed": "Käyttäjänimen päivitys epäonnistui",
353
357
"passwordChanged": "Salasana vaihdettu",
354
358
"passwordChangeFailed": "Salasanan vaihto epäonnistui",
359
+
"passwordSet": "Salasana asetettu onnistuneesti",
360
+
"passwordSetFailed": "Salasanan asettaminen epäonnistui",
355
361
"passwordsMismatch": "Salasanat eivät täsmää",
356
362
"passwordsDoNotMatch": "Salasanat eivät täsmää",
357
363
"passwordLength": "Salasanan on oltava vähintään 8 merkkiä",
···
509
515
"beforeProceedingItem3": "Varmista, että palautusilmoituskanavasi on ajan tasalla",
510
516
"addPasskeyFirst": "Lisää vähintään yksi pääsyavain ennen kuin voit poistaa salasanasi.",
511
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",
512
520
"trustedDevices": "Luotetut laitteet",
513
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.",
514
522
"manageTrustedDevices": "Hallitse luotettuja laitteita",
+8
frontend/src/locales/ja.json
+8
frontend/src/locales/ja.json
···
296
296
"confirmNewPasswordPlaceholder": "新しいパスワードを再入力",
297
297
"changePasswordButton": "パスワードを変更",
298
298
"changing": "変更中...",
299
+
"setPassword": "パスワードを設定",
300
+
"setPasswordDescription": "現在、あなたのアカウントはパスキーのみです。パスワードを追加すると、パスキーと併せて従来のログインも使用できます。",
301
+
"setPasswordButton": "パスワードを設定",
302
+
"setting": "設定中...",
299
303
"exportData": "データエクスポート",
300
304
"exportDataDescription": "リポジトリ全体を CAR(Content Addressable Archive)ファイルとしてダウンロードします。投稿、いいね、フォローなどすべてのデータが含まれます。",
301
305
"downloadRepo": "リポジトリをダウンロード",
···
345
349
"handleUpdateFailed": "ハンドルの更新に失敗しました",
346
350
"passwordChanged": "パスワードを変更しました",
347
351
"passwordChangeFailed": "パスワードの変更に失敗しました",
352
+
"passwordSet": "パスワードを設定しました",
353
+
"passwordSetFailed": "パスワードの設定に失敗しました",
348
354
"passwordsMismatch": "パスワードが一致しません",
349
355
"passwordsDoNotMatch": "パスワードが一致しません",
350
356
"passwordLength": "パスワードは8文字以上である必要があります",
···
502
508
"beforeProceedingItem3": "復旧用の通知チャンネルが最新であることを確認",
503
509
"addPasskeyFirst": "パスワードを削除する前に、少なくとも1つのパスキーを追加してください。",
504
510
"passkeyOnlyHint": "パスキーのみでサインインしています。パスキーにアクセスできなくなった場合、ログインページの「パスキーを紛失しましたか?」リンクからアカウントを復旧できます。",
511
+
"addPasswordHint": "パスワードを追加しますか?設定で追加できます。",
512
+
"goToSettings": "設定へ移動",
505
513
"trustedDevices": "信頼済みデバイス",
506
514
"trustedDevicesDescription": "サインイン時に二要素認証をスキップできるデバイスを管理します。信頼は30日間有効で、デバイスを使用すると自動的に延長されます。",
507
515
"manageTrustedDevices": "信頼済みデバイスを管理",
+8
frontend/src/locales/ko.json
+8
frontend/src/locales/ko.json
···
296
296
"confirmNewPasswordPlaceholder": "새 비밀번호 재입력",
297
297
"changePasswordButton": "비밀번호 변경",
298
298
"changing": "변경 중...",
299
+
"setPassword": "비밀번호 설정",
300
+
"setPasswordDescription": "현재 계정은 패스키 전용입니다. 비밀번호를 추가하면 패스키와 함께 기존 로그인 방식도 사용할 수 있습니다.",
301
+
"setPasswordButton": "비밀번호 설정",
302
+
"setting": "설정 중...",
299
303
"exportData": "데이터 내보내기",
300
304
"exportDataDescription": "전체 저장소를 CAR (Content Addressable Archive) 파일로 다운로드합니다. 모든 게시물, 좋아요, 팔로우 및 기타 데이터가 포함됩니다.",
301
305
"downloadRepo": "저장소 다운로드",
···
345
349
"handleUpdateFailed": "핸들 업데이트에 실패했습니다",
346
350
"passwordChanged": "비밀번호가 변경되었습니다",
347
351
"passwordChangeFailed": "비밀번호 변경에 실패했습니다",
352
+
"passwordSet": "비밀번호가 설정되었습니다",
353
+
"passwordSetFailed": "비밀번호 설정에 실패했습니다",
348
354
"passwordsMismatch": "비밀번호가 일치하지 않습니다",
349
355
"passwordsDoNotMatch": "비밀번호가 일치하지 않습니다",
350
356
"passwordLength": "비밀번호는 8자 이상이어야 합니다",
···
502
508
"beforeProceedingItem3": "복구 알림 채널이 최신인지 확인",
503
509
"addPasskeyFirst": "비밀번호를 제거하려면 먼저 최소 하나의 패스키를 추가하세요.",
504
510
"passkeyOnlyHint": "패스키로만 로그인합니다. 패스키에 액세스할 수 없게 되면 로그인 페이지의 '패스키를 분실하셨나요?' 링크를 사용하여 계정을 복구할 수 있습니다.",
511
+
"addPasswordHint": "비밀번호를 추가하시겠습니까? 설정에서 설정하세요.",
512
+
"goToSettings": "설정으로 이동",
505
513
"trustedDevices": "신뢰할 수 있는 기기",
506
514
"trustedDevicesDescription": "로그인 시 2단계 인증을 건너뛸 수 있는 기기를 관리합니다. 신뢰는 30일간 유효하며 기기를 사용하면 자동으로 연장됩니다.",
507
515
"manageTrustedDevices": "신뢰할 수 있는 기기 관리",
+8
frontend/src/locales/sv.json
+8
frontend/src/locales/sv.json
···
296
296
"confirmNewPasswordPlaceholder": "Bekräfta nytt lösenord",
297
297
"changePasswordButton": "Ändra lösenord",
298
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...",
299
303
"exportData": "Exportera data",
300
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.",
301
305
"downloadRepo": "Ladda ner arkiv",
···
345
349
"handleUpdateFailed": "Kunde inte uppdatera användarnamn",
346
350
"passwordChanged": "Lösenord ändrat",
347
351
"passwordChangeFailed": "Kunde inte ändra lösenord",
352
+
"passwordSet": "Lösenord har angetts",
353
+
"passwordSetFailed": "Kunde inte ange lösenord",
348
354
"passwordsMismatch": "Lösenorden matchar inte",
349
355
"passwordsDoNotMatch": "Lösenorden matchar inte",
350
356
"passwordLength": "Lösenordet måste vara minst 8 tecken",
···
502
508
"beforeProceedingItem3": "Se till att din meddelandekanal för återställning är uppdaterad",
503
509
"addPasskeyFirst": "Lägg till minst en nyckel innan du kan ta bort ditt lösenord.",
504
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",
505
513
"trustedDevices": "Betrodda enheter",
506
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.",
507
515
"manageTrustedDevices": "Hantera betrodda enheter",
+8
frontend/src/locales/zh.json
+8
frontend/src/locales/zh.json
···
296
296
"confirmNewPasswordPlaceholder": "再次输入新密码",
297
297
"changePasswordButton": "更改密码",
298
298
"changing": "更改中...",
299
+
"setPassword": "设置密码",
300
+
"setPasswordDescription": "您的账户当前仅使用通行密钥。您可以添加密码以启用传统登录方式与通行密钥并用。",
301
+
"setPasswordButton": "设置密码",
302
+
"setting": "设置中...",
299
303
"exportData": "导出数据",
300
304
"exportDataDescription": "将您的所有数据下载为 CAR 文件。包括您的所有帖子、点赞、关注等数据。",
301
305
"downloadRepo": "下载数据",
···
345
349
"handleUpdateFailed": "用户名更新失败",
346
350
"passwordChanged": "密码更改成功",
347
351
"passwordChangeFailed": "密码更改失败",
352
+
"passwordSet": "密码设置成功",
353
+
"passwordSetFailed": "密码设置失败",
348
354
"passwordsMismatch": "两次输入的密码不一致",
349
355
"passwordsDoNotMatch": "两次输入的密码不一致",
350
356
"passwordLength": "密码至少需要8位字符",
···
502
508
"beforeProceedingItem3": "确保您的恢复通知渠道是最新的",
503
509
"addPasskeyFirst": "请先添加至少一个通行密钥才能移除密码。",
504
510
"passkeyOnlyHint": "您使用通行密钥登录。如果您丢失了通行密钥,可以使用登录页面上的「丢失通行密钥?」链接恢复账户。",
511
+
"addPasswordHint": "想要添加密码?前往设置进行设置。",
512
+
"goToSettings": "前往设置",
505
513
"trustedDevices": "受信任设备",
506
514
"trustedDevicesDescription": "管理可以跳过双重身份验证的设备。信任有效期为30天,使用设备时自动延长。",
507
515
"manageTrustedDevices": "管理受信任设备",
+6
frontend/src/routes/Security.svelte
+6
frontend/src/routes/Security.svelte
···
678
678
<p class="hint">
679
679
{$_('security.passkeyOnlyHint')}
680
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>
681
687
{/if}
682
688
</section>
683
689
+160
-42
frontend/src/routes/Settings.svelte
+160
-42
frontend/src/routes/Settings.svelte
···
8
8
import { unsafeAsHandle } from '../lib/types/branded'
9
9
import type { Session } from '../lib/types/api'
10
10
import { toast } from '../lib/toast.svelte'
11
+
import ReauthModal from '../components/ReauthModal.svelte'
11
12
12
13
const auth = $derived(getAuthState())
13
14
const supportedLocales = getSupportedLocales()
···
63
64
let newPassword = $state('')
64
65
let confirmNewPassword = $state('')
65
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)
66
73
67
74
$effect(() => {
68
75
if (!loading && !session) {
69
76
navigate(routes.login)
70
77
}
71
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
+
}
72
98
73
99
async function handleRequestEmailUpdate() {
74
100
if (!session) return
···
374
400
passwordLoading = false
375
401
}
376
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
+
}
377
449
</script>
378
450
<div class="page">
379
451
<header>
···
522
594
</form>
523
595
{/if}
524
596
</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>
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}
567
676
<section>
568
677
<h2>{$_('settings.exportData')}</h2>
569
678
<p class="description">{$_('settings.exportDataDescription')}</p>
···
681
790
{/if}
682
791
</section>
683
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}
684
802
<style>
685
803
.page {
686
804
max-width: var(--width-lg);
+1
-1
src/api/error.rs
+1
-1
src/api/error.rs
···
127
127
| Self::InvalidCode(_)
128
128
| Self::InvalidPassword(_)
129
129
| Self::InvalidToken(_)
130
-
| Self::ExpiredToken(_)
131
130
| Self::PasskeyCounterAnomaly => StatusCode::UNAUTHORIZED,
131
+
Self::ExpiredToken(_) => StatusCode::BAD_REQUEST,
132
132
Self::Forbidden
133
133
| Self::AdminRequired
134
134
| Self::InsufficientScope(_)
+10
-13
src/api/proxy.rs
+10
-13
src/api/proxy.rs
···
268
268
}
269
269
Err(e) => {
270
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
-
);
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\"";
278
276
let mut response =
279
277
ApiError::ExpiredToken(Some("Token has expired".into())).into_response();
278
+
*response.status_mut() = axum::http::StatusCode::UNAUTHORIZED;
280
279
response
281
280
.headers_mut()
282
281
.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
-
}
282
+
let nonce = crate::oauth::verify::generate_dpop_nonce();
283
+
response
284
+
.headers_mut()
285
+
.insert("DPoP-Nonce", nonce.parse().unwrap());
289
286
return response;
290
287
}
291
288
}
+3
src/api/repo/record/batch.rs
+3
src/api/repo/record/batch.rs
···
444
444
.await
445
445
{
446
446
Ok(res) => res,
447
+
Err(e) if e.contains("ConcurrentModification") => {
448
+
return ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response();
449
+
}
447
450
Err(e) => {
448
451
error!("Commit failed: {}", e);
449
452
return ApiError::InternalError(Some("Failed to commit changes".into()))
+3
src/api/repo/record/delete.rs
+3
src/api/repo/record/delete.rs
···
183
183
.await
184
184
{
185
185
Ok(res) => res,
186
+
Err(e) if e.contains("ConcurrentModification") => {
187
+
return ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response();
188
+
}
186
189
Err(e) => return ApiError::InternalError(Some(e)).into_response(),
187
190
};
188
191
+12
-10
src/api/repo/record/write.rs
+12
-10
src/api/repo/record/write.rs
···
83
83
.map_err(|e| {
84
84
tracing::warn!(error = ?e, is_dpop = extracted.is_dpop, "Token validation failed in prepare_repo_write");
85
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
-
);
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\"";
92
90
response.headers_mut().insert(
93
91
"WWW-Authenticate",
94
92
www_auth.parse().unwrap(),
95
93
);
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
-
}
94
+
let nonce = crate::oauth::verify::generate_dpop_nonce();
95
+
response.headers_mut().insert("DPoP-Nonce", nonce.parse().unwrap());
100
96
}
101
97
response
102
98
})?;
···
322
318
.await
323
319
{
324
320
Ok(res) => res,
321
+
Err(e) if e.contains("ConcurrentModification") => {
322
+
return ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response();
323
+
}
325
324
Err(e) => return ApiError::InternalError(Some(e)).into_response(),
326
325
};
327
326
···
580
579
.await
581
580
{
582
581
Ok(res) => res,
582
+
Err(e) if e.contains("ConcurrentModification") => {
583
+
return ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response();
584
+
}
583
585
Err(e) => return ApiError::InternalError(Some(e)).into_response(),
584
586
};
585
587
+1
src/api/server/mod.rs
+1
src/api/server/mod.rs
+84
src/api/server/password.rs
+84
src/api/server/password.rs
···
412
412
info!(did = %&auth.0.did, "Password removed - account is now passkey-only");
413
413
SuccessResponse::ok().into_response()
414
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
+14
-1
src/lib.rs
···
198
198
post(api::server::remove_password),
199
199
)
200
200
.route(
201
+
"/_account.setPassword",
202
+
post(api::server::set_password),
203
+
)
204
+
.route(
201
205
"/_account.getPasswordStatus",
202
206
get(api::server::get_password_status),
203
207
)
···
590
594
CorsLayer::new()
591
595
.allow_origin(Any)
592
596
.allow_methods([Method::GET, Method::POST, Method::OPTIONS])
593
-
.allow_headers(Any)
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
+
])
594
607
.expose_headers([
595
608
"WWW-Authenticate".parse().unwrap(),
596
609
"DPoP-Nonce".parse().unwrap(),
-3
src/oauth/endpoints/token/grants.rs
-3
src/oauth/endpoints/token/grants.rs
···
116
116
} else {
117
117
None
118
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
119
let token_id = TokenId::generate();
123
120
let refresh_token = RefreshToken::generate();
124
121
let now = Utc::now();
+10
-36
src/scheduled.rs
+10
-36
src/scheduled.rs
···
803
803
db: &PgPool,
804
804
block_store: &PostgresBlockStore,
805
805
user_id: uuid::Uuid,
806
-
head_cid: &Cid,
806
+
_head_cid: &Cid,
807
807
) -> Result<Vec<u8>, String> {
808
-
use jacquard_repo::storage::BlockStore;
808
+
use std::str::FromStr;
809
809
810
-
let block_cid_bytes: Vec<Vec<u8>> = sqlx::query_scalar!(
811
-
"SELECT block_cid FROM user_blocks WHERE user_id = $1",
810
+
let repo_root_cid_str: String = sqlx::query_scalar!(
811
+
"SELECT repo_root_cid FROM repos WHERE user_id = $1",
812
812
user_id
813
813
)
814
-
.fetch_all(db)
814
+
.fetch_optional(db)
815
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();
816
+
.map_err(|e| format!("Failed to fetch repo: {}", e))?
817
+
.ok_or_else(|| "Repository not found".to_string())?;
830
818
831
-
let car_bytes =
832
-
encode_car_header(head_cid).map_err(|e| format!("Failed to encode CAR header: {}", e))?;
819
+
let actual_head_cid = Cid::from_str(&repo_root_cid_str)
820
+
.map_err(|e| format!("Invalid repo_root_cid: {}", e))?;
833
821
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)
822
+
generate_repo_car(block_store, &actual_head_cid).await
849
823
}
850
824
851
825
pub async fn generate_full_backup(
+2
tests/common/mod.rs
+2
tests/common/mod.rs
···
136
136
);
137
137
std::env::set_var("S3_ENDPOINT", &s3_endpoint);
138
138
std::env::set_var("MAX_IMPORT_SIZE", "100000000");
139
+
std::env::set_var("SKIP_IMPORT_VERIFICATION", "true");
139
140
}
140
141
let mock_server = MockServer::start().await;
141
142
setup_mock_appview(&mock_server).await;
···
170
171
std::env::set_var("AWS_REGION", "us-east-1");
171
172
std::env::set_var("S3_ENDPOINT", &s3_endpoint);
172
173
std::env::set_var("MAX_IMPORT_SIZE", "100000000");
174
+
std::env::set_var("SKIP_IMPORT_VERIFICATION", "true");
173
175
}
174
176
let sdk_config = aws_config::defaults(BehaviorVersion::latest())
175
177
.region("us-east-1")
+1
-1
tests/delete_account.rs
+1
-1
tests/delete_account.rs
···
228
228
.send()
229
229
.await
230
230
.expect("Failed to send delete request");
231
-
assert_eq!(delete_res.status(), StatusCode::UNAUTHORIZED);
231
+
assert_eq!(delete_res.status(), StatusCode::BAD_REQUEST);
232
232
let body: Value = delete_res.json().await.unwrap();
233
233
assert_eq!(body["error"], "ExpiredToken");
234
234
}
+15
-5
tests/import_verification.rs
+15
-5
tests/import_verification.rs
···
156
156
.send()
157
157
.await
158
158
.expect("Failed to import repo");
159
-
assert_eq!(import_res.status(), StatusCode::OK);
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
+
}
160
167
}
161
168
162
169
#[tokio::test]
···
285
292
async fn test_import_preserves_records_after_reimport() {
286
293
let client = client();
287
294
let (token, did) = create_account_and_login(&client).await;
288
-
let mut rkeys = Vec::new();
295
+
let mut rkeys = Vec::with_capacity(3);
289
296
for i in 0..3 {
290
297
let post_payload = json!({
291
298
"repo": did,
···
309
316
assert_eq!(res.status(), StatusCode::OK);
310
317
let body: serde_json::Value = res.json().await.unwrap();
311
318
let uri = body["uri"].as_str().unwrap();
312
-
let rkey = uri.split('/').next_back().unwrap().to_string();
313
-
rkeys.push(rkey);
319
+
rkeys.push(uri.split('/').next_back().unwrap().to_string());
314
320
}
315
321
for rkey in &rkeys {
316
322
let get_res = client
···
352
358
.send()
353
359
.await
354
360
.expect("Failed to import repo");
355
-
assert_eq!(import_res.status(), StatusCode::OK);
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
+
}
356
366
let list_res = client
357
367
.get(format!(
358
368
"{}/xrpc/com.atproto.repo.listRecords?repo={}&collection=app.bsky.feed.post",
+1
-1
tests/password_reset.rs
+1
-1
tests/password_reset.rs
···
241
241
.send()
242
242
.await
243
243
.expect("Failed to reset password");
244
-
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
244
+
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
245
245
let body: Value = res.json().await.expect("Invalid JSON");
246
246
assert_eq!(body["error"], "ExpiredToken");
247
247
}