feat: add passkey sign-in settings control

This commit is contained in:
Zhixuan Jiang
2026-07-26 11:07:12 -04:00
parent 4158e73b3f
commit 357c5b917b
19 changed files with 343 additions and 12 deletions
+61
View File
@@ -1578,6 +1578,62 @@
/>
</div>
<!-- Passkey sign-in -->
<div
class="border-t border-gray-100 pt-4 dark:border-dark-700"
data-testid="passkey-settings"
>
<div class="flex items-start justify-between gap-4">
<div>
<label class="font-medium text-gray-900 dark:text-white">{{
t("admin.settings.security.passkey")
}}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.security.passkeyHint") }}
</p>
</div>
<Toggle
v-model="form.passkey_enabled"
data-testid="passkey-toggle"
:disabled="!form.passkey_configured"
/>
</div>
<div
class="mt-3 rounded-lg border px-3 py-2 text-sm"
:class="
form.passkey_configured
? 'border-green-200 bg-green-50 text-green-800 dark:border-green-900 dark:bg-green-950/40 dark:text-green-300'
: 'border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-300'
"
data-testid="passkey-config-status"
>
<p class="font-medium">
{{
form.passkey_configured
? t("admin.settings.security.passkeyConfigured")
: t("admin.settings.security.passkeyNotConfigured")
}}
</p>
<p class="mt-1 break-all">
{{ t("admin.settings.security.passkeyRPID") }}:
{{
form.passkey_rp_id ||
t("admin.settings.security.passkeyValueNotConfigured")
}}
</p>
<p class="mt-1 break-all">
{{ t("admin.settings.security.passkeyOrigins") }}:
{{
form.passkey_rp_origins.length > 0
? form.passkey_rp_origins.join(", ")
: t(
"admin.settings.security.passkeyValueNotConfigured",
)
}}
</p>
</div>
</div>
<!-- 敏感操作 step-up 2FA -->
<div
class="flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700"
@@ -8497,6 +8553,10 @@ const form = reactive<SettingsForm>({
password_reset_enabled: false,
totp_enabled: false,
totp_encryption_key_configured: false,
passkey_enabled: false,
passkey_configured: false,
passkey_rp_id: "",
passkey_rp_origins: [],
session_binding_enabled: false,
step_up_enabled: false,
audit_log_retention_days: 180,
@@ -10010,6 +10070,7 @@ async function saveSettings() {
invitation_code_enabled: form.invitation_code_enabled,
password_reset_enabled: form.password_reset_enabled,
totp_enabled: form.totp_enabled,
passkey_enabled: form.passkey_enabled,
session_binding_enabled: form.session_binding_enabled,
step_up_enabled: form.step_up_enabled,
// 清空数字框时 v-model.number 会得到空串,后端 int 字段解析空串会 400 拒绝整次保存;
@@ -350,6 +350,10 @@ const baseSettingsResponse = {
password_reset_enabled: false,
totp_enabled: false,
totp_encryption_key_configured: false,
passkey_enabled: true,
passkey_configured: true,
passkey_rp_id: "sub3.nebula-spaces.com",
passkey_rp_origins: ["https://sub3.nebula-spaces.com"],
default_balance: 0,
default_concurrency: 1,
default_subscriptions: [],
@@ -672,6 +676,47 @@ describe("admin SettingsView payment visible method controls", () => {
expect(wrapper.text()).not.toContain("支付来源");
});
it("shows valid passkey RP configuration and persists the sign-in toggle", async () => {
const wrapper = mountView();
await flushPromises();
await openSecurityTab(wrapper);
const settings = wrapper.get('[data-testid="passkey-settings"]');
const toggle = settings.get('[data-testid="passkey-toggle"]');
expect(toggle.attributes("disabled")).toBeUndefined();
expect(settings.text()).toContain("sub3.nebula-spaces.com");
expect(settings.text()).toContain("https://sub3.nebula-spaces.com");
await toggle.setValue(false);
await wrapper.find("form").trigger("submit.prevent");
await flushPromises();
expect(updateSettings).toHaveBeenCalledWith(
expect.objectContaining({ passkey_enabled: false }),
);
});
it("disables passkey sign-in when the RP configuration is unavailable", async () => {
getSettings.mockResolvedValueOnce({
...baseSettingsResponse,
passkey_enabled: false,
passkey_configured: false,
passkey_rp_id: "",
passkey_rp_origins: [],
});
const wrapper = mountView();
await flushPromises();
await openSecurityTab(wrapper);
const settings = wrapper.get('[data-testid="passkey-settings"]');
expect(settings.get('[data-testid="passkey-toggle"]').attributes("disabled")).toBeDefined();
expect(settings.get('[data-testid="passkey-config-status"]').text()).toContain(
"admin.settings.security.passkeyNotConfigured",
);
});
it("loads, edits, validates, and saves forwarded client-IP headers", async () => {
getSettings.mockResolvedValueOnce({
...baseSettingsResponse,