Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fdd7cbd2f |
6
package-lock.json
generated
6
package-lock.json
generated
@ -9,6 +9,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons-vue": "^7.0.1",
|
"@ant-design/icons-vue": "^7.0.1",
|
||||||
|
"@simplewebauthn/browser": "^13.1.0",
|
||||||
"@types/crypto-js": "^4.2.2",
|
"@types/crypto-js": "^4.2.2",
|
||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"@types/node": "^20.12.11",
|
"@types/node": "^20.12.11",
|
||||||
@ -1173,6 +1174,11 @@
|
|||||||
"nanopop": "^2.1.0"
|
"nanopop": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@simplewebauthn/browser": {
|
||||||
|
"version": "13.3.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@simplewebauthn/browser/-/browser-13.3.0.tgz",
|
||||||
|
"integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ=="
|
||||||
|
},
|
||||||
"node_modules/@swc/core": {
|
"node_modules/@swc/core": {
|
||||||
"version": "1.5.5",
|
"version": "1.5.5",
|
||||||
"resolved": "https://registry.npmmirror.com/@swc/core/-/core-1.5.5.tgz",
|
"resolved": "https://registry.npmmirror.com/@swc/core/-/core-1.5.5.tgz",
|
||||||
|
|||||||
@ -36,7 +36,8 @@
|
|||||||
"vue-router": "^4.0.13",
|
"vue-router": "^4.0.13",
|
||||||
"vue3-photo-preview": "^0.3.0",
|
"vue3-photo-preview": "^0.3.0",
|
||||||
"vue3-video-play": "^1.3.1",
|
"vue3-video-play": "^1.3.1",
|
||||||
"wow.js": "^1.2.2"
|
"wow.js": "^1.2.2",
|
||||||
|
"@simplewebauthn/browser": "^13.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/howler": "^2.2.11",
|
"@types/howler": "^2.2.11",
|
||||||
|
|||||||
604
src/components/basic/BiometricEnrollCard.vue
Normal file
604
src/components/basic/BiometricEnrollCard.vue
Normal file
@ -0,0 +1,604 @@
|
|||||||
|
<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"
|
||||||
|
>
|
||||||
|
{{ "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"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
"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">
|
||||||
|
{{ "No biometric authenticator on this device" }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-amber-700">
|
||||||
|
{{
|
||||||
|
"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"
|
||||||
|
>
|
||||||
|
{{ "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
|
||||||
|
? `Enrolled on ${enrolledDeviceName || "this device"}`
|
||||||
|
: "Sign in with fingerprint, face, or device PIN on this device."
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<p v-if="enrolled && enrolledAt" class="text-xs text-slate-400">
|
||||||
|
{{ "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. -->
|
||||||
|
|
||||||
|
<!-- Confirm-remove modal — kept inline for compactness -->
|
||||||
|
<a-modal
|
||||||
|
v-model:open="confirmRemoveOpen"
|
||||||
|
:title="'Remove biometric login?'"
|
||||||
|
:ok-text="'Remove'"
|
||||||
|
:ok-type="'danger'"
|
||||||
|
:cancel-text="'Cancel'"
|
||||||
|
@ok="onConfirmRemove"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
{{
|
||||||
|
"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="'Verify your password'"
|
||||||
|
:ok-text="'Confirm'"
|
||||||
|
:cancel-text="'Cancel'"
|
||||||
|
:confirm-loading="verifying"
|
||||||
|
:ok-button-props="{ disabled: !passwordInput }"
|
||||||
|
@ok="onConfirmPassword"
|
||||||
|
>
|
||||||
|
<p class="mb-4 text-sm text-slate-600">
|
||||||
|
{{
|
||||||
|
"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="'Password'"
|
||||||
|
:rules="[{ required: true, message: 'Please enter your password' }]"
|
||||||
|
>
|
||||||
|
<a-input-password
|
||||||
|
v-model:value="passwordInput"
|
||||||
|
:placeholder="'Password'"
|
||||||
|
size="large"
|
||||||
|
@press-enter="onConfirmPassword"
|
||||||
|
/>
|
||||||
|
</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 enUS from "ant-design-vue/es/locale/en_US";
|
||||||
|
import zhCN from "ant-design-vue/es/locale/zh_CN";
|
||||||
|
import i18n from "../../locales";
|
||||||
|
import authService from "@/service/security/authService";
|
||||||
|
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import { JSEncrypt } from "jsencrypt";
|
||||||
|
import CryptoJS from "crypto-js";
|
||||||
|
import { FinishBiometricRegistrationData } from "@/data/auto/finishBiometricRegistrationData";
|
||||||
|
|
||||||
|
// import userProfileService from '@/service/UserProfileService'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** HR+ username — used as the localStorage key prefix. */
|
||||||
|
username: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { t } = i18n.global;
|
||||||
|
|
||||||
|
// ===== 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 epayslipSaving = 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 = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
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(() => {
|
||||||
|
// console.log(
|
||||||
|
// "showCard",
|
||||||
|
// isSupported.value,
|
||||||
|
// enrolled.value,
|
||||||
|
// hasPlatformAuthenticator.value,
|
||||||
|
// );
|
||||||
|
|
||||||
|
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 "Not supported in this browser";
|
||||||
|
if (enrolled.value) return "Enabled on this device";
|
||||||
|
return "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: "Please enter your password",
|
||||||
|
placement: "topRight",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
verifying.value = true;
|
||||||
|
let submitted = passwordInput.value;
|
||||||
|
try {
|
||||||
|
let key = await authService.GetRsaPublicKey();
|
||||||
|
|
||||||
|
let encrypt = new JSEncrypt();
|
||||||
|
encrypt.setPublicKey(key);
|
||||||
|
submitted=uuidv4() + CryptoJS.MD5(submitted);
|
||||||
|
|
||||||
|
submitted = encodeURIComponent(encrypt.encrypt(submitted).toString());
|
||||||
|
|
||||||
|
const ok = await authService.ConfirmPassword(submitted);
|
||||||
|
if (!ok) {
|
||||||
|
// Wrong password. Keep the dialog open so the user can retry.
|
||||||
|
notification.error({
|
||||||
|
message: "Incorrect password",
|
||||||
|
description: "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: "Could not verify password",
|
||||||
|
description: err?.message ?? String(err),
|
||||||
|
placement: "topRight",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
verifying.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 authService.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.
|
||||||
|
let fbrd: FinishBiometricRegistrationData=new FinishBiometricRegistrationData();
|
||||||
|
fbrd.AttestationJson = JSON.stringify(attestation);
|
||||||
|
fbrd.CreationOptionsJson = JSON.stringify(optionsJSON);
|
||||||
|
|
||||||
|
const result = await authService.FinishBiometricRegistration(fbrd)
|
||||||
|
// 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: '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( '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: 'No biometric authenticator',
|
||||||
|
description: '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: '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 authService.RemoveBiometric()
|
||||||
|
localStorage.removeItem(storageKey.value)
|
||||||
|
enrolled.value = false
|
||||||
|
enrolledDeviceName.value = null
|
||||||
|
enrolledAt.value = null
|
||||||
|
notification.success({
|
||||||
|
message: 'Biometric login removed',
|
||||||
|
placement: 'topRight',
|
||||||
|
})
|
||||||
|
} catch (err: any) {
|
||||||
|
notification.error({
|
||||||
|
message: 'Could not remove biometric login',
|
||||||
|
description: err?.message ?? String(err),
|
||||||
|
placement: 'topRight',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
removing.value = false
|
||||||
|
confirmRemoveOpen.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>
|
||||||
@ -1,6 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<a-layout class="layout" has-sider>
|
<a-layout class="layout" has-sider>
|
||||||
<a-layout-sider :collapsed-width="collapsedWidth" class="layout-sider" theme="light" :width="sideBarWidth" :style="{
|
<a-layout-sider
|
||||||
|
:collapsed-width="collapsedWidth"
|
||||||
|
class="layout-sider"
|
||||||
|
theme="light"
|
||||||
|
:width="sideBarWidth"
|
||||||
|
:style="{
|
||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
height: '100vh',
|
height: '100vh',
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
@ -9,43 +14,82 @@
|
|||||||
bottom: 0,
|
bottom: 0,
|
||||||
'z-index': 1000,
|
'z-index': 1000,
|
||||||
'box-shadow': '0px 0px 10px rgba(0, 0, 0, 0.9)',
|
'box-shadow': '0px 0px 10px rgba(0, 0, 0, 0.9)',
|
||||||
}" v-model:collapsed="collapsed" collapsible>
|
}"
|
||||||
|
v-model:collapsed="collapsed"
|
||||||
|
collapsible
|
||||||
|
>
|
||||||
<div class="logo" />
|
<div class="logo" />
|
||||||
<a-menu class="layout-menu" v-model:selectedKeys="selectedMenuKeys" mode="inline" @click="handleClick"
|
<a-menu
|
||||||
:items="menuItems" />
|
class="layout-menu"
|
||||||
|
v-model:selectedKeys="selectedMenuKeys"
|
||||||
|
mode="inline"
|
||||||
|
@click="handleClick"
|
||||||
|
:items="menuItems"
|
||||||
|
/>
|
||||||
</a-layout-sider>
|
</a-layout-sider>
|
||||||
<a-layout :style="{
|
<a-layout
|
||||||
|
:style="{
|
||||||
marginLeft: sideBarWidth,
|
marginLeft: sideBarWidth,
|
||||||
height: '100vh',
|
height: '100vh',
|
||||||
transition: 'margin-left 200ms',
|
transition: 'margin-left 200ms',
|
||||||
}" class="layout-main">
|
}"
|
||||||
|
class="layout-main"
|
||||||
|
>
|
||||||
<a-layout-header class="layout-header">
|
<a-layout-header class="layout-header">
|
||||||
<div class="layout-header-left" @click="onpush()">
|
<div class="layout-header-left" @click="onpush()">
|
||||||
<a-avatar shape="square" size="large" src="/youjia.svg" />
|
<a-avatar shape="square" size="large" src="/youjia.svg" />
|
||||||
<a-page-header style="padding-left: 4px" :title="compnentTitle" />
|
<a-page-header style="padding-left: 4px" :title="compnentTitle" />
|
||||||
</div>
|
</div>
|
||||||
<a-menu mode="horizontal" v-model:selectedKeys="selectedHeaderKeys" :items="headerItems" breakpoint="lg"
|
<a-menu
|
||||||
@click="headerHandleClick" />
|
mode="horizontal"
|
||||||
|
v-model:selectedKeys="selectedHeaderKeys"
|
||||||
|
:items="headerItems"
|
||||||
|
breakpoint="lg"
|
||||||
|
@click="headerHandleClick"
|
||||||
|
/>
|
||||||
</a-layout-header>
|
</a-layout-header>
|
||||||
<a-config-provider :locale="locale === 'en' ? enUS : zhCN">
|
<a-config-provider :locale="locale === 'en' ? enUS : zhCN">
|
||||||
<a-layout-content class="layout-content">
|
<a-layout-content class="layout-content">
|
||||||
<div class="layout-page-header">
|
<div class="layout-page-header">
|
||||||
<MenuUnfoldOutlined :style="{ fontSize: '22px', color: '#08c' }" @click="collapsedHandle"
|
<MenuUnfoldOutlined
|
||||||
v-if="collapsed" />
|
:style="{ fontSize: '22px', color: '#08c' }"
|
||||||
<MenuFoldOutlined :style="{ fontSize: '22px', color: '#08c' }" @click="collapsedHandle" v-if="!collapsed" />
|
@click="collapsedHandle"
|
||||||
<a-page-header style="border: 0px none rgb(235, 237, 240); padding: 0; padding-left: 8px"
|
v-if="collapsed"
|
||||||
:title="titleHeader" />
|
/>
|
||||||
|
<MenuFoldOutlined
|
||||||
|
:style="{ fontSize: '22px', color: '#08c' }"
|
||||||
|
@click="collapsedHandle"
|
||||||
|
v-if="!collapsed"
|
||||||
|
/>
|
||||||
|
<a-page-header
|
||||||
|
style="
|
||||||
|
border: 0px none rgb(235, 237, 240);
|
||||||
|
padding: 0;
|
||||||
|
padding-left: 8px;
|
||||||
|
"
|
||||||
|
:title="titleHeader"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="layout-content-view" :style="{
|
<div
|
||||||
height: bsInfo.height - (bsInfo.size.sm || !isWelcome ? 100 : 140) + 'px',
|
class="layout-content-view"
|
||||||
}">
|
:style="{
|
||||||
|
height:
|
||||||
|
bsInfo.height -
|
||||||
|
(bsInfo.size.sm || !isWelcome ? 100 : 140) +
|
||||||
|
'px',
|
||||||
|
}"
|
||||||
|
>
|
||||||
<router-view />
|
<router-view />
|
||||||
</div>
|
</div>
|
||||||
</a-layout-content>
|
</a-layout-content>
|
||||||
<changePassword v-model:showModel="showChangePassword" />
|
<changePassword v-model:showModel="showChangePassword" />
|
||||||
<changeLocalName v-model:showModel="showChangeUserLocalName" />
|
<changeLocalName v-model:showModel="showChangeUserLocalName" />
|
||||||
|
<changeBiometricEnrollCard v-model:showModel="showBiometricEnroll" />
|
||||||
</a-config-provider>
|
</a-config-provider>
|
||||||
<a-layout-footer v-if="!bsInfo.size.sm && isWelcome" class="layout-footer">
|
<a-layout-footer
|
||||||
|
v-if="!bsInfo.size.sm && isWelcome"
|
||||||
|
class="layout-footer"
|
||||||
|
>
|
||||||
<div>{{ copy }}</div>
|
<div>{{ copy }}</div>
|
||||||
<div>
|
<div>
|
||||||
<a href="https://beian.miit.gov.cn/">{{ beian }}</a>
|
<a href="https://beian.miit.gov.cn/">{{ beian }}</a>
|
||||||
@ -68,16 +112,25 @@ import {
|
|||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
MenuUnfoldOutlined,
|
MenuUnfoldOutlined,
|
||||||
MenuFoldOutlined,
|
MenuFoldOutlined,
|
||||||
LayoutOutlined
|
LayoutOutlined,
|
||||||
} from "@ant-design/icons-vue";
|
} from "@ant-design/icons-vue";
|
||||||
const collapsed = ref<boolean>(false);
|
const collapsed = ref<boolean>(false);
|
||||||
import { onMounted, reactive, computed, provide, watch, h, ref, VNode } from "vue";
|
import {
|
||||||
|
onMounted,
|
||||||
|
reactive,
|
||||||
|
computed,
|
||||||
|
provide,
|
||||||
|
watch,
|
||||||
|
h,
|
||||||
|
ref,
|
||||||
|
VNode,
|
||||||
|
} from "vue";
|
||||||
import { MenuProps, ItemType } from "ant-design-vue";
|
import { MenuProps, ItemType } from "ant-design-vue";
|
||||||
import { notification } from "ant-design-vue";
|
import { notification } from "ant-design-vue";
|
||||||
import securityUserService from "@/service/security/securityUserService";
|
import securityUserService from "@/service/security/securityUserService";
|
||||||
import { SecurityFunctionResultData } from "@/data/auto/securityFunctionResultData";
|
import { SecurityFunctionResultData } from "@/data/auto/securityFunctionResultData";
|
||||||
import { useRouter, useRoute, type RouteLocationNormalized } from 'vue-router'
|
import { useRouter, useRoute, type RouteLocationNormalized } from "vue-router";
|
||||||
import { set, slice } from 'lodash-es';
|
import { set, slice } from "lodash-es";
|
||||||
import authService from "@/service/security/authService";
|
import authService from "@/service/security/authService";
|
||||||
import { useLoginProfileStore } from "@/store/modules/loginProfile";
|
import { useLoginProfileStore } from "@/store/modules/loginProfile";
|
||||||
import enUS from "ant-design-vue/es/locale/en_US";
|
import enUS from "ant-design-vue/es/locale/en_US";
|
||||||
@ -85,16 +138,20 @@ import zhCN from "ant-design-vue/es/locale/zh_CN";
|
|||||||
import i18n from "../locales";
|
import i18n from "../locales";
|
||||||
import changePassword from "@/views/login/changePassword.vue";
|
import changePassword from "@/views/login/changePassword.vue";
|
||||||
import changeLocalName from "@/views/login/changeLocalName.vue";
|
import changeLocalName from "@/views/login/changeLocalName.vue";
|
||||||
import { WebSitePolicy } from "@/policy/custom/webSitePolicy";
|
import changeBiometricEnrollCard from "@/views/login/changeBiometricEnrollCard.vue";
|
||||||
import { bsInfo } from "@/utils/browser"
|
|
||||||
import { CustIconFont } from '@/components/basic/iconfont';
|
|
||||||
import { layoutProviderMethodKey, LayoutProvider } from "@/injections"
|
|
||||||
|
|
||||||
const router = useRouter()
|
import { WebSitePolicy } from "@/policy/custom/webSitePolicy";
|
||||||
|
import { bsInfo } from "@/utils/browser";
|
||||||
|
import { CustIconFont } from "@/components/basic/iconfont";
|
||||||
|
import { layoutProviderMethodKey, LayoutProvider } from "@/injections";
|
||||||
|
import { startAuthentication } from "@simplewebauthn/browser";
|
||||||
|
import { FinishBiometricLoginData } from "@/data/auto/finishBiometricLoginData";
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const userLoginProfile = useLoginProfileStore();
|
const userLoginProfile = useLoginProfileStore();
|
||||||
const layoutProvider = ref<LayoutProvider>(new LayoutProvider());
|
const layoutProvider = ref<LayoutProvider>(new LayoutProvider());
|
||||||
const titleHeader = ref<string>('');
|
const titleHeader = ref<string>("");
|
||||||
const menuItems: ContextMenuItem[] = reactive([]);
|
const menuItems: ContextMenuItem[] = reactive([]);
|
||||||
|
|
||||||
const locale = ref(userLoginProfile.getLocale() ?? zhCN.locale);
|
const locale = ref(userLoginProfile.getLocale() ?? zhCN.locale);
|
||||||
@ -104,43 +161,44 @@ onMounted(async () => {
|
|||||||
await initMenu();
|
await initMenu();
|
||||||
initHeaderItem();
|
initHeaderItem();
|
||||||
setTitle();
|
setTitle();
|
||||||
let map: string = route.path.split('/')[route.path.split('/').length - 1];
|
let map: string = route.path.split("/")[route.path.split("/").length - 1];
|
||||||
if (map.length > 1)
|
if (map.length > 1) selectedMenuKeys.value = [map];
|
||||||
selectedMenuKeys.value = [map]
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const showChangePassword = ref<boolean>(false);
|
const showChangePassword = ref<boolean>(false);
|
||||||
const showChangeUserLocalName = ref<boolean>(false);
|
const showChangeUserLocalName = ref<boolean>(false);
|
||||||
|
const showBiometricEnroll = ref<boolean>(false);
|
||||||
const collapsedWidth = ref<number>(0);
|
const collapsedWidth = ref<number>(0);
|
||||||
const sideBarWidth = ref<string>('0');
|
const sideBarWidth = ref<string>("0");
|
||||||
const isWelcome = ref<boolean>(route.path == '/dashboard');
|
const isWelcome = ref<boolean>(route.path == "/dashboard");
|
||||||
|
|
||||||
if (!bsInfo.size.sm) {
|
if (!bsInfo.size.sm) {
|
||||||
collapsedWidth.value = 82;
|
collapsedWidth.value = 82;
|
||||||
sideBarWidth.value = '300px';
|
sideBarWidth.value = "300px";
|
||||||
collapsed.value = false;
|
collapsed.value = false;
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
collapsed.value = true;
|
collapsed.value = true;
|
||||||
collapsedWidth.value = 0;
|
collapsedWidth.value = 0;
|
||||||
sideBarWidth.value = '0px';
|
sideBarWidth.value = "0px";
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(route, () => {
|
watch(route, () => {
|
||||||
if (route.path != '/dashboard') {
|
if (route.path != "/dashboard") {
|
||||||
isWelcome.value = false;
|
isWelcome.value = false;
|
||||||
}
|
} else isWelcome.value = true;
|
||||||
else isWelcome.value = true;
|
|
||||||
|
|
||||||
setTitle();
|
setTitle();
|
||||||
let map: string = route.path.split('/')[route.path.split('/').length - 1];
|
let map: string = route.path.split("/")[route.path.split("/").length - 1];
|
||||||
if (map.length > 1)
|
if (map.length > 1) selectedMenuKeys.value = [map];
|
||||||
selectedMenuKeys.value = [map]
|
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(collapsed, () => {
|
watch(collapsed, () => {
|
||||||
sideBarWidth.value = collapsed.value ? (bsInfo.size.sm ? '0px' : '82px') : '300px'
|
sideBarWidth.value = collapsed.value
|
||||||
collapsedWidth.value = collapsed.value ? (bsInfo.size.sm ? 0 : 82) : 300
|
? bsInfo.size.sm
|
||||||
|
? "0px"
|
||||||
|
: "82px"
|
||||||
|
: "300px";
|
||||||
|
collapsedWidth.value = collapsed.value ? (bsInfo.size.sm ? 0 : 82) : 300;
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(bsInfo.size, (_) => {
|
watch(bsInfo.size, (_) => {
|
||||||
@ -148,18 +206,18 @@ watch(bsInfo.size, (_) => {
|
|||||||
if (_.sm === true) {
|
if (_.sm === true) {
|
||||||
collapsed.value = true;
|
collapsed.value = true;
|
||||||
collapsedWidth.value = 0;
|
collapsedWidth.value = 0;
|
||||||
sideBarWidth.value = '0px';
|
sideBarWidth.value = "0px";
|
||||||
} else {
|
} else {
|
||||||
// 非小设备
|
// 非小设备
|
||||||
collapsed.value = false;
|
collapsed.value = false;
|
||||||
collapsedWidth.value = 82;
|
collapsedWidth.value = 82;
|
||||||
sideBarWidth.value = '300px';
|
sideBarWidth.value = "300px";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const collapsedHandle = (() => {
|
const collapsedHandle = () => {
|
||||||
collapsed.value = !collapsed.value;
|
collapsed.value = !collapsed.value;
|
||||||
})
|
};
|
||||||
|
|
||||||
export interface ContextMenuItem {
|
export interface ContextMenuItem {
|
||||||
label: string;
|
label: string;
|
||||||
@ -189,10 +247,10 @@ const beian: string = WebSitePolicy.Beian;
|
|||||||
const compnentTitle: string = WebSitePolicy.Title;
|
const compnentTitle: string = WebSitePolicy.Title;
|
||||||
|
|
||||||
const setTitle = () => {
|
const setTitle = () => {
|
||||||
let map: string = route.path.split('/')[route.path.split('/').length - 1];
|
let map: string = route.path.split("/")[route.path.split("/").length - 1];
|
||||||
titleHeader.value = maps[map.length == 0 ? 'dashboard' : map];
|
titleHeader.value = maps[map.length == 0 ? "dashboard" : map];
|
||||||
layoutProvider.value.layoutTitle = titleHeader.value;
|
layoutProvider.value.layoutTitle = titleHeader.value;
|
||||||
}
|
};
|
||||||
|
|
||||||
const selectedMenuKeys = ref<string[]>(["dashboard"]);
|
const selectedMenuKeys = ref<string[]>(["dashboard"]);
|
||||||
|
|
||||||
@ -212,16 +270,16 @@ function getMenuItem(
|
|||||||
children,
|
children,
|
||||||
label,
|
label,
|
||||||
type,
|
type,
|
||||||
item
|
item,
|
||||||
} as ContextMenuItem;
|
} as ContextMenuItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
const maps: Record<string, string> = {};
|
const maps: Record<string, string> = {};
|
||||||
// const breadcrumbs = ref<string[]>([]);
|
// const breadcrumbs = ref<string[]>([]);
|
||||||
|
|
||||||
|
|
||||||
const initMenu = async () => {
|
const initMenu = async () => {
|
||||||
let fd: SecurityFunctionResultData = await securityUserService.FindMenuByUsername();
|
let fd: SecurityFunctionResultData =
|
||||||
|
await securityUserService.FindMenuByUsername();
|
||||||
|
|
||||||
if (
|
if (
|
||||||
fd == null ||
|
fd == null ||
|
||||||
@ -232,22 +290,48 @@ const initMenu = async () => {
|
|||||||
menuItems.splice(0, menuItems.length);
|
menuItems.splice(0, menuItems.length);
|
||||||
|
|
||||||
let parent: ContextMenuItem;
|
let parent: ContextMenuItem;
|
||||||
set(maps, 'dashboard', t("layout.header.dashboard"));
|
set(maps, "dashboard", t("layout.header.dashboard"));
|
||||||
parent = getMenuItem(maps['dashboard'], 0, 'dashboard', h(HomeTwoTone), null!, null!, { router: '/dashboard' });
|
parent = getMenuItem(
|
||||||
|
maps["dashboard"],
|
||||||
|
0,
|
||||||
|
"dashboard",
|
||||||
|
h(HomeTwoTone),
|
||||||
|
null!,
|
||||||
|
null!,
|
||||||
|
{ router: "/dashboard" },
|
||||||
|
);
|
||||||
|
|
||||||
menuItems.push(parent);
|
menuItems.push(parent);
|
||||||
fd.SecurityFunctionDisplayDatas.forEach((element) => {
|
fd.SecurityFunctionDisplayDatas.forEach((element) => {
|
||||||
set(maps, element.FunctionName, locale.value == zhCN.locale ? element.FunctionLocalName : element.FunctionEngName);
|
set(
|
||||||
|
maps,
|
||||||
|
element.FunctionName,
|
||||||
|
locale.value == zhCN.locale
|
||||||
|
? element.FunctionLocalName
|
||||||
|
: element.FunctionEngName,
|
||||||
|
);
|
||||||
|
|
||||||
if (element.FunctionIndc == "Y" && element.PageUrl && element.PageUrl.length > 0) {
|
if (
|
||||||
|
element.FunctionIndc == "Y" &&
|
||||||
|
element.PageUrl &&
|
||||||
|
element.PageUrl.length > 0
|
||||||
|
) {
|
||||||
//找到父节点加入
|
//找到父节点加入
|
||||||
parent = findParent(
|
parent = findParent(
|
||||||
getMenuItem(maps[element.FunctionName], element.Orderby, element.FunctionName,
|
getMenuItem(
|
||||||
element.Icon && element.Icon.length > 0 ? h(CustIconFont, { type: 'icon-' + element.Icon, size: 16 }) : h(FileTextTwoTone)
|
maps[element.FunctionName],
|
||||||
, null!, "divider" == element.FunctionLocalName ? "divider" : null!, { router: element.ApiRoute }),
|
element.Orderby,
|
||||||
|
element.FunctionName,
|
||||||
|
element.Icon && element.Icon.length > 0
|
||||||
|
? h(CustIconFont, { type: "icon-" + element.Icon, size: 16 })
|
||||||
|
: h(FileTextTwoTone),
|
||||||
|
null!,
|
||||||
|
"divider" == element.FunctionLocalName ? "divider" : null!,
|
||||||
|
{ router: element.ApiRoute },
|
||||||
|
),
|
||||||
element.FunctionName,
|
element.FunctionName,
|
||||||
element.FunctionNameParent,
|
element.FunctionNameParent,
|
||||||
fd
|
fd,
|
||||||
);
|
);
|
||||||
let exist: Boolean = false;
|
let exist: Boolean = false;
|
||||||
menuItems.forEach((menu) => {
|
menuItems.forEach((menu) => {
|
||||||
@ -267,7 +351,7 @@ const findParent = (
|
|||||||
son: ContextMenuItem,
|
son: ContextMenuItem,
|
||||||
functionName: string,
|
functionName: string,
|
||||||
functionNameParent: string,
|
functionNameParent: string,
|
||||||
fd: SecurityFunctionResultData
|
fd: SecurityFunctionResultData,
|
||||||
): ContextMenuItem => {
|
): ContextMenuItem => {
|
||||||
if (
|
if (
|
||||||
fd == null ||
|
fd == null ||
|
||||||
@ -283,21 +367,31 @@ const findParent = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
fd.SecurityFunctionMaps.forEach((element) => {
|
fd.SecurityFunctionMaps.forEach((element) => {
|
||||||
if (element.FunctionNameSon == functionName && element.FunctionNameParent == functionNameParent) {
|
if (
|
||||||
|
element.FunctionNameSon == functionName &&
|
||||||
|
element.FunctionNameParent == functionNameParent
|
||||||
|
) {
|
||||||
fd.SecurityFunctionDisplayDatas.forEach((item: any) => {
|
fd.SecurityFunctionDisplayDatas.forEach((item: any) => {
|
||||||
if (element.FunctionNameParent == item.FunctionName) {
|
if (element.FunctionNameParent == item.FunctionName) {
|
||||||
set(maps, item.FunctionName, locale.value == zhCN.locale ? item.FunctionLocalName : item.FunctionEngName);
|
set(
|
||||||
|
maps,
|
||||||
|
item.FunctionName,
|
||||||
|
locale.value == zhCN.locale
|
||||||
|
? item.FunctionLocalName
|
||||||
|
: item.FunctionEngName,
|
||||||
|
);
|
||||||
let parent: ContextMenuItem = getMenuItem(
|
let parent: ContextMenuItem = getMenuItem(
|
||||||
maps[item.FunctionName],
|
maps[item.FunctionName],
|
||||||
item.Orderby,
|
item.Orderby,
|
||||||
item.FunctionName,
|
item.FunctionName,
|
||||||
item.Icon && item.Icon.length > 0 ? h(CustIconFont, { type: 'icon-' + item.Icon, size: 16 }) : h(FolderTwoTone),
|
item.Icon && item.Icon.length > 0
|
||||||
[son]
|
? h(CustIconFont, { type: "icon-" + item.Icon, size: 16 })
|
||||||
|
: h(FolderTwoTone),
|
||||||
|
[son],
|
||||||
);
|
);
|
||||||
let existIetm: any;
|
let existIetm: any;
|
||||||
menuItems.forEach((menu) => {
|
menuItems.forEach((menu) => {
|
||||||
if (menu?.key == parent?.key) {
|
if (menu?.key == parent?.key) {
|
||||||
|
|
||||||
let exist: Boolean = false;
|
let exist: Boolean = false;
|
||||||
|
|
||||||
menu?.children?.forEach((menu) => {
|
menu?.children?.forEach((menu) => {
|
||||||
@ -309,9 +403,13 @@ const findParent = (
|
|||||||
|
|
||||||
if (parent != null && !exist) {
|
if (parent != null && !exist) {
|
||||||
menu?.children?.push(son);
|
menu?.children?.push(son);
|
||||||
}
|
} else {
|
||||||
else {
|
if (
|
||||||
if (son != null && existIetm != null && son.children != null && son.children.length > 0)
|
son != null &&
|
||||||
|
existIetm != null &&
|
||||||
|
son.children != null &&
|
||||||
|
son.children.length > 0
|
||||||
|
)
|
||||||
existIetm.children.push(son.children[0]);
|
existIetm.children.push(son.children[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -322,7 +420,12 @@ const findParent = (
|
|||||||
if (parent?.key == "BOOTUP") {
|
if (parent?.key == "BOOTUP") {
|
||||||
pt = son;
|
pt = son;
|
||||||
} else {
|
} else {
|
||||||
pt = findParent(parent, item.FunctionName, item.FunctionNameParent, fd);
|
pt = findParent(
|
||||||
|
parent,
|
||||||
|
item.FunctionName,
|
||||||
|
item.FunctionNameParent,
|
||||||
|
fd,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -332,7 +435,6 @@ const findParent = (
|
|||||||
return pt;
|
return pt;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const headerItems: ContextMenuItem[] = reactive([]);
|
const headerItems: ContextMenuItem[] = reactive([]);
|
||||||
const selectedHeaderKeys = ref<string[]>(["createdtime"]);
|
const selectedHeaderKeys = ref<string[]>(["createdtime"]);
|
||||||
|
|
||||||
@ -342,48 +444,124 @@ const initHeaderItem = () => {
|
|||||||
let item: ContextMenuItem;
|
let item: ContextMenuItem;
|
||||||
|
|
||||||
let order: number = 1;
|
let order: number = 1;
|
||||||
item = getMenuItem("中文/English", order++, 'languages', h(TranslationOutlined), [], null!);
|
item = getMenuItem(
|
||||||
|
"中文/English",
|
||||||
|
order++,
|
||||||
|
"languages",
|
||||||
|
h(TranslationOutlined),
|
||||||
|
[],
|
||||||
|
null!,
|
||||||
|
);
|
||||||
let children: ContextMenuItem;
|
let children: ContextMenuItem;
|
||||||
children = getMenuItem("中文", order++, 'chinese', h(TranslationOutlined), null!, null!);
|
children = getMenuItem(
|
||||||
|
"中文",
|
||||||
|
order++,
|
||||||
|
"chinese",
|
||||||
|
h(TranslationOutlined),
|
||||||
|
null!,
|
||||||
|
null!,
|
||||||
|
);
|
||||||
item.children?.push(children);
|
item.children?.push(children);
|
||||||
children = getMenuItem("English", order++, 'english', h(TranslationOutlined), null!, null!);
|
children = getMenuItem(
|
||||||
|
"English",
|
||||||
|
order++,
|
||||||
|
"english",
|
||||||
|
h(TranslationOutlined),
|
||||||
|
null!,
|
||||||
|
null!,
|
||||||
|
);
|
||||||
item.children?.push(children);
|
item.children?.push(children);
|
||||||
//headerItems.push(item);//暂时不显示语言切换
|
//headerItems.push(item);//暂时不显示语言切换
|
||||||
item = getMenuItem((locale.value == zhCN.locale ? userLoginProfile.getUserLocalName()! : userLoginProfile.getUserEngName()!), order++, 'userinfo', h(UserOutlined), [], null!);
|
item = getMenuItem(
|
||||||
|
locale.value == zhCN.locale
|
||||||
|
? userLoginProfile.getUserLocalName()!
|
||||||
|
: userLoginProfile.getUserEngName()!,
|
||||||
|
order++,
|
||||||
|
"userinfo",
|
||||||
|
h(UserOutlined),
|
||||||
|
[],
|
||||||
|
null!,
|
||||||
|
);
|
||||||
|
|
||||||
children = getMenuItem(t("layout.header.amentUserLocalName"), order++, 'amentUserLocalName', h(UserOutlined), null!, null!);
|
children = getMenuItem(
|
||||||
|
t("layout.header.amentUserLocalName"),
|
||||||
|
order++,
|
||||||
|
"amentUserLocalName",
|
||||||
|
h(UserOutlined),
|
||||||
|
null!,
|
||||||
|
null!,
|
||||||
|
);
|
||||||
item.children?.push(children);
|
item.children?.push(children);
|
||||||
|
|
||||||
children = getMenuItem(t("layout.header.amentPassword"), order++, 'amentPassword', h(LockOutlined), null!, null!);
|
children = getMenuItem(
|
||||||
|
t("layout.header.amentPassword"),
|
||||||
|
order++,
|
||||||
|
"amentPassword",
|
||||||
|
h(LockOutlined),
|
||||||
|
null!,
|
||||||
|
null!,
|
||||||
|
);
|
||||||
item.children?.push(children);
|
item.children?.push(children);
|
||||||
|
|
||||||
children = getMenuItem(t("layout.header.logout"), order++, 'logout', h(LogoutOutlined), null!, null!);
|
children = getMenuItem(
|
||||||
|
biometricButtonLabel.value,
|
||||||
|
order++,
|
||||||
|
"biometricButton",
|
||||||
|
h(LockOutlined),
|
||||||
|
null!,
|
||||||
|
null!,
|
||||||
|
);
|
||||||
item.children?.push(children);
|
item.children?.push(children);
|
||||||
children = getMenuItem(t("layout.header.createdtime") + userLoginProfile.getCreatedTime()!, order++, 'createdtime', h(FieldTimeOutlined), null!, null!);
|
|
||||||
|
children = getMenuItem(
|
||||||
|
t("layout.header.logout"),
|
||||||
|
order++,
|
||||||
|
"logout",
|
||||||
|
h(LogoutOutlined),
|
||||||
|
null!,
|
||||||
|
null!,
|
||||||
|
);
|
||||||
|
item.children?.push(children);
|
||||||
|
children = getMenuItem(
|
||||||
|
t("layout.header.createdtime") + userLoginProfile.getCreatedTime()!,
|
||||||
|
order++,
|
||||||
|
"createdtime",
|
||||||
|
h(FieldTimeOutlined),
|
||||||
|
null!,
|
||||||
|
null!,
|
||||||
|
);
|
||||||
item.children?.push(children);
|
item.children?.push(children);
|
||||||
headerItems.push(item);
|
headerItems.push(item);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Coarse platform detection so the button label is friendly on each
|
||||||
|
// device. Falls back to a generic "fingerprint / face" if we can't tell.
|
||||||
|
const biometricButtonLabel = computed(() => {
|
||||||
|
const ua = typeof navigator !== "undefined" ? navigator.userAgent : "";
|
||||||
|
if (/iPhone|iPad/.test(ua)) return "Sign in with Face ID / Touch ID";
|
||||||
|
if (/Android/.test(ua)) return "Sign in with fingerprint";
|
||||||
|
if (/Windows/.test(ua)) return "Sign in with Windows Hello";
|
||||||
|
if (/Mac OS X/.test(ua)) return "Sign in with Touch ID";
|
||||||
|
return "Sign in with fingerprint / face";
|
||||||
|
});
|
||||||
|
|
||||||
const headerHandleClick: MenuProps['onClick'] = (menuInfo) => {
|
const headerHandleClick: MenuProps["onClick"] = (menuInfo) => {
|
||||||
if (menuInfo.key == "logout") {
|
if (menuInfo.key == "logout") {
|
||||||
logout();
|
logout();
|
||||||
}
|
} else if (menuInfo.key == "amentPassword") {
|
||||||
else if (menuInfo.key == "amentPassword") {
|
|
||||||
showChangePassword.value = true;
|
showChangePassword.value = true;
|
||||||
}
|
} else if (menuInfo.key == "biometricButton") {
|
||||||
else if (menuInfo.key == "amentUserLocalName") {
|
showBiometricEnroll.value=true;
|
||||||
|
} else if (menuInfo.key == "amentUserLocalName") {
|
||||||
showChangeUserLocalName.value = true;
|
showChangeUserLocalName.value = true;
|
||||||
}
|
} else if (menuInfo.key == "chinese") {
|
||||||
else if (menuInfo.key == "chinese") {
|
|
||||||
userLoginProfile.setLocale(zhCN.locale);
|
userLoginProfile.setLocale(zhCN.locale);
|
||||||
locale.value = zhCN.locale;
|
locale.value = zhCN.locale;
|
||||||
}
|
} else if (menuInfo.key == "english") {
|
||||||
else if (menuInfo.key == "english") {
|
|
||||||
userLoginProfile.setLocale(enUS.locale);
|
userLoginProfile.setLocale(enUS.locale);
|
||||||
locale.value = enUS.locale;
|
locale.value = enUS.locale;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
await authService.Logout();
|
await authService.Logout();
|
||||||
@ -395,13 +573,13 @@ const logout = async () => {
|
|||||||
placement: "topRight",
|
placement: "topRight",
|
||||||
});
|
});
|
||||||
router.push("/");
|
router.push("/");
|
||||||
}
|
};
|
||||||
|
|
||||||
const onpush = () => {
|
const onpush = () => {
|
||||||
router.push("/dashboard");
|
router.push("/dashboard");
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleClick: MenuProps['onClick'] = (menuInfo) => {
|
const handleClick: MenuProps["onClick"] = (menuInfo) => {
|
||||||
if (menuInfo.item.item.router) {
|
if (menuInfo.item.item.router) {
|
||||||
router.push({ path: menuInfo.item.item.router }).then(() => {
|
router.push({ path: menuInfo.item.item.router }).then(() => {
|
||||||
menuInfo.keyPath?.forEach((e) => {
|
menuInfo.keyPath?.forEach((e) => {
|
||||||
@ -410,26 +588,20 @@ const handleClick: MenuProps['onClick'] = (menuInfo) => {
|
|||||||
if (bsInfo.size.sm) {
|
if (bsInfo.size.sm) {
|
||||||
collapsed.value = true;
|
collapsed.value = true;
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
layoutProvider.value.locale = locale.value;
|
layoutProvider.value.locale = locale.value;
|
||||||
|
|
||||||
const getLayoutProvider = () => {
|
const getLayoutProvider = () => {
|
||||||
return layoutProvider.value
|
return layoutProvider.value;
|
||||||
}
|
};
|
||||||
|
|
||||||
provide(
|
provide(layoutProviderMethodKey, {
|
||||||
layoutProviderMethodKey,
|
|
||||||
{
|
|
||||||
getLayoutProvider: getLayoutProvider,
|
getLayoutProvider: getLayoutProvider,
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.layout {
|
.layout {
|
||||||
@ -457,7 +629,8 @@ provide(
|
|||||||
height: calc(100vh - 48px);
|
height: calc(100vh - 48px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.layout-main {}
|
.layout-main {
|
||||||
|
}
|
||||||
|
|
||||||
.layout-header {
|
.layout-header {
|
||||||
background-color: white;
|
background-color: white;
|
||||||
|
|||||||
@ -1,8 +1,13 @@
|
|||||||
|
|
||||||
import { request } from "@/utils/request";
|
import { request } from "@/utils/request";
|
||||||
import type { LoginData } from "@/data/auto/loginData";
|
import type { LoginData } from "@/data/auto/loginData";
|
||||||
import type { LoginProfile } from "@/data/auto/loginProfile";
|
import type { LoginProfile } from "@/data/auto/loginProfile";
|
||||||
import type { RegisterData } from "@/data/auto/registerData";
|
import type { RegisterData } from "@/data/auto/registerData";
|
||||||
import type { JwtInfo } from "@/data/auto/jwtInfo";
|
import type { JwtInfo } from "@/data/auto/jwtInfo";
|
||||||
|
import type { FinishBiometricRegistrationData } from "@/data/auto/finishBiometricRegistrationData";
|
||||||
|
import type { BiometricRegistrationResultDto } from "@/data/auto/biometricRegistrationResultDto";
|
||||||
|
import type { BiometricLoginChallengeDto } from "@/data/auto/biometricLoginChallengeDto";
|
||||||
|
import type { FinishBiometricLoginData } from "@/data/auto/finishBiometricLoginData";
|
||||||
|
|
||||||
class AuthService{
|
class AuthService{
|
||||||
|
|
||||||
@ -74,6 +79,65 @@ class AuthService{
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async ConfirmPassword(data: string) {
|
||||||
|
return request<any>(
|
||||||
|
{
|
||||||
|
url: "/Auth/ConfirmPassword?password="+data,
|
||||||
|
method: "post",
|
||||||
|
data,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
async BeginBiometricRegistration(data: string) {
|
||||||
|
return request<string>(
|
||||||
|
{
|
||||||
|
url: "/Auth/BeginBiometricRegistration?deviceName="+data,
|
||||||
|
method: "post",
|
||||||
|
data,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
async FinishBiometricRegistration(data: FinishBiometricRegistrationData) {
|
||||||
|
return request<BiometricRegistrationResultDto>(
|
||||||
|
{
|
||||||
|
url: "/Auth/FinishBiometricRegistration",
|
||||||
|
method: "post",
|
||||||
|
data,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
async BeginBiometricLogin(data: string) {
|
||||||
|
return request<BiometricLoginChallengeDto>(
|
||||||
|
{
|
||||||
|
url: "/Auth/BeginBiometricLogin?username="+data,
|
||||||
|
method: "post",
|
||||||
|
data,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
async FinishBiometricLogin(data: FinishBiometricLoginData) {
|
||||||
|
return request<LoginProfile>(
|
||||||
|
{
|
||||||
|
url: "/Auth/FinishBiometricLogin",
|
||||||
|
method: "post",
|
||||||
|
data,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
async RemoveBiometric() {
|
||||||
|
return request<any>(
|
||||||
|
{
|
||||||
|
url: "/Auth/RemoveBiometric",
|
||||||
|
method: "post",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const authService= new AuthService();
|
const authService= new AuthService();
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
import { request } from "@/utils/request";
|
import { request } from "@/utils/request";
|
||||||
import type { LocaleEntity } from "@/data/auto/localeEntity";
|
import type { LocaleEntity } from "@/data/auto/localeEntity";
|
||||||
import type { LocaleSearchCriteria } from "@/data/auto/localeSearchCriteria";
|
import type { LocaleSearchCriteria } from "@/data/auto/localeSearchCriteria";
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
import { request,download } from "@/utils/request";
|
import { request,download } from "@/utils/request";
|
||||||
import type { SecurityActorData } from "@/data/auto/securityActorData";
|
import type { SecurityActorData } from "@/data/auto/securityActorData";
|
||||||
import type { SecurityActorEntity } from "@/data/auto/securityActorEntity";
|
import type { SecurityActorEntity } from "@/data/auto/securityActorEntity";
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
import { request,download } from "@/utils/request";
|
import { request,download } from "@/utils/request";
|
||||||
import type { SecurityFunctionData } from "@/data/auto/securityFunctionData";
|
import type { SecurityFunctionData } from "@/data/auto/securityFunctionData";
|
||||||
import type { SecurityFunctionSearchCriteria } from "@/data/auto/securityFunctionSearchCriteria";
|
import type { SecurityFunctionSearchCriteria } from "@/data/auto/securityFunctionSearchCriteria";
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
import { request,download } from "@/utils/request";
|
import { request,download } from "@/utils/request";
|
||||||
import type { SecurityGeneratorNumberEntity } from "@/data/auto/securityGeneratorNumberEntity";
|
import type { SecurityGeneratorNumberEntity } from "@/data/auto/securityGeneratorNumberEntity";
|
||||||
import type { SecurityGeneratorNumberSearchCriteria } from "@/data/auto/securityGeneratorNumberSearchCriteria";
|
import type { SecurityGeneratorNumberSearchCriteria } from "@/data/auto/securityGeneratorNumberSearchCriteria";
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
import { request,download } from "@/utils/request";
|
import { request,download } from "@/utils/request";
|
||||||
import type { SecurityGroupData } from "@/data/auto/securityGroupData";
|
import type { SecurityGroupData } from "@/data/auto/securityGroupData";
|
||||||
import type { SecurityGroupEntity } from "@/data/auto/securityGroupEntity";
|
import type { SecurityGroupEntity } from "@/data/auto/securityGroupEntity";
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
import { request } from "@/utils/request";
|
import { request } from "@/utils/request";
|
||||||
import type { SecurityLockEntityKey } from "@/data/auto/securityLockEntityKey";
|
import type { SecurityLockEntityKey } from "@/data/auto/securityLockEntityKey";
|
||||||
import type { SecurityLockEntity } from "@/data/auto/securityLockEntity";
|
import type { SecurityLockEntity } from "@/data/auto/securityLockEntity";
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
import { request,download } from "@/utils/request";
|
import { request,download } from "@/utils/request";
|
||||||
import type { SecurityMasterEntityKey } from "@/data/auto/securityMasterEntityKey";
|
import type { SecurityMasterEntityKey } from "@/data/auto/securityMasterEntityKey";
|
||||||
import type { SecurityMasterEntity } from "@/data/auto/securityMasterEntity";
|
import type { SecurityMasterEntity } from "@/data/auto/securityMasterEntity";
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
import { request,download } from "@/utils/request";
|
import { request,download } from "@/utils/request";
|
||||||
import type { SecurityPermissionData } from "@/data/auto/securityPermissionData";
|
import type { SecurityPermissionData } from "@/data/auto/securityPermissionData";
|
||||||
import type { SecurityPermissionSearchCriteria } from "@/data/auto/securityPermissionSearchCriteria";
|
import type { SecurityPermissionSearchCriteria } from "@/data/auto/securityPermissionSearchCriteria";
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
import { request,download } from "@/utils/request";
|
import { request,download } from "@/utils/request";
|
||||||
import type { SecurityUserPreferenceEntityKey } from "@/data/auto/securityUserPreferenceEntityKey";
|
import type { SecurityUserPreferenceEntityKey } from "@/data/auto/securityUserPreferenceEntityKey";
|
||||||
import type { SecurityUserPreferenceEntity } from "@/data/auto/securityUserPreferenceEntity";
|
import type { SecurityUserPreferenceEntity } from "@/data/auto/securityUserPreferenceEntity";
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
import { request,download } from "@/utils/request";
|
import { request,download } from "@/utils/request";
|
||||||
import type { SecurityUserEntity } from "@/data/auto/securityUserEntity";
|
import type { SecurityUserEntity } from "@/data/auto/securityUserEntity";
|
||||||
import type { SecurityUserData } from "@/data/auto/securityUserData";
|
import type { SecurityUserData } from "@/data/auto/securityUserData";
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
import { request } from "@/utils/request";
|
import { request } from "@/utils/request";
|
||||||
import type { SelectOption } from "@/data/auto/selectOption";
|
import type { SelectOption } from "@/data/auto/selectOption";
|
||||||
|
|
||||||
|
|||||||
@ -23,7 +23,7 @@ export const useLoginProfileStore = defineStore("loginProfile", {
|
|||||||
actions: {
|
actions: {
|
||||||
updateLoginProfile(loginProfile: LoginProfile) {
|
updateLoginProfile(loginProfile: LoginProfile) {
|
||||||
this.loginProfile = loginProfile;
|
this.loginProfile = loginProfile;
|
||||||
storage.set(JWT_STORAGE_ID, "Bearer " +loginProfile.Token.AccessToken);
|
storage.set(JWT_STORAGE_ID, "Bearer " + loginProfile.Token.AccessToken);
|
||||||
storage.set(JWT_REFRESHKEY_STORAGE_ID, loginProfile.Token.RefreshToken);
|
storage.set(JWT_REFRESHKEY_STORAGE_ID, loginProfile.Token.RefreshToken);
|
||||||
storage.set(LOGIN_PROFILE_STORAGE_ID, JSON.stringify(this.loginProfile));
|
storage.set(LOGIN_PROFILE_STORAGE_ID, JSON.stringify(this.loginProfile));
|
||||||
},
|
},
|
||||||
@ -32,22 +32,22 @@ export const useLoginProfileStore = defineStore("loginProfile", {
|
|||||||
storage.set(JWT_STORAGE_ID, token);
|
storage.set(JWT_STORAGE_ID, token);
|
||||||
},
|
},
|
||||||
updateJwtInfo(jwtInfo: JwtInfo) {
|
updateJwtInfo(jwtInfo: JwtInfo) {
|
||||||
storage.set(JWT_STORAGE_ID, "Bearer " +jwtInfo.AccessToken);
|
storage.set(JWT_STORAGE_ID, "Bearer " + jwtInfo.AccessToken);
|
||||||
storage.set(JWT_REFRESHKEY_STORAGE_ID, jwtInfo.RefreshToken);
|
storage.set(JWT_REFRESHKEY_STORAGE_ID, jwtInfo.RefreshToken);
|
||||||
},
|
},
|
||||||
getToken() {
|
getToken() {
|
||||||
return storage.get(JWT_STORAGE_ID);
|
return storage.get(JWT_STORAGE_ID);
|
||||||
},
|
},
|
||||||
setLocale(locale:string ) {
|
setLocale(locale: string) {
|
||||||
return storage.set(LOCALE_ID,locale);
|
return storage.set(LOCALE_ID, locale);
|
||||||
},
|
},
|
||||||
getLocale() {
|
getLocale() {
|
||||||
return storage.get(LOCALE_ID);
|
return storage.get(LOCALE_ID);
|
||||||
},
|
},
|
||||||
getJwtInfo() {
|
getJwtInfo() {
|
||||||
let jwt:JwtInfo=new JwtInfo();
|
let jwt: JwtInfo = new JwtInfo();
|
||||||
jwt.AccessToken=storage.get(JWT_STORAGE_ID);
|
jwt.AccessToken = storage.get(JWT_STORAGE_ID);
|
||||||
jwt.RefreshToken=storage.get(JWT_REFRESHKEY_STORAGE_ID);
|
jwt.RefreshToken = storage.get(JWT_REFRESHKEY_STORAGE_ID);
|
||||||
return jwt;
|
return jwt;
|
||||||
},
|
},
|
||||||
getRefreshToken() {
|
getRefreshToken() {
|
||||||
@ -96,8 +96,11 @@ export const useLoginProfileStore = defineStore("loginProfile", {
|
|||||||
return (JSON.parse(str) as LoginProfile).PermissionInfo;
|
return (JSON.parse(str) as LoginProfile).PermissionInfo;
|
||||||
},
|
},
|
||||||
clearStorage() {
|
clearStorage() {
|
||||||
storage.clear();
|
|
||||||
storage.clearCookie();
|
storage.set(JWT_STORAGE_ID, null);
|
||||||
|
storage.set(JWT_REFRESHKEY_STORAGE_ID, null);
|
||||||
|
storage.set(LOGIN_PROFILE_STORAGE_ID, null);
|
||||||
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
87
src/views/login/changeBiometricEnrollCard.vue
Normal file
87
src/views/login/changeBiometricEnrollCard.vue
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
<template>
|
||||||
|
<a-spin size="large" :spinning="state.loading">
|
||||||
|
<a-modal v-model:open="props.showModel" title="" @cancel="$emit('update:showModel', false)" >
|
||||||
|
<template #footer>
|
||||||
|
<a-button type="primary" @click="handleSubmit">Close</a-button>
|
||||||
|
</template>
|
||||||
|
<a-form
|
||||||
|
name="basic"
|
||||||
|
autocomplete="off"
|
||||||
|
:label-col="{ span: 8 }"
|
||||||
|
:rules="rules"
|
||||||
|
:model="state.LoginData"
|
||||||
|
ref="formRef"
|
||||||
|
>
|
||||||
|
<BiometricEnrollCard :username="state.LoginData.UserMobile" />
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
</a-spin>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref,watch } from "vue";
|
||||||
|
import authService from "@/service/security/authService";
|
||||||
|
import { LoginData } from "@/data/auto/loginData";
|
||||||
|
import { LoginProfile } from "@/data/auto/loginProfile";
|
||||||
|
import { useLoginProfileStore } from "@/store/modules/loginProfile";
|
||||||
|
import type { Rule } from "ant-design-vue/es/form";
|
||||||
|
import BiometricEnrollCard from "@/components/basic/BiometricEnrollCard.vue";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import { JSEncrypt } from "jsencrypt";
|
||||||
|
import CryptoJS from "crypto-js";
|
||||||
|
import i18n from "@/locales";
|
||||||
|
|
||||||
|
const { t } = i18n.global;
|
||||||
|
|
||||||
|
const formRef = ref();
|
||||||
|
|
||||||
|
const userLoginProfileStore = useLoginProfileStore();
|
||||||
|
const getCurrentUser = () => userLoginProfileStore.loginProfile?.Username ?? "";
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
loading: false,
|
||||||
|
LoginData: {
|
||||||
|
UserMobile: getCurrentUser(),
|
||||||
|
RsaUserPassword: "",
|
||||||
|
UserPassword: "",
|
||||||
|
ConfirmNewPassword: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = defineProps(["showModel"]);
|
||||||
|
const emit = defineEmits(["update:showModel"]);
|
||||||
|
|
||||||
|
|
||||||
|
// 监听弹窗打开,每次弹出都会执行赋值
|
||||||
|
watch(
|
||||||
|
() => props.showModel,
|
||||||
|
(isOpen) => {
|
||||||
|
if (isOpen) {
|
||||||
|
state.LoginData.UserMobile = getCurrentUser();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ flush: "post" } // 保证dom更新后执行,兜底兼容
|
||||||
|
);
|
||||||
|
|
||||||
|
const validatePass = async (_rule: Rule, value: string) => {
|
||||||
|
if (value !== state.LoginData.UserPassword) {
|
||||||
|
return Promise.reject(t("login.register.twoDifferentPasswords"));
|
||||||
|
} else {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rules: Record<string, Rule[]> = {
|
||||||
|
ConfirmNewPassword: [{ validator: validatePass, trigger: "change" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
state.loading = true;
|
||||||
|
|
||||||
|
emit("update:showModel", false);
|
||||||
|
} catch (error) {
|
||||||
|
} finally {
|
||||||
|
state.loading = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@ -6,8 +6,8 @@
|
|||||||
<a-typography-title style="margin-top: 18px" :level="1">有家相册</a-typography-title>
|
<a-typography-title style="margin-top: 18px" :level="1">有家相册</a-typography-title>
|
||||||
</div>
|
</div>
|
||||||
<a-form layout="horizontal" :model="state.LoginData" @finish="handleSubmit">
|
<a-form layout="horizontal" :model="state.LoginData" @finish="handleSubmit">
|
||||||
<a-form-item name="UserMobile" :rules="[{ required: true, message: '请输入手机号!' }]">
|
<a-form-item name="UserMobile" :rules="[{ required: true, message: '请输入手机号或者账号名!' }]">
|
||||||
<a-input v-model:value="state.LoginData.UserMobile" size="large" placeholder="手机号">
|
<a-input v-model:value="state.LoginData.UserMobile" size="large" placeholder="手机号或者账号名">
|
||||||
<template #prefix><user-outlined type="user" /></template>
|
<template #prefix><user-outlined type="user" /></template>
|
||||||
</a-input>
|
</a-input>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
@ -19,6 +19,26 @@
|
|||||||
<a-form-item>
|
<a-form-item>
|
||||||
<a-button type="primary" html-type="submit" size="large" block> 登录 </a-button>
|
<a-button type="primary" html-type="submit" size="large" block> 登录 </a-button>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
<!-- Biometric (WebAuthn) — only visible if the user has a stored
|
||||||
|
credential on this device. The credential id is read from
|
||||||
|
localStorage (key: biometric_<username>). -->
|
||||||
|
<div v-if="biometricVisible" class="mt-3">
|
||||||
|
<a-divider plain class="!my-2 !text-xs">
|
||||||
|
{{'or' }}
|
||||||
|
</a-divider>
|
||||||
|
<a-button
|
||||||
|
type="default"
|
||||||
|
:loading="biometricLoading"
|
||||||
|
:disabled="!state.LoginData.UserMobile"
|
||||||
|
size="large"
|
||||||
|
block
|
||||||
|
class="!h-12"
|
||||||
|
@click="onBiometricLogin"
|
||||||
|
>
|
||||||
|
<span class="mr-2">🔒</span>
|
||||||
|
{{ biometricButtonLabel }}
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<a-button type="dashed" @click.prevent="onRegister" size="large" style="width: 80px">
|
<a-button type="dashed" @click.prevent="onRegister" size="large" style="width: 80px">
|
||||||
注册
|
注册
|
||||||
@ -32,7 +52,7 @@
|
|||||||
</a-spin>
|
</a-spin>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive } from "vue";
|
import { reactive, ref, computed, watch } from 'vue'
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { UserOutlined, LockOutlined } from "@ant-design/icons-vue";
|
import { UserOutlined, LockOutlined } from "@ant-design/icons-vue";
|
||||||
import authService from "@/service/security/authService";
|
import authService from "@/service/security/authService";
|
||||||
@ -45,6 +65,9 @@ import { JSEncrypt } from "jsencrypt";
|
|||||||
import CryptoJS from "crypto-js";
|
import CryptoJS from "crypto-js";
|
||||||
|
|
||||||
import i18n from "@/locales";
|
import i18n from "@/locales";
|
||||||
|
import { startAuthentication } from '@simplewebauthn/browser';
|
||||||
|
import { FinishBiometricLoginData } from '@/data/auto/finishBiometricLoginData';
|
||||||
|
import { notification } from 'ant-design-vue';
|
||||||
|
|
||||||
const { t } = i18n.global;
|
const { t } = i18n.global;
|
||||||
//const props = defineProps(['id'])
|
//const props = defineProps(['id'])
|
||||||
@ -101,6 +124,197 @@ const handleSubmit = async () => {
|
|||||||
state.loading = false;
|
state.loading = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// Auto-fill the username from the last successful biometric enrollment
|
||||||
|
// on this device. BiometricEnrollCard writes `last_biometric_username`
|
||||||
|
// when enrollment completes, so a user who enrolled and then opens a
|
||||||
|
// fresh tab to the login page can hit the fingerprint button
|
||||||
|
// immediately — no need to type the username first.
|
||||||
|
//
|
||||||
|
// Only auto-fill if the field is empty (don't clobber what the user
|
||||||
|
// is already typing) and only if a matching `biometric_<username>`
|
||||||
|
// entry actually exists in localStorage (so a stale
|
||||||
|
// `last_biometric_username` from a different account doesn't fill in
|
||||||
|
// a credential we can't actually use).
|
||||||
|
;(function autofillFromBiometric() {
|
||||||
|
try {
|
||||||
|
const last = localStorage.getItem('last_biometric_username')
|
||||||
|
if (!last) return
|
||||||
|
const stored = localStorage.getItem(`biometric_${last}`)
|
||||||
|
if (!stored) return
|
||||||
|
if (state.LoginData.UserMobile && state.LoginData.UserMobile !== last) return
|
||||||
|
state.LoginData.UserMobile = last
|
||||||
|
} catch {
|
||||||
|
// localStorage unavailable (private mode, etc.) — silently skip
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
// ===== Biometric login (WebAuthn) =====
|
||||||
|
const biometricLoading = ref(false)
|
||||||
|
const biometricStoredCredentialId = ref<string | null>(null)
|
||||||
|
|
||||||
|
// Read the credential id from localStorage whenever the user types a
|
||||||
|
// username. Key: biometric_<username>. The credential id is a base64url
|
||||||
|
// string as produced by @simplewebauthn/browser's startRegistration.
|
||||||
|
watch(
|
||||||
|
() => state.LoginData.UserMobile,
|
||||||
|
(u) => {
|
||||||
|
biometricStoredCredentialId.value = null
|
||||||
|
if (!u) return
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(`biometric_${u}`)
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw) as { credentialId?: string }
|
||||||
|
if (parsed.credentialId) {
|
||||||
|
biometricStoredCredentialId.value = parsed.credentialId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore — just don't show the button
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
const biometricVisible = computed(
|
||||||
|
() => !!state.LoginData.UserMobile && !!biometricStoredCredentialId.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Coarse platform detection so the button label is friendly on each
|
||||||
|
// device. Falls back to a generic "fingerprint / face" if we can't tell.
|
||||||
|
const biometricButtonLabel = computed(() => {
|
||||||
|
const ua = typeof navigator !== 'undefined' ? navigator.userAgent : ''
|
||||||
|
if (/iPhone|iPad/.test(ua)) return 'Sign in with Face ID / Touch ID'
|
||||||
|
if (/Android/.test(ua)) return 'Sign in with fingerprint'
|
||||||
|
if (/Windows/.test(ua)) return 'Sign in with Windows Hello'
|
||||||
|
if (/Mac OS X/.test(ua)) return 'Sign in with Touch ID'
|
||||||
|
return 'Sign in with fingerprint / face'
|
||||||
|
})
|
||||||
|
|
||||||
|
const onBiometricLogin = async () => {
|
||||||
|
if (biometricLoading.value) return
|
||||||
|
if (!state.LoginData.UserMobile|| !biometricStoredCredentialId.value) return
|
||||||
|
biometricLoading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Ask the server for the assertion options (challenge +
|
||||||
|
// allowCredentials restricted to the user's stored credential).
|
||||||
|
// BiometricLoginChallengeDto.requestOptionsJson is a JSON string
|
||||||
|
// (TypeGen mirrors the C# property as string). @simplewebauthn/
|
||||||
|
// browser@13 expects a parsed object, so parse it here.
|
||||||
|
//
|
||||||
|
// NOTE: these two calls go through the hand-written
|
||||||
|
// BiometricLoginController (api/BiometricLogin/...) — NOT the
|
||||||
|
// auto-generated UserProfileController (api/userProfile/...).
|
||||||
|
// The auto-generated controller has [Authorize] at the class
|
||||||
|
// level, which blocks the unauthenticated login flow (the user
|
||||||
|
// has no JWT yet at this point). The dedicated controller is
|
||||||
|
// identical in process logic; only the route and the missing
|
||||||
|
// [Authorize] differ.
|
||||||
|
|
||||||
|
|
||||||
|
const beginResp = await authService.BeginBiometricLogin(state.LoginData.UserMobile)
|
||||||
|
|
||||||
|
|
||||||
|
const begin = beginResp;
|
||||||
|
const requestOptions = JSON.parse(begin.RequestOptionsJson)
|
||||||
|
|
||||||
|
|
||||||
|
// 2. @simplewebauthn/browser shows the OS prompt and returns the
|
||||||
|
// signed assertion. We pass the parsed options; the stored
|
||||||
|
// credential id is already inside allowCredentials[].
|
||||||
|
|
||||||
|
|
||||||
|
const assertion = await startAuthentication({
|
||||||
|
optionsJSON: requestOptions,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3. Server verifies the assertion and returns a LoginProfile
|
||||||
|
// (same shape as AuthProcess.Login).
|
||||||
|
// Use camelCase keys — that matches the TypeGen-generated
|
||||||
|
// FinishBiometricLoginData class exactly, so TS type-checks and
|
||||||
|
// ASP.NET's case-insensitive JSON binder maps to the C#
|
||||||
|
// Username / AssertionJson / RequestOptionsJson / ChallengeId
|
||||||
|
// properties without 400s.
|
||||||
|
|
||||||
|
let fbdt: FinishBiometricLoginData={
|
||||||
|
Username: state.LoginData.UserMobile,
|
||||||
|
AssertionJson: JSON.stringify(assertion),
|
||||||
|
RequestOptionsJson: JSON.stringify(begin.RequestOptionsJson),
|
||||||
|
ChallengeId: begin.ChallengeId,
|
||||||
|
}
|
||||||
|
const profile = await authService.FinishBiometricLogin(fbdt)
|
||||||
|
|
||||||
|
// 4. Persist the JWT locally and navigate. installProfile is the
|
||||||
|
// same call LoginView makes after a successful password login.
|
||||||
|
const userLoginProfile = useLoginProfileStore();
|
||||||
|
|
||||||
|
userLoginProfile.updateLoginProfile(profile);
|
||||||
|
|
||||||
|
router.push({ path: "/dashboard" });
|
||||||
|
} catch (err: any) {
|
||||||
|
// Best-effort error extraction — handles ALL of:
|
||||||
|
// 1. Axios error: err.response.data is the server body (string or object)
|
||||||
|
// 2. WebAuthn/JS: err is a DOMException / Error with .name + .message
|
||||||
|
// 3. Plain string thrown by our own service
|
||||||
|
// 4. **The auto-generated UserProfileService stubs reject with
|
||||||
|
// err.response.data (the raw server body) instead of a proper
|
||||||
|
// axios error** — so err is often a plain object like
|
||||||
|
// { message: "Biometric not enrolled" } or { error: "..." }.
|
||||||
|
// The old extractor fell through to `String(err)` and produced
|
||||||
|
// "[object Object]" for these. Handle plain objects directly.
|
||||||
|
|
||||||
|
|
||||||
|
const msg = (() => {
|
||||||
|
if (err == null) return 'Unknown error'
|
||||||
|
if (typeof err === 'string') return err
|
||||||
|
// Plain object: likely the server's response body (auto-generated
|
||||||
|
// service rejects with error.response.data, not the full axios
|
||||||
|
// error). Common ASP.NET ValidationException shapes:
|
||||||
|
if (typeof err === 'object') {
|
||||||
|
// 1. { message: "..." } — most common
|
||||||
|
if (typeof err.message === 'string' && err.message) return err.message
|
||||||
|
// 2. { error: "..." } — ProblemDetails style
|
||||||
|
if (typeof err.error === 'string' && err.error) return err.error
|
||||||
|
// 3. { title: "..." } — ProblemDetails style
|
||||||
|
if (typeof err.title === 'string' && err.title) return err.title
|
||||||
|
// 4. Axios error with nested response.data
|
||||||
|
if (err.response?.data != null) {
|
||||||
|
const data = err.response.data
|
||||||
|
if (typeof data === 'string') return data
|
||||||
|
if (typeof data === 'object') {
|
||||||
|
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
|
||||||
|
try { return JSON.stringify(data) } catch { return String(data) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 5. Error / DOMException on the plain object
|
||||||
|
if (err.name && err.message) return `${err.name}: ${err.message}`
|
||||||
|
// 6. Last resort: stringify. Never fall through to String(err)
|
||||||
|
// which would produce "[object Object]".
|
||||||
|
try { return JSON.stringify(err) } catch { return 'Unknown error' }
|
||||||
|
}
|
||||||
|
return String(err)
|
||||||
|
})()
|
||||||
|
if (/user cancelled|user canceled/i.test(msg)) {
|
||||||
|
notification.info({
|
||||||
|
message: 'Biometric sign-in cancelled',
|
||||||
|
description: 'Biometric sign-in cancelled',
|
||||||
|
placement: "topRight",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
notification.error({
|
||||||
|
message: 'Biometric sign-in failed',
|
||||||
|
description: msg + '. ' + ( 'Please use your password.'),
|
||||||
|
placement: 'topRight',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
biometricLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user