mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-30 08:22:34 +00:00
feat: add passkey sign-in settings control
This commit is contained in:
@@ -366,6 +366,10 @@ export interface SystemSettings {
|
||||
invitation_code_enabled: boolean;
|
||||
totp_enabled: boolean; // TOTP 双因素认证
|
||||
totp_encryption_key_configured: boolean; // TOTP 加密密钥是否已配置
|
||||
passkey_enabled: boolean;
|
||||
passkey_configured: boolean;
|
||||
passkey_rp_id: string;
|
||||
passkey_rp_origins: string[];
|
||||
session_binding_enabled: boolean; // 会话 IP/UA 绑定
|
||||
step_up_enabled: boolean; // 敏感操作 step-up 2FA
|
||||
audit_log_retention_days: number; // 审计日志保留天数
|
||||
@@ -674,6 +678,7 @@ export interface UpdateSettingsRequest {
|
||||
frontend_url?: string;
|
||||
invitation_code_enabled?: boolean;
|
||||
totp_enabled?: boolean; // TOTP 双因素认证
|
||||
passkey_enabled?: boolean;
|
||||
session_binding_enabled?: boolean; // 会话 IP/UA 绑定
|
||||
step_up_enabled?: boolean; // 敏感操作 step-up 2FA
|
||||
audit_log_retention_days?: number; // 审计日志保留天数
|
||||
|
||||
@@ -21,15 +21,15 @@
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-6">
|
||||
<div v-if="!enabled" class="text-sm text-gray-500 dark:text-gray-400">
|
||||
<div v-if="!enabled" class="mb-5 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('profile.passkey.featureDisabled') }}
|
||||
</div>
|
||||
<div v-else-if="!supported" class="text-sm text-amber-600 dark:text-amber-400">
|
||||
<div v-if="enabled && !supported" class="mb-5 text-sm text-amber-600 dark:text-amber-400">
|
||||
{{ t('profile.passkey.unsupported') }}
|
||||
</div>
|
||||
<div v-else>
|
||||
<div>
|
||||
<form
|
||||
v-if="showAddForm"
|
||||
v-if="enabled && supported && showAddForm"
|
||||
class="mb-5 flex flex-col gap-3 rounded-lg border border-gray-200 p-4 dark:border-dark-700 sm:flex-row sm:items-end"
|
||||
@submit.prevent="addPasskey"
|
||||
>
|
||||
@@ -135,7 +135,6 @@ const newName = ref('')
|
||||
const credentials = ref<PasskeyCredentialSummary[]>([])
|
||||
|
||||
async function loadCredentials(): Promise<void> {
|
||||
if (!supported) return
|
||||
loading.value = true
|
||||
try {
|
||||
credentials.value = await passkeyAPI.list()
|
||||
@@ -209,8 +208,8 @@ function formatDate(value: string): string {
|
||||
|
||||
watch(
|
||||
() => props.enabled,
|
||||
(enabled) => {
|
||||
if (enabled) void loadCredentials()
|
||||
() => {
|
||||
void loadCredentials()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
@@ -127,6 +127,13 @@ export default {
|
||||
'Please configure TOTP_ENCRYPTION_KEY in environment variables first. Generate a key with: openssl rand -hex 32'
|
||||
},
|
||||
security: {
|
||||
passkey: 'Passkey Sign-in',
|
||||
passkeyHint: 'Allow passwordless sign-in and user-managed passkeys when the relying party configuration is valid.',
|
||||
passkeyConfigured: 'WebAuthn relying party configuration is valid.',
|
||||
passkeyNotConfigured: 'Configure a valid RP ID and allowed HTTPS origins before enabling passkey sign-in.',
|
||||
passkeyRPID: 'RP ID',
|
||||
passkeyOrigins: 'Allowed HTTPS origins',
|
||||
passkeyValueNotConfigured: 'Not configured',
|
||||
stepUp: 'Step-up 2FA for Sensitive Operations',
|
||||
stepUpHint: 'When enabled, sensitive operations (account/proxy export, backup creation and download, S3 config changes, promoting admins) require a recent TOTP verification (valid for 15 minutes). Your own account must have 2FA enabled before turning this on; turning it off also requires step-up verification.',
|
||||
stepUpEnableRequiresTotp: 'Enable 2FA (TOTP) for your own account in Profile before turning on step-up verification.',
|
||||
|
||||
@@ -127,6 +127,13 @@ export default {
|
||||
'请先在环境变量中配置 TOTP_ENCRYPTION_KEY。使用命令 openssl rand -hex 32 生成密钥。'
|
||||
},
|
||||
security: {
|
||||
passkey: 'Passkey 登录',
|
||||
passkeyHint: '当依赖方配置有效时,允许无密码登录及用户自行管理 Passkey。',
|
||||
passkeyConfigured: 'WebAuthn 依赖方配置有效。',
|
||||
passkeyNotConfigured: '请先配置有效的 RP ID 与允许的 HTTPS 来源,再启用 Passkey 登录。',
|
||||
passkeyRPID: 'RP ID',
|
||||
passkeyOrigins: '允许的 HTTPS 来源',
|
||||
passkeyValueNotConfigured: '未配置',
|
||||
stepUp: '敏感操作二次验证 (step-up 2FA)',
|
||||
stepUpHint: '开启后,账号/代理导出、备份创建与下载、S3 配置修改、提升管理员等敏感操作需要先完成 TOTP 二次验证(15 分钟内有效)。开启前需本人已启用 2FA;关闭该开关本身也需要二次验证。',
|
||||
stepUpEnableRequiresTotp: '开启敏感操作二次验证前,请先在个人资料中为当前账号启用 2FA (TOTP)。',
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user