From 3cde5bb54a87b62e7900615eb24e96fd3494fd92 Mon Sep 17 00:00:00 2001 From: John Cardinal Date: Sun, 1 Nov 2026 18:46:27 -0700 Subject: [PATCH] case 4678 - passkey sign-in and management Co-Authored-By: Claude Opus 5.5 --- src/api/passkey.js | 76 ++++++++++++++++++++++ src/plugins/vuetify.js | 8 +++ src/views/adm-user.vue | 34 ++++++++++ src/views/home-security.vue | 125 ++++++++++++++++++++++++++++++++++++ src/views/login.vue | 51 +++++++++++++++ 5 files changed, 294 insertions(+) create mode 100644 src/api/passkey.js diff --git a/src/api/passkey.js b/src/api/passkey.js new file mode 100644 index 0000000..c0066b5 --- /dev/null +++ b/src/api/passkey.js @@ -0,0 +1,76 @@ +// case 4678: passkeys (WebAuthn). The server sends the options as JSON with binary fields in base64url; +// the browser API wants ArrayBuffers, and its answer goes back the same way + +function toBuffer(b64url) { + const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/"); + const bin = atob(b64 + "===".slice((b64.length + 3) % 4)); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) { + bytes[i] = bin.charCodeAt(i); + } + return bytes.buffer; +} + +function toB64Url(buffer) { + if (!buffer) { + return null; + } + const bytes = new Uint8Array(buffer); + let bin = ""; + for (let i = 0; i < bytes.length; i++) { + bin += String.fromCharCode(bytes[i]); + } + return btoa(bin) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +function credentialJson(cred, response) { + return { + id: cred.id, + rawId: toB64Url(cred.rawId), + type: cred.type, + response: response, + clientExtensionResults: cred.getClientExtensionResults + ? cred.getClientExtensionResults() + : {} + }; +} + +export default { + // passkeys need a browser that has them and a secure page (HTTPS, or localhost) + supported() { + return !!(window.PublicKeyCredential && window.isSecureContext); + }, + + // run navigator.credentials.create() with the server's register-options; returns the JSON to send back + async create(options) { + const o = JSON.parse(JSON.stringify(options)); + o.challenge = toBuffer(o.challenge); + o.user.id = toBuffer(o.user.id); + (o.excludeCredentials || []).forEach(c => (c.id = toBuffer(c.id))); + const cred = await navigator.credentials.create({ publicKey: o }); + return credentialJson(cred, { + clientDataJSON: toB64Url(cred.response.clientDataJSON), + attestationObject: toB64Url(cred.response.attestationObject), + transports: cred.response.getTransports + ? cred.response.getTransports() + : [] + }); + }, + + // run navigator.credentials.get() with the server's login-options; returns the JSON to send back + async get(options) { + const o = JSON.parse(JSON.stringify(options)); + o.challenge = toBuffer(o.challenge); + (o.allowCredentials || []).forEach(c => (c.id = toBuffer(c.id))); + const cred = await navigator.credentials.get({ publicKey: o }); + return credentialJson(cred, { + clientDataJSON: toB64Url(cred.response.clientDataJSON), + authenticatorData: toB64Url(cred.response.authenticatorData), + signature: toB64Url(cred.response.signature), + userHandle: toB64Url(cred.response.userHandle) + }); + } +}; diff --git a/src/plugins/vuetify.js b/src/plugins/vuetify.js index 0a1e228..fe19d62 100644 --- a/src/plugins/vuetify.js +++ b/src/plugins/vuetify.js @@ -193,6 +193,7 @@ import { faSeedling, faServer, faShare, + faFingerprint, faShieldAlt, faShippingFast, faShoppingCart, @@ -391,6 +392,7 @@ library.add( faSeedling, faServer, faShare, + faFingerprint, faShieldAlt, faShippingFast, faShoppingCart, @@ -1281,6 +1283,12 @@ const CUSTOM_ICONS = { icon: ["fas", "shipping-fast"] } }, + sockiFingerprint: { + component: FontAwesomeIcon, + props: { + icon: ["fas", "fingerprint"] + } + }, sockiShieldAlt: { component: FontAwesomeIcon, props: { diff --git a/src/views/adm-user.vue b/src/views/adm-user.vue index fe738c6..126414e 100644 --- a/src/views/adm-user.vue +++ b/src/views/adm-user.vue @@ -824,6 +824,30 @@ export default { window.$gz.errorHandler.handleFormError(error, vm); } }, + //case 4678: for a user who lost their passkeys + async removePasskeys() { + const vm = this; + if ((await window.$gz.dialog.confirmDelete()) != true) { + return; + } + window.$gz.form.deleteAllErrorBoxErrors(vm); + try { + const res = await window.$gz.api.remove( + `auth/passkey/user/${vm.obj.id}` + ); + if (res.error) { + vm.formState.serverError = res.error; + window.$gz.form.setErrorBoxErrors(vm); + } else { + window.$gz.eventBus.$emit( + "notify-success", + vm.$sock.t("PasskeyRemoveAll") + ); + } + } catch (error) { + window.$gz.errorHandler.handleFormError(error, vm); + } + }, async disableTfa() { const vm = this; @@ -907,6 +931,9 @@ async function clickHandler(menuItem) { case "disable2fa": m.vm.disableTfa(); break; + case "removepasskeys": + m.vm.removePasskeys(); + break; case "WorkOrderItemScheduledUserList": m.vm.$router.push({ name: "svc-workorder-item-scheduled-users", @@ -1021,6 +1048,12 @@ function generateMenu(vm) { key: FORM_KEY + ":sendreset", vm: vm }); + menuOptions.menuItems.push({ + title: "PasskeyRemoveAll", + icon: "$sockiFingerprint", + key: FORM_KEY + ":removepasskeys", + vm: vm + }); if (vm.obj.twoFactorEnabled) { menuOptions.menuItems.push({ title: "AuthDisableTwoFactor", @@ -1067,6 +1100,7 @@ async function initForm(vm) { // async function fetchTranslatedText() { await window.$gz.translation.cacheTranslations([ + "PasskeyRemoveAll", "Vendor", "User", "UserSettings", diff --git a/src/views/home-security.vue b/src/views/home-security.vue index 1a5fea9..eb56e02 100644 --- a/src/views/home-security.vue +++ b/src/views/home-security.vue @@ -65,12 +65,59 @@ + + + + {{ $sock.t("Passkeys") }} + {{ $sock.t("PasskeyIntro") }} + +
+ {{ $sock.t("PasskeyNotSupported") }} +
+ + + + {{ k.name }} + + {{ $sock.t("PasskeyCreated") }}: {{ date(k.created) }} +
+ {{ $sock.t("PasskeyLastUsed") }}: {{ date(k.lastUsed) }} + + + + $sockiTrashAlt + + + + +
+
{{ $sock.t("PasskeyNone") }}
+ +
+
+