上传文件至 /
指纹验证
This commit is contained in:
parent
44558e78a7
commit
6ff9249dc1
699
BiometricEnrollCard.vue
Normal file
699
BiometricEnrollCard.vue
Normal file
@ -0,0 +1,699 @@
|
||||
<template>
|
||||
<!-- ============================================================
|
||||
Biometric login enrollment card
|
||||
Shown on MyProfile.vue below the personal-details card.
|
||||
Two related toggles:
|
||||
1. Biometric login — enrolls / removes this device's
|
||||
fingerprint / face / device PIN for signing in.
|
||||
2. Biometric for epayslip / taxation — once biometric login
|
||||
is enabled, lets the user skip re-typing the epayslip
|
||||
password on the enquiry pages. Requires the user to prove
|
||||
they know the epayslip password at enable-time.
|
||||
|
||||
The "credential id" of the WebAuthn registration is stashed in
|
||||
localStorage under `biometric_<username>` so the login page can
|
||||
show the "Sign in with fingerprint" button.
|
||||
|
||||
The card hides itself entirely on devices that have a browser
|
||||
but no platform authenticator (e.g. a desktop without Windows
|
||||
Hello / Touch ID configured).
|
||||
============================================================ -->
|
||||
<section
|
||||
v-if="showCard"
|
||||
class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft sm:p-6"
|
||||
>
|
||||
<header class="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wider text-slate-500">
|
||||
{{ t('MyProfileView.biometric_login') || 'Biometric login' }}
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-slate-400">
|
||||
{{ statusText }}
|
||||
</p>
|
||||
</div>
|
||||
<a-spin v-if="enrolling || removing" :spinning="true" size="small" />
|
||||
</header>
|
||||
|
||||
<div v-if="!isSupported" class="rounded-lg bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
{{ t('MyProfileView.biometric_not_supported') || 'This browser does not support biometric login. Try the latest Chrome, Edge, Safari, or Firefox.' }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="hasPlatformAuthenticator === false" class="rounded-lg bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
<p class="font-medium">{{ t('MyProfileView.biometric_no_authenticator') || 'No biometric authenticator on this device' }}</p>
|
||||
<p class="mt-1 text-xs text-amber-700">
|
||||
{{ t('MyProfileView.biometric_no_authenticator_detail') || 'Enable Windows Hello (Settings → Accounts → Sign-in options), Touch ID, or a device PIN, then refresh this page.' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="hasPlatformAuthenticator === null" class="rounded-lg bg-slate-50 px-4 py-3 text-sm text-slate-500">
|
||||
{{ t('MyProfileView.biometric_checking') || 'Checking biometric support…' }}
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="text-sm text-slate-700">
|
||||
{{ enrolled
|
||||
? (t('MyProfileView.biometric_enrolled_with') || `Enrolled on ${enrolledDeviceName || 'this device'}`)
|
||||
: (t('MyProfileView.biometric_use_prompt') || 'Sign in with fingerprint, face, or device PIN on this device.') }}
|
||||
</p>
|
||||
<p v-if="enrolled && enrolledAt" class="text-xs text-slate-400">
|
||||
{{ t('MyProfileView.biometric_enrolled_on') || 'Enrolled on' }} {{ formatDate(enrolledAt) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<a-switch
|
||||
:checked="enrolled"
|
||||
:loading="enrolling || removing"
|
||||
@change="onToggle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Second toggle — biometric for epayslip / taxation.
|
||||
Only visible when biometric login itself is enrolled (the local
|
||||
`enrolled` state is set from localStorage on mount, so the second
|
||||
toggle appears as soon as the user has a stored credential id).
|
||||
The server-side guard in EnableBiometricForEpayslip also re-checks
|
||||
that biometric_credential_id is not null. -->
|
||||
<div
|
||||
v-if="enrolled"
|
||||
class="mt-4 flex flex-col gap-3 border-t border-slate-100 pt-4 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="text-sm text-slate-700">
|
||||
{{ t('MyProfileView.biometric_for_epayslip_prompt') || 'Use biometric for epayslip / taxation enquiry' }}
|
||||
</p>
|
||||
<p class="text-xs text-slate-400">
|
||||
{{
|
||||
biometricForEpayslip
|
||||
? (t('MyProfileView.biometric_for_epayslip_enabled') || 'You can open epayslip / taxation with fingerprint or face.')
|
||||
: (t('MyProfileView.biometric_for_epayslip_disabled') || 'You will need to type your epayslip password each time.')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<a-spin v-if="epayslipSaving" :spinning="true" size="small" />
|
||||
<a-switch
|
||||
v-else
|
||||
:checked="biometricForEpayslip"
|
||||
:disabled="epayslipSaving"
|
||||
@change="onEpayslipToggle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirm-remove modal — kept inline for compactness -->
|
||||
<a-modal
|
||||
v-model:open="confirmRemoveOpen"
|
||||
:title="t('MyProfileView.biometric_remove_title') || 'Remove biometric login?'"
|
||||
:ok-text="t('buttons.remove') || 'Remove'"
|
||||
:ok-type="'danger'"
|
||||
:cancel-text="t('buttons.cancel') || 'Cancel'"
|
||||
@ok="onConfirmRemove"
|
||||
>
|
||||
<p>{{ t('MyProfileView.biometric_remove_body') || 'You will need to type your password to sign in next time. You can re-enroll anytime.' }}</p>
|
||||
</a-modal>
|
||||
|
||||
<!-- Password re-verification modal — shown when the user toggles
|
||||
biometric login ON. Requires the user to prove they know the
|
||||
account password before WebAuthn enrollment starts. Closes
|
||||
silently on Cancel / X / ESC; clears `passwordInput` via the
|
||||
watcher when closed. -->
|
||||
<a-modal
|
||||
v-model:open="confirmPasswordOpen"
|
||||
:title="t('MyProfileView.biometric_enable_title') || 'Verify your password'"
|
||||
:ok-text="t('buttons.confirm') || 'Confirm'"
|
||||
:cancel-text="t('buttons.cancel') || 'Cancel'"
|
||||
:confirm-loading="verifying"
|
||||
:ok-button-props="{ disabled: !passwordInput }"
|
||||
@ok="onConfirmPassword"
|
||||
>
|
||||
<p class="mb-4 text-sm text-slate-600">
|
||||
{{ t('MyProfileView.biometric_enable_prompt') || 'Enter your password to enable biometric login on this device. This prevents someone from enrolling their fingerprint on a stolen or unattended device without your knowledge.' }}
|
||||
</p>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item
|
||||
:label="t('LoginView.password') || 'Password'"
|
||||
:rules="[{ required: true, message: t('LoginView.pls_input_password') || 'Please enter your password' }]"
|
||||
>
|
||||
<a-input-password
|
||||
v-model:value="passwordInput"
|
||||
:placeholder="t('LoginView.password') || 'Password'"
|
||||
size="large"
|
||||
@press-enter="onConfirmPassword"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- Epayslip password modal — shown when the user enables
|
||||
"biometric for epayslip". Re-uses passwordInput state.
|
||||
Disable flow needs no password (the user is just opting out
|
||||
of a convenience), so it calls disableBiometricForEpayslip
|
||||
directly. -->
|
||||
<a-modal
|
||||
v-model:open="epayslipPasswordOpen"
|
||||
:title="t('MyProfileView.biometric_for_epayslip_enable_title') || 'Verify your epayslip password'"
|
||||
:ok-text="t('buttons.confirm') || 'Confirm'"
|
||||
:cancel-text="t('buttons.cancel') || 'Cancel'"
|
||||
:confirm-loading="epayslipSaving"
|
||||
:ok-button-props="{
|
||||
type: 'primary',
|
||||
disabled: !passwordInput,
|
||||
style: { backgroundColor: '#1677ff', borderColor: '#1677ff', color: '#ffffff' }
|
||||
}"
|
||||
@ok="onConfirmEpayslipPassword"
|
||||
>
|
||||
<p class="mb-4 text-sm text-slate-600">
|
||||
{{ t('MyProfileView.biometric_for_epayslip_password_prompt') || 'Enter your epayslip password to confirm. After this you can open epayslip / taxation with fingerprint or face instead of typing it again.' }}
|
||||
</p>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item
|
||||
:label="t('MyProfileView.epayslip_password') || 'Epayslip password'"
|
||||
:rules="[{ required: true, message: t('LoginView.pls_input_password') || 'Please enter your password' }]"
|
||||
>
|
||||
<a-input-password
|
||||
v-model:value="passwordInput"
|
||||
:placeholder="t('MyProfileView.epayslip_password') || 'Epayslip password'"
|
||||
size="large"
|
||||
@press-enter="onConfirmEpayslipPassword"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { startRegistration } from '@simplewebauthn/browser'
|
||||
import { notification, message } from 'ant-design-vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import userProfileService from '@/service/UserProfileService'
|
||||
|
||||
const props = defineProps<{
|
||||
/** HR+ username — used as the localStorage key prefix. */
|
||||
username: string
|
||||
/** Current value of security_user.biometric_for_epayslip — drives the
|
||||
* switch's checked state and the helper-text copy. */
|
||||
biometricForEpayslipInitial?: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// ===== State =====
|
||||
const enrolling = ref(false)
|
||||
const removing = ref(false)
|
||||
const enrolled = ref(false)
|
||||
const enrolledDeviceName = ref<string | null>(null)
|
||||
const enrolledAt = ref<Date | null>(null)
|
||||
const confirmRemoveOpen = ref(false)
|
||||
// Password re-verification before enabling biometric login. This prevents
|
||||
// someone from enrolling their fingerprint on a stolen / unattended device
|
||||
// without the user explicitly proving they know the account password.
|
||||
// confirmPasswordOpen — controls the modal visibility
|
||||
// passwordInput — bound to the password input field
|
||||
// verifying — disables the OK button + shows a spinner while
|
||||
// useLogin is in flight
|
||||
const confirmPasswordOpen = ref(false)
|
||||
const passwordInput = ref('')
|
||||
const verifying = ref(false)
|
||||
|
||||
// ===== Epayslip / taxation biometric sub-toggle =====
|
||||
// Mirrors security_user.biometric_for_epayslip. The initial value is
|
||||
// passed in as a prop from MyProfile (which reads it from
|
||||
// UserProfileDisplayData); we keep a local copy so the switch can
|
||||
// optimistically flip when the user toggles, then commit to the server.
|
||||
const biometricForEpayslip = ref(!!props.biometricForEpayslipInitial)
|
||||
const epayslipSaving = ref(false)
|
||||
const epayslipPasswordOpen = ref(false)
|
||||
|
||||
// Clear the password field whenever either modal closes (cancel, X, OK, ESC,
|
||||
// or backdrop click). Otherwise the next open would pre-fill with the
|
||||
// previous value.
|
||||
watch(confirmPasswordOpen, (open) => {
|
||||
if (!open) passwordInput.value = ''
|
||||
})
|
||||
watch(epayslipPasswordOpen, (open) => {
|
||||
if (!open) passwordInput.value = ''
|
||||
})
|
||||
|
||||
const isSupported = computed(() =>
|
||||
typeof window !== 'undefined' &&
|
||||
typeof window.PublicKeyCredential === 'function'
|
||||
)
|
||||
|
||||
// Tri-state probe for the device's biometric capability.
|
||||
// null → still loading (probe in flight or not started)
|
||||
// true → the device has a user-verifying platform authenticator
|
||||
// false → the browser supports WebAuthn but no biometric is available
|
||||
//
|
||||
// isUserVerifyingPlatformAuthenticatorAvailable() is the W3C-standard
|
||||
// check: it returns true only when the device has a biometric / PIN
|
||||
// authenticator the browser can use to satisfy "user verification".
|
||||
// Older / non-biometric devices return false, which means we should
|
||||
// hide the toggle entirely instead of letting the user click into
|
||||
// a failing flow.
|
||||
const hasPlatformAuthenticator = ref<boolean | null>(
|
||||
isSupported.value ? null : false,
|
||||
)
|
||||
|
||||
if (isSupported.value && typeof PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable === 'function') {
|
||||
PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
|
||||
.then((available) => {
|
||||
hasPlatformAuthenticator.value = !!available
|
||||
})
|
||||
.catch(() => {
|
||||
// Probe can fail on locked-down browsers. Default to "show the
|
||||
// toggle" — the call-time catch in enroll() will still surface
|
||||
// a friendly error if the user actually tries.
|
||||
hasPlatformAuthenticator.value = true
|
||||
})
|
||||
}
|
||||
|
||||
// Only show the card if either:
|
||||
// - the device DOES have a platform authenticator, or
|
||||
// - the user has already enrolled on this device (so we need the
|
||||
// card to render the "remove" toggle even if the current
|
||||
// authenticator is no longer available, e.g. the user replaced
|
||||
// their fingerprint scanner but the credential still works).
|
||||
const showCard = computed(() => {
|
||||
if (!isSupported.value) return false
|
||||
if (enrolled.value) return true
|
||||
return hasPlatformAuthenticator.value === true
|
||||
})
|
||||
|
||||
// localStorage key — the login page reads this same key to decide
|
||||
// whether to show the fingerprint button.
|
||||
const storageKey = computed(() => `biometric_${props.username}`)
|
||||
|
||||
// Best-effort error message extraction. Handles three error shapes:
|
||||
// 1. Axios: error.response.data is the server body (string or object)
|
||||
// 2. WebAuthn/JS: err is a DOMException / SyntaxError with .name + .message
|
||||
// 3. Plain string thrown by our own service
|
||||
// Returns a single non-empty human-readable string.
|
||||
const normalizeError = (err: any): string => {
|
||||
if (err == null) return 'Unknown error'
|
||||
if (typeof err === 'string') return err
|
||||
// Axios error: prefer the server's body when present
|
||||
if (err.response?.data != null) {
|
||||
const data = err.response.data
|
||||
if (typeof data === 'string') return data
|
||||
if (typeof data === 'object') {
|
||||
// Common ASP.NET ValidationException shape: { message, ... } or
|
||||
// a string that came from WebMessages.GetString.
|
||||
if (typeof data.message === 'string' && data.message) return data.message
|
||||
if (typeof data.error === 'string' && data.error) return data.error
|
||||
if (typeof data.title === 'string' && data.title) return data.title
|
||||
// Last resort: stringify the body. Try JSON first (avoids
|
||||
// "[object Object]" for plain objects) then fall back to String.
|
||||
try { return JSON.stringify(data) } catch { return String(data) }
|
||||
}
|
||||
}
|
||||
// WebAuthn / DOMException / Error
|
||||
if (err.name && err.message) return `${err.name}: ${err.message}`
|
||||
if (err.message) return err.message
|
||||
return String(err)
|
||||
}
|
||||
|
||||
// Coerce the BeginBiometricRegistration response into a
|
||||
// PublicKeyCredentialCreationOptionsJSON object that
|
||||
// @simplewebauthn/browser can consume.
|
||||
//
|
||||
// The C# method returns a JSON *string* (the output of Fido2NetLib's
|
||||
// options.ToJson()), which ASP.NET serializes as a JSON-encoded string.
|
||||
// axios's default transformResponse JSON.parses that, so resp.data is
|
||||
// normally a JS string. But we've seen this flow break in three ways:
|
||||
//
|
||||
// 1. resp.data is an empty string (server-side exception, the catch
|
||||
// logged it and rethrew, ASP.NET emitted a 500 with no body, or
|
||||
// a proxy stripped the body). Bare JSON.parse("") throws
|
||||
// "Unexpected end of JSON input" — the cryptic error the user
|
||||
// was seeing.
|
||||
// 2. resp.data is an object (axios mis-classified the response, or
|
||||
// the body was a JSON object like {message: "..."} with a 200).
|
||||
// JSON.parse({message: "..."}) throws
|
||||
// "Unexpected token o in JSON at position 1".
|
||||
// 3. resp.data is a valid object already (some intermediate layer
|
||||
// double-parsed). Just pass it through.
|
||||
//
|
||||
// This helper normalises all three cases and throws a readable error
|
||||
// for (1) and (2) so the user sees "the server returned …" instead of
|
||||
// a raw SyntaxError.
|
||||
const parseCreationOptions = (raw: unknown): any => {
|
||||
if (raw == null || raw === '') {
|
||||
throw new Error(
|
||||
'Server returned an empty response for BeginBiometricRegistration. ' +
|
||||
'The backend probably threw an exception. Check the API log.',
|
||||
)
|
||||
}
|
||||
if (typeof raw === 'object') {
|
||||
return raw
|
||||
}
|
||||
if (typeof raw === 'string') {
|
||||
try {
|
||||
return JSON.parse(raw)
|
||||
} catch (e: any) {
|
||||
// Truncate the raw value so we don't dump a giant HTML error page
|
||||
// into the notification toast.
|
||||
const preview = raw.length > 200 ? raw.slice(0, 200) + '…' : raw
|
||||
throw new Error(
|
||||
`Server returned invalid JSON for BeginBiometricRegistration: ` +
|
||||
`${e.message}. Raw response: ${preview}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`Unexpected BeginBiometricRegistration response type: ${typeof raw}`,
|
||||
)
|
||||
}
|
||||
|
||||
const statusText = computed(() => {
|
||||
if (!isSupported.value) return t('MyProfileView.biometric_unsupported') || 'Not supported in this browser'
|
||||
if (enrolled.value) return t('MyProfileView.biometric_enrolled') || 'Enabled on this device'
|
||||
return t('MyProfileView.biometric_disabled') || 'Disabled'
|
||||
})
|
||||
|
||||
// ===== Lifecycle =====
|
||||
onMounted(() => {
|
||||
if (!props.username) return
|
||||
try {
|
||||
const stored = localStorage.getItem(storageKey.value)
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored) as { deviceName?: string; enrolledAt?: string }
|
||||
enrolled.value = true
|
||||
enrolledDeviceName.value = parsed.deviceName ?? null
|
||||
enrolledAt.value = parsed.enrolledAt ? new Date(parsed.enrolledAt) : null
|
||||
}
|
||||
} catch {
|
||||
// corrupted localStorage entry — treat as not enrolled
|
||||
localStorage.removeItem(storageKey.value)
|
||||
}
|
||||
})
|
||||
|
||||
// ===== Actions =====
|
||||
const formatDate = (d: Date): string => d.toLocaleDateString()
|
||||
|
||||
const onToggle = async (checked: boolean | string | Event) => {
|
||||
const next = typeof checked === 'boolean' ? checked : !!checked
|
||||
if (next) {
|
||||
// Don't enroll straight away — require the user to re-enter their
|
||||
// account password first. This is the standard "step-up auth" pattern
|
||||
// for sensitive enrollment changes (FIDO2 / WebAuthn best practice
|
||||
// §6.1.1 — "user verification prior to credential creation").
|
||||
confirmPasswordOpen.value = true
|
||||
} else {
|
||||
confirmRemoveOpen.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the password by calling UserProfileProcess.ConfirmPassword
|
||||
// (auto-generated webmethod). The username is taken from the JWT on
|
||||
// the server side via loginCredential, not from the request body, so
|
||||
// the user can only verify their own password. No new session is
|
||||
// minted — the existing one is preserved.
|
||||
//
|
||||
// IMPORTANT: the backend returns `false` (not throws) on wrong password.
|
||||
// So we MUST check the boolean return value — only checking for thrown
|
||||
// errors (the catch block) would let a wrong password fall through to
|
||||
// the success path and start WebAuthn enrollment. That would defeat the
|
||||
// entire purpose of the step-up auth gate.
|
||||
const onConfirmPassword = async () => {
|
||||
if (verifying.value) return
|
||||
if (!passwordInput.value) {
|
||||
notification.warning({
|
||||
message: t('LoginView.pls_input_password') || 'Please enter your password',
|
||||
placement: 'topRight',
|
||||
})
|
||||
return
|
||||
}
|
||||
verifying.value = true
|
||||
const submitted = passwordInput.value
|
||||
try {
|
||||
const ok = await userProfileService.confirmPassword(submitted)
|
||||
if (!ok) {
|
||||
// Wrong password. Keep the dialog open so the user can retry.
|
||||
notification.error({
|
||||
message: t('MyProfileView.biometric_password_incorrect') || 'Incorrect password',
|
||||
description: t('MyProfileView.biometric_password_incorrect_detail') || 'Please re-enter your password to enable biometric login.',
|
||||
placement: 'topRight',
|
||||
})
|
||||
return
|
||||
}
|
||||
// Password is correct. Close the dialog and kick off WebAuthn
|
||||
// enrollment. Use the captured `submitted` value, not the cleared
|
||||
// `passwordInput` (the modal reset it on close).
|
||||
confirmPasswordOpen.value = false
|
||||
await enroll()
|
||||
} catch (err: any) {
|
||||
// Network / 500 / other transport errors. Wrong-password itself
|
||||
// does NOT throw — it returns false (handled above).
|
||||
notification.error({
|
||||
message: t('MyProfileView.biometric_password_check_failed') || 'Could not verify password',
|
||||
description: err?.message ?? String(err),
|
||||
placement: 'topRight',
|
||||
})
|
||||
} finally {
|
||||
verifying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle handler for the epayslip sub-switch. Just opens the password
|
||||
// dialog if the user wants to enable; if they want to disable, the
|
||||
// password is NOT required (disabling is a safety/no-consequence
|
||||
// action — the user can always re-enable with their password later).
|
||||
const onEpayslipToggle = async (checked: boolean | string | Event) => {
|
||||
const next = typeof checked === 'boolean' ? checked : !!checked
|
||||
if (next) {
|
||||
// Enable path: don't optimistically flip the switch — the user must
|
||||
// prove they know the epayslip password before the flag (and the
|
||||
// switch) is set. The switch only goes to `true` after
|
||||
// onConfirmEpayslipPassword succeeds. If the user cancels the
|
||||
// password modal (Cancel button, X, Esc, or backdrop click), the
|
||||
// switch stays OFF and the server flag is unchanged.
|
||||
epayslipPasswordOpen.value = true
|
||||
} else {
|
||||
// Disable path: no password needed.
|
||||
epayslipSaving.value = true
|
||||
try {
|
||||
await userProfileService.disableBiometricForEpayslip()
|
||||
biometricForEpayslip.value = false
|
||||
notification.success({
|
||||
message: t('MyProfileView.biometric_for_epayslip_disabled_toast') || 'Epayslip biometric disabled',
|
||||
placement: 'topRight',
|
||||
})
|
||||
} catch (err: any) {
|
||||
// Revert the optimistic flip on failure.
|
||||
biometricForEpayslip.value = true
|
||||
notification.error({
|
||||
message: t('MyProfileView.biometric_for_epayslip_disable_failed') || 'Could not disable epayslip biometric',
|
||||
description: err?.message ?? String(err),
|
||||
placement: 'topRight',
|
||||
})
|
||||
} finally {
|
||||
epayslipSaving.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// OK handler for the epayslip-password modal. Same boolean-check
|
||||
// pattern as onConfirmPassword — the backend returns true on success
|
||||
// and throws on wrong password, so we MUST inspect the return value.
|
||||
const onConfirmEpayslipPassword = async () => {
|
||||
if (epayslipSaving.value) return
|
||||
if (!passwordInput.value) {
|
||||
notification.warning({
|
||||
message: t('LoginView.pls_input_password') || 'Please enter your password',
|
||||
placement: 'topRight',
|
||||
})
|
||||
return
|
||||
}
|
||||
epayslipSaving.value = true
|
||||
const submitted = passwordInput.value
|
||||
try {
|
||||
const ok = await userProfileService.enableBiometricForEpayslip(submitted)
|
||||
if (!ok) {
|
||||
// Wrong password — keep dialog open, don't flip the switch.
|
||||
biometricForEpayslip.value = false
|
||||
notification.error({
|
||||
message: t('MyProfileView.biometric_for_epayslip_password_incorrect') || 'Incorrect epayslip password',
|
||||
description: t('MyProfileView.biometric_for_epayslip_password_incorrect_detail') || 'Please re-enter your epayslip password to enable biometric authentication.',
|
||||
placement: 'topRight',
|
||||
})
|
||||
return
|
||||
}
|
||||
// Success: close the dialog AND flip the switch on now that the
|
||||
// server has confirmed the flag. (We do NOT optimistically flip in
|
||||
// onEpayslipToggle so that cancelling the modal leaves the switch
|
||||
// in its previous off state — see that handler for details.)
|
||||
biometricForEpayslip.value = true
|
||||
epayslipPasswordOpen.value = false
|
||||
notification.success({
|
||||
message: t('MyProfileView.biometric_for_epayslip_enabled_toast') || 'Epayslip biometric enabled',
|
||||
placement: 'topRight',
|
||||
})
|
||||
} catch (err: any) {
|
||||
biometricForEpayslip.value = false
|
||||
notification.error({
|
||||
message: t('MyProfileView.biometric_for_epayslip_check_failed') || 'Could not verify epayslip password',
|
||||
description: err?.message ?? String(err),
|
||||
placement: 'topRight',
|
||||
})
|
||||
} finally {
|
||||
epayslipSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const enroll = async () => {
|
||||
if (enrolling.value) return
|
||||
enrolling.value = true
|
||||
try {
|
||||
// 1. Server returns the PublicKeyCredentialCreationOptions as a JSON
|
||||
// string (the auto-generated UserProfileService stub types it as
|
||||
// `Promise<string>` because the C# method returns `string`).
|
||||
// @simplewebauthn/browser@13 wants a parsed object, so parse it
|
||||
// here before handing it to startRegistration().
|
||||
//
|
||||
// Be defensive about the shape: the backend could legitimately
|
||||
// return the body as a JSON string (axios auto-parses to a JS
|
||||
// string), as an already-parsed object (if a proxy/CDN unwrapped
|
||||
// the JSON string), or as an error object (which axios rejects
|
||||
// on, but a 200 with an error payload would land here). Treat
|
||||
// all three cases and surface a clear error if parsing fails —
|
||||
// a bare `JSON.parse` on an empty string throws the cryptic
|
||||
// "Unexpected end of JSON input" the user was seeing.
|
||||
const raw = await userProfileService.beginBiometricRegistration(
|
||||
deriveDeviceName(),
|
||||
)
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[biometric] beginBiometricRegistration raw:', typeof raw, raw)
|
||||
|
||||
const optionsJSON = parseCreationOptions(raw)
|
||||
|
||||
// 2. Browser drives navigator.credentials.create(). @simplewebauthn/browser
|
||||
// validates the object and shows the OS prompt.
|
||||
const attestation = await startRegistration({ optionsJSON })
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[biometric] attestation received:', JSON.stringify({
|
||||
id: attestation.id,
|
||||
rawId: attestation.rawId,
|
||||
type: attestation.type,
|
||||
hasResponse: !!attestation.response,
|
||||
hasAttestationObject: !!attestation.response?.attestationObject,
|
||||
attestationObjectLen: attestation.response?.attestationObject?.length,
|
||||
hasClientDataJSON: !!attestation.response?.clientDataJSON,
|
||||
clientDataJSONLen: attestation.response?.clientDataJSON?.length,
|
||||
}))
|
||||
|
||||
// 3. Server verifies the attestation and persists the credential.
|
||||
// Use camelCase keys — that matches the TypeGen-generated
|
||||
// FinishBiometricRegistrationData class exactly, so TS type-checks
|
||||
// and ASP.NET's case-insensitive JSON binder maps to the C#
|
||||
// AttestationJson / CreationOptionsJson properties without 400s.
|
||||
const result = await userProfileService.finishBiometricRegistration({
|
||||
|
||||
attestationJson: JSON.stringify(attestation),
|
||||
// creationOptionsJson is no longer used server-side (it was
|
||||
// round-tripped to Fido2NetLib in v1; for v2+ the server can
|
||||
// re-derive the challenge from the session). We keep the field
|
||||
// shape intact for backwards compatibility with the v1 backend.
|
||||
creationOptionsJson: JSON.stringify(optionsJSON),
|
||||
})
|
||||
|
||||
// 4. Stash the credential id locally so the login page can use it
|
||||
// as allowCredentials[0].id in navigator.credentials.get().
|
||||
localStorage.setItem(
|
||||
storageKey.value,
|
||||
JSON.stringify({
|
||||
credentialId: result.credentialIdBase64Url,
|
||||
deviceName: deriveDeviceName(),
|
||||
enrolledAt: new Date().toISOString(),
|
||||
}),
|
||||
)
|
||||
|
||||
// 5. Remember the username separately so the login page can
|
||||
// auto-fill the field on next visit (if the user opens a fresh
|
||||
// tab and the credential id is the only thing in localStorage,
|
||||
// they still have to type the username before the fingerprint
|
||||
// button shows). Keyed by username so multiple enrolled
|
||||
// accounts on the same device don't clobber each other.
|
||||
localStorage.setItem('last_biometric_username', props.username)
|
||||
|
||||
enrolled.value = true
|
||||
enrolledDeviceName.value = deriveDeviceName()
|
||||
enrolledAt.value = new Date()
|
||||
notification.success({
|
||||
message: t('MyProfileView.biometric_enrolled_toast') || 'Biometric login enabled',
|
||||
placement: 'topRight',
|
||||
})
|
||||
} catch (err: any) {
|
||||
// @simplewebauthn/browser throws on user cancel, no authenticator,
|
||||
// or verification failure. Normalize the error to a readable string
|
||||
// (axios errors have a nested response.data; WebAuthn errors have
|
||||
// .name/.message). The previous "err?.message ?? String(err)" would
|
||||
// produce "[object Object]" when err is a structured axios error.
|
||||
const msg = normalizeError(err)
|
||||
if (/user cancelled|user canceled/i.test(msg)) {
|
||||
message.info(t('MyProfileView.biometric_cancelled') || 'Cancelled')
|
||||
} else if (/not allowed|security error|notallowed/i.test(msg)) {
|
||||
// Common on desktop without a biometric reader: the browser
|
||||
// has no platform authenticator. Show a friendlier message.
|
||||
notification.warning({
|
||||
message: t('MyProfileView.biometric_not_supported') || 'No biometric authenticator',
|
||||
description: t('MyProfileView.biometric_not_supported_detail') || 'This device has no fingerprint reader, face camera, or PIN configured. Try on a phone or laptop with Windows Hello / Touch ID.',
|
||||
placement: 'topRight',
|
||||
})
|
||||
} else {
|
||||
notification.error({
|
||||
message: t('MyProfileView.biometric_enroll_failed') || 'Could not enable biometric login',
|
||||
description: msg,
|
||||
placement: 'topRight',
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
enrolling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onConfirmRemove = async () => {
|
||||
if (removing.value) return
|
||||
removing.value = true
|
||||
try {
|
||||
await userProfileService.removeBiometric()
|
||||
localStorage.removeItem(storageKey.value)
|
||||
enrolled.value = false
|
||||
enrolledDeviceName.value = null
|
||||
enrolledAt.value = null
|
||||
notification.success({
|
||||
message: t('MyProfileView.biometric_removed') || 'Biometric login removed',
|
||||
placement: 'topRight',
|
||||
})
|
||||
} catch (err: any) {
|
||||
notification.error({
|
||||
message: t('MyProfileView.biometric_remove_failed') || 'Could not remove biometric login',
|
||||
description: err?.message ?? String(err),
|
||||
placement: 'topRight',
|
||||
})
|
||||
} finally {
|
||||
removing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort friendly name from the user agent. We use a coarse
|
||||
// platform detection — the user can override by re-enrolling on a
|
||||
// different device. The server-side column is the source of truth.
|
||||
const deriveDeviceName = (): string => {
|
||||
const ua = navigator.userAgent
|
||||
if (/iPhone/.test(ua)) return 'iPhone'
|
||||
if (/iPad/.test(ua)) return 'iPad'
|
||||
if (/Android/.test(ua)) {
|
||||
const m = ua.match(/Android[^;]+;\s*([^)]+)/)
|
||||
return m ? `Android (${m[1].trim()})` : 'Android device'
|
||||
}
|
||||
if (/Mac OS X/.test(ua)) return 'Mac'
|
||||
if (/Windows/.test(ua)) return 'Windows PC'
|
||||
if (/Linux/.test(ua)) return 'Linux'
|
||||
return 'Unnamed device'
|
||||
}
|
||||
</script>
|
||||
Loading…
Reference in New Issue
Block a user