mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-31 16:26:30 +00:00
Merge pull request #4920 from alexj11324/feat/passkey-auth
feat: add passkey authentication
This commit is contained in:
@@ -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"
|
||||
@@ -8699,6 +8755,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,
|
||||
@@ -10212,6 +10272,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 拒绝整次保存;
|
||||
|
||||
@@ -362,6 +362,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: [],
|
||||
@@ -722,6 +726,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,
|
||||
|
||||
@@ -131,7 +131,7 @@
|
||||
@open="showAgreementModal = true"
|
||||
/>
|
||||
|
||||
<div v-if="showOAuthLogin" class="space-y-3 pt-1">
|
||||
<div v-if="showPasskeyLogin || showOAuthLogin" class="space-y-3 pt-1">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
||||
<span class="text-xs text-gray-500 dark:text-dark-400">
|
||||
@@ -140,6 +140,17 @@
|
||||
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="showPasskeyLogin"
|
||||
type="button"
|
||||
class="btn btn-secondary w-full"
|
||||
:disabled="authActionDisabled"
|
||||
@click="handlePasskeyLogin"
|
||||
>
|
||||
<Icon name="key" size="md" class="mr-2" />
|
||||
{{ passkeyLoading ? t('auth.passkeySigningIn') : t('auth.passkeySignIn') }}
|
||||
</button>
|
||||
|
||||
<EmailOAuthButtons
|
||||
:disabled="authActionDisabled"
|
||||
:github-enabled="githubOAuthEnabled"
|
||||
@@ -229,6 +240,7 @@ const appStore = useAppStore()
|
||||
// ==================== State ====================
|
||||
|
||||
const isLoading = ref<boolean>(false)
|
||||
const passkeyLoading = ref<boolean>(false)
|
||||
const errorMessage = ref<string>('')
|
||||
const showPassword = ref<boolean>(false)
|
||||
const publicSettingsLoaded = ref<boolean>(false)
|
||||
@@ -245,6 +257,7 @@ const oidcOAuthProviderName = ref<string>('OIDC')
|
||||
const githubOAuthEnabled = ref<boolean>(false)
|
||||
const googleOAuthEnabled = ref<boolean>(false)
|
||||
const passwordResetEnabled = ref<boolean>(false)
|
||||
const passkeyEnabled = ref<boolean>(false)
|
||||
const loginAgreementEnabled = ref<boolean>(false)
|
||||
const loginAgreementMode = ref<'modal' | 'checkbox' | string>('modal')
|
||||
const loginAgreementUpdatedAt = ref<string>('')
|
||||
@@ -283,7 +296,11 @@ const agreementGateActive = computed(
|
||||
)
|
||||
|
||||
const authActionDisabled = computed(
|
||||
() => isLoading.value || !publicSettingsLoaded.value || agreementGateActive.value
|
||||
() => isLoading.value || passkeyLoading.value || !publicSettingsLoaded.value || agreementGateActive.value
|
||||
)
|
||||
|
||||
const showPasskeyLogin = computed(
|
||||
() => passkeyEnabled.value && typeof window.PublicKeyCredential !== 'undefined'
|
||||
)
|
||||
|
||||
const showOAuthLogin = computed(
|
||||
@@ -328,6 +345,7 @@ onMounted(async () => {
|
||||
googleOAuthEnabled.value = settings.google_oauth_enabled
|
||||
backendModeEnabled.value = settings.backend_mode_enabled
|
||||
passwordResetEnabled.value = settings.password_reset_enabled
|
||||
passkeyEnabled.value = settings.passkey_enabled === true
|
||||
applyLoginAgreementSettings(settings)
|
||||
} catch (error) {
|
||||
console.error('Failed to load public settings:', error)
|
||||
@@ -516,6 +534,33 @@ async function handleLogin(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePasskeyLogin(): Promise<void> {
|
||||
if (agreementGateActive.value) {
|
||||
appStore.showWarning(t('legal.loginAgreementPrompt.loginRequiredWarning'))
|
||||
if (loginAgreementMode.value !== 'checkbox') {
|
||||
showAgreementModal.value = true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
passkeyLoading.value = true
|
||||
try {
|
||||
await authStore.loginWithPasskey()
|
||||
clearAllAffiliateReferralCodes()
|
||||
appStore.showSuccess(t('auth.loginSuccess'))
|
||||
const redirectTo = (router.currentRoute.value.query.redirect as string) || '/dashboard'
|
||||
await router.push(redirectTo)
|
||||
} catch (error: unknown) {
|
||||
const fallback = error instanceof DOMException && error.name === 'NotAllowedError'
|
||||
? t('auth.passkeyCancelled')
|
||||
: t('auth.passkeyFailed')
|
||||
errorMessage.value = extractI18nErrorMessage(error, t, 'auth.errors', fallback)
|
||||
appStore.showError(errorMessage.value)
|
||||
} finally {
|
||||
passkeyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 2FA Handlers ====================
|
||||
|
||||
async function handle2FAVerify(code: string): Promise<void> {
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
/>
|
||||
|
||||
<ProfileTotpCard />
|
||||
<ProfilePasskeyCard :enabled="passkeyEnabled" />
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -57,6 +58,7 @@ import ProfileBalanceNotifyCard from '@/components/user/profile/ProfileBalanceNo
|
||||
import ProfileInfoCard from '@/components/user/profile/ProfileInfoCard.vue'
|
||||
import ProfilePasswordForm from '@/components/user/profile/ProfilePasswordForm.vue'
|
||||
import ProfileTotpCard from '@/components/user/profile/ProfileTotpCard.vue'
|
||||
import ProfilePasskeyCard from '@/components/user/profile/ProfilePasskeyCard.vue'
|
||||
import { isWeChatWebOAuthEnabled } from '@/api/auth'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
@@ -76,6 +78,7 @@ const wechatOAuthOpenEnabled = ref<boolean | undefined>(undefined)
|
||||
const wechatOAuthMPEnabled = ref<boolean | undefined>(undefined)
|
||||
const oidcOAuthEnabled = ref(false)
|
||||
const oidcOAuthProviderName = ref('OIDC')
|
||||
const passkeyEnabled = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
const profileRefresh = authStore.refreshUser().catch((error) => {
|
||||
@@ -101,6 +104,7 @@ onMounted(async () => {
|
||||
: undefined
|
||||
oidcOAuthEnabled.value = settings.oidc_oauth_enabled ?? false
|
||||
oidcOAuthProviderName.value = settings.oidc_oauth_provider_name || 'OIDC'
|
||||
passkeyEnabled.value = settings.passkey_enabled === true
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load settings:', error)
|
||||
|
||||
Reference in New Issue
Block a user