@ibgib/web-gib 0.0.62 → 0.0.63
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/AUTO-GENERATED-version.d.mts +1 -1
- package/dist/AUTO-GENERATED-version.mjs +1 -1
- package/dist/helpers.web.d.mts +17 -2
- package/dist/helpers.web.d.mts.map +1 -1
- package/dist/helpers.web.mjs +67 -2
- package/dist/helpers.web.mjs.map +1 -1
- package/dist/identity/passkey-helper.d.mts +7 -2
- package/dist/identity/passkey-helper.d.mts.map +1 -1
- package/dist/identity/passkey-helper.mjs +33 -12
- package/dist/identity/passkey-helper.mjs.map +1 -1
- package/dist/ui/component/identity/keystone-creator/keystone-creator.css +26 -0
- package/dist/ui/component/identity/keystone-creator/keystone-creator.d.mts.map +1 -1
- package/dist/ui/component/identity/keystone-creator/keystone-creator.html +18 -1
- package/dist/ui/component/identity/keystone-creator/keystone-creator.mjs +10 -2
- package/dist/ui/component/identity/keystone-creator/keystone-creator.mjs.map +1 -1
- package/dist/ui/component/identity/keystone-details/keystone-details.d.mts +12 -0
- package/dist/ui/component/identity/keystone-details/keystone-details.d.mts.map +1 -1
- package/dist/ui/component/identity/keystone-details/keystone-details.html +8 -0
- package/dist/ui/component/identity/keystone-details/keystone-details.mjs +419 -305
- package/dist/ui/component/identity/keystone-details/keystone-details.mjs.map +1 -1
- package/dist/ui/ui-helpers.d.mts.map +1 -1
- package/dist/ui/ui-helpers.mjs +7 -2
- package/dist/ui/ui-helpers.mjs.map +1 -1
- package/package.json +2 -2
- package/src/AUTO-GENERATED-version.mts +1 -1
- package/src/helpers.web.mts +95 -5
- package/src/identity/passkey-helper.mts +43 -10
- package/src/ui/component/identity/keystone-creator/keystone-creator.css +26 -0
- package/src/ui/component/identity/keystone-creator/keystone-creator.html +18 -1
- package/src/ui/component/identity/keystone-creator/keystone-creator.mts +12 -2
- package/src/ui/component/identity/keystone-details/keystone-details.html +8 -0
- package/src/ui/component/identity/keystone-details/keystone-details.mts +474 -339
- package/src/ui/ui-helpers.mts +6 -2
- package/tools/auto-generated-agent-skills/skills/ibgib-create-app-server/SKILL.md +50 -0
- package/src/identity/custodian-delegate-helper.mts.bak +0 -7
- package/src/identity/sso/sso-config-helper.mts.bak +0 -52
- package/src/identity/sso/sso-custodian-service.mts.bak +0 -566
- package/src/identity/sso/sso-custodian-service.respec.mts.bak +0 -298
package/src/helpers.web.mts
CHANGED
|
@@ -427,9 +427,13 @@ export async function promptForText({
|
|
|
427
427
|
import { evaluatePasskeyPrf, isPasskeyPrfSupported } from "./identity/passkey-helper.mjs";
|
|
428
428
|
import { KeystonePasskeyEntry } from "@ibgib/core-gib/dist/keystone/keystone-types.mjs";
|
|
429
429
|
|
|
430
|
+
export interface AuthoritySecretResult {
|
|
431
|
+
secret: string;
|
|
432
|
+
poolId: string;
|
|
433
|
+
}
|
|
434
|
+
|
|
430
435
|
/**
|
|
431
|
-
*
|
|
432
|
-
* with a known name and get at it that way.
|
|
436
|
+
* Prompts the user for a secret, supporting both master password and passkey/hardware authenticators.
|
|
433
437
|
*/
|
|
434
438
|
export async function promptForSecret({
|
|
435
439
|
msg,
|
|
@@ -444,7 +448,7 @@ export async function promptForSecret({
|
|
|
444
448
|
try {
|
|
445
449
|
if (logalot) { console.log(`${lc} starting... (I: 795a4ce348ddb9a5119770f4c572ac24)`); }
|
|
446
450
|
|
|
447
|
-
let result:
|
|
451
|
+
let result: any = undefined;
|
|
448
452
|
|
|
449
453
|
let firstTry = true;
|
|
450
454
|
let attempts = 0;
|
|
@@ -458,6 +462,7 @@ export async function promptForSecret({
|
|
|
458
462
|
credentialId: passkeyEntry.credentialId,
|
|
459
463
|
salt: passkeyEntry.salt,
|
|
460
464
|
transports: passkeyEntry.transports,
|
|
465
|
+
userVerification: 'preferred'
|
|
461
466
|
});
|
|
462
467
|
return passkeySecret;
|
|
463
468
|
}
|
|
@@ -481,9 +486,13 @@ export async function promptForSecret({
|
|
|
481
486
|
});
|
|
482
487
|
if (result === undefined) { throw new Error(`user cancelled (E: 91519d04378e84106f3df8eb36d99725)`); }
|
|
483
488
|
|
|
489
|
+
if (typeof result === 'object' && result?.secret !== undefined) {
|
|
490
|
+
result = result.secret;
|
|
491
|
+
}
|
|
492
|
+
|
|
484
493
|
if (confirm) {
|
|
485
494
|
await delay(250);
|
|
486
|
-
let result2 = await showFullscreenDialog({
|
|
495
|
+
let result2: any = await showFullscreenDialog({
|
|
487
496
|
title: 'Shh...',
|
|
488
497
|
msg: 'Confirm...',
|
|
489
498
|
prompt: true,
|
|
@@ -492,6 +501,9 @@ export async function promptForSecret({
|
|
|
492
501
|
defaultValue: undefined,
|
|
493
502
|
});
|
|
494
503
|
if (result2 === undefined) { result = undefined; }
|
|
504
|
+
if (typeof result2 === 'object' && result2?.secret !== undefined) {
|
|
505
|
+
result2 = result2.secret;
|
|
506
|
+
}
|
|
495
507
|
if (result !== result2) {
|
|
496
508
|
result = undefined;
|
|
497
509
|
firstTry = false;
|
|
@@ -499,7 +511,6 @@ export async function promptForSecret({
|
|
|
499
511
|
}
|
|
500
512
|
}
|
|
501
513
|
|
|
502
|
-
|
|
503
514
|
} while (result === undefined);
|
|
504
515
|
|
|
505
516
|
return result;
|
|
@@ -511,6 +522,85 @@ export async function promptForSecret({
|
|
|
511
522
|
}
|
|
512
523
|
}
|
|
513
524
|
|
|
525
|
+
/**
|
|
526
|
+
* Unified authority prompt that inspects the candidate pools on a keystone and prompts the user
|
|
527
|
+
* to authorize via either Master Password or a specific Passkey.
|
|
528
|
+
* Returns both the verified secret and the exact signingPoolId to use for keystone operations.
|
|
529
|
+
*/
|
|
530
|
+
export async function promptForAuthoritySecret({
|
|
531
|
+
keystone,
|
|
532
|
+
aggregatedDetails,
|
|
533
|
+
title,
|
|
534
|
+
msg,
|
|
535
|
+
verb = 'manage',
|
|
536
|
+
}: {
|
|
537
|
+
keystone: any;
|
|
538
|
+
aggregatedDetails?: any;
|
|
539
|
+
title?: string;
|
|
540
|
+
msg?: string;
|
|
541
|
+
verb?: string;
|
|
542
|
+
}): Promise<AuthoritySecretResult> {
|
|
543
|
+
const lc = `[${promptForAuthoritySecret.name}]`;
|
|
544
|
+
try {
|
|
545
|
+
const passkeysMap = aggregatedDetails?.passkeys
|
|
546
|
+
|| (keystone.data as any)?.frameDetails?.passkeys
|
|
547
|
+
|| (keystone.data as any)?.passkeys
|
|
548
|
+
|| {};
|
|
549
|
+
|
|
550
|
+
const prfSupported = await isPasskeyPrfSupported().catch(() => false);
|
|
551
|
+
const onPasskeyAuth = (prfSupported && passkeysMap && Object.keys(passkeysMap).length > 0)
|
|
552
|
+
? async (passkeyEntry?: any) => {
|
|
553
|
+
if (!passkeyEntry) return undefined;
|
|
554
|
+
const { passkeySecret } = await evaluatePasskeyPrf({
|
|
555
|
+
credentialId: passkeyEntry.credentialId,
|
|
556
|
+
salt: passkeyEntry.salt,
|
|
557
|
+
transports: passkeyEntry.transports,
|
|
558
|
+
userVerification: 'preferred'
|
|
559
|
+
});
|
|
560
|
+
return passkeySecret;
|
|
561
|
+
}
|
|
562
|
+
: undefined;
|
|
563
|
+
|
|
564
|
+
const candidatePools = (keystone.data?.challengePools || []).filter((p: any) =>
|
|
565
|
+
p.id === 'manage' ||
|
|
566
|
+
p.id.startsWith('passkey-manage-') ||
|
|
567
|
+
(p.config?.allowedVerbs && verb && p.config.allowedVerbs.includes(verb))
|
|
568
|
+
);
|
|
569
|
+
|
|
570
|
+
const hasPasswordPool = candidatePools.some((p: any) => p.id === 'manage');
|
|
571
|
+
|
|
572
|
+
const dialogResult: any = await showFullscreenDialog({
|
|
573
|
+
title: title || 'Authorize Action',
|
|
574
|
+
msg: msg || 'Authorize this action with your master password or passkey:',
|
|
575
|
+
prompt: true,
|
|
576
|
+
showInput: hasPasswordPool,
|
|
577
|
+
isPassword: true,
|
|
578
|
+
defaultValue: undefined,
|
|
579
|
+
passkeys: passkeysMap,
|
|
580
|
+
onPasskeyAuth,
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
if (!dialogResult) {
|
|
584
|
+
throw new Error("Action cancelled by user.");
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (typeof dialogResult === 'object' && dialogResult.secret !== undefined) {
|
|
588
|
+
return {
|
|
589
|
+
secret: dialogResult.secret,
|
|
590
|
+
poolId: dialogResult.poolId || 'manage'
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return {
|
|
595
|
+
secret: String(dialogResult),
|
|
596
|
+
poolId: 'manage'
|
|
597
|
+
};
|
|
598
|
+
} catch (error) {
|
|
599
|
+
console.error(`${lc} ${extractErrorMsg(error)}`);
|
|
600
|
+
throw error;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
514
604
|
/**
|
|
515
605
|
* prompt specifically looking to confirm something. if "cancelled", returns
|
|
516
606
|
* false.
|
|
@@ -91,12 +91,18 @@ export async function registerPasskeyCredential({
|
|
|
91
91
|
rpName = 'ibgib',
|
|
92
92
|
rpId,
|
|
93
93
|
salt,
|
|
94
|
+
userVerification = 'preferred',
|
|
95
|
+
authenticatorAttachment,
|
|
96
|
+
hints = ['security-key', 'client-device', 'hybrid'],
|
|
94
97
|
}: {
|
|
95
98
|
username: string;
|
|
96
99
|
displayName?: string;
|
|
97
100
|
rpName?: string;
|
|
98
101
|
rpId?: string;
|
|
99
102
|
salt?: string | Uint8Array;
|
|
103
|
+
userVerification?: UserVerificationRequirement;
|
|
104
|
+
authenticatorAttachment?: AuthenticatorAttachment;
|
|
105
|
+
hints?: ('security-key' | 'client-device' | 'hybrid')[];
|
|
100
106
|
}): Promise<{
|
|
101
107
|
credentialId: string;
|
|
102
108
|
rawId: ArrayBuffer;
|
|
@@ -137,8 +143,10 @@ export async function registerPasskeyCredential({
|
|
|
137
143
|
],
|
|
138
144
|
authenticatorSelection: {
|
|
139
145
|
residentKey: 'required',
|
|
140
|
-
userVerification
|
|
146
|
+
userVerification,
|
|
147
|
+
...(authenticatorAttachment ? { authenticatorAttachment } : {})
|
|
141
148
|
},
|
|
149
|
+
...(hints && hints.length > 0 ? { hints } : {}),
|
|
142
150
|
extensions: {
|
|
143
151
|
prf: prfExtension
|
|
144
152
|
} as any
|
|
@@ -184,11 +192,15 @@ export async function evaluatePasskeyPrf({
|
|
|
184
192
|
credentialId,
|
|
185
193
|
transports,
|
|
186
194
|
rpId,
|
|
195
|
+
userVerification = 'preferred',
|
|
196
|
+
hints = ['security-key', 'client-device', 'hybrid'],
|
|
187
197
|
}: {
|
|
188
198
|
salt: string | Uint8Array;
|
|
189
199
|
credentialId?: string;
|
|
190
200
|
transports?: AuthenticatorTransport[];
|
|
191
201
|
rpId?: string;
|
|
202
|
+
userVerification?: UserVerificationRequirement;
|
|
203
|
+
hints?: ('security-key' | 'client-device' | 'hybrid')[];
|
|
192
204
|
}): Promise<{
|
|
193
205
|
passkeySecret: string;
|
|
194
206
|
credentialId: string;
|
|
@@ -203,6 +215,7 @@ export async function evaluatePasskeyPrf({
|
|
|
203
215
|
const challenge = crypto.getRandomValues(new Uint8Array(32));
|
|
204
216
|
const saltBuffer = normalizeSaltTo32Bytes(salt);
|
|
205
217
|
|
|
218
|
+
let credentialIdBase64Url: string | undefined = undefined;
|
|
206
219
|
let allowCredentials: PublicKeyCredentialDescriptor[] | undefined = undefined;
|
|
207
220
|
if (credentialId) {
|
|
208
221
|
const rawBytes = hexToUint8Array(credentialId);
|
|
@@ -211,6 +224,24 @@ export async function evaluatePasskeyPrf({
|
|
|
211
224
|
type: 'public-key' as const,
|
|
212
225
|
...(transports && transports.length > 0 ? { transports } : {})
|
|
213
226
|
}];
|
|
227
|
+
let binary = '';
|
|
228
|
+
for (let i = 0; i < rawBytes.byteLength; i++) {
|
|
229
|
+
binary += String.fromCharCode(rawBytes[i]);
|
|
230
|
+
}
|
|
231
|
+
credentialIdBase64Url = btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const prfExtension: any = {
|
|
235
|
+
eval: {
|
|
236
|
+
first: saltBuffer
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
if (credentialIdBase64Url) {
|
|
240
|
+
prfExtension.evalByCredential = {
|
|
241
|
+
[credentialIdBase64Url]: {
|
|
242
|
+
first: saltBuffer
|
|
243
|
+
}
|
|
244
|
+
};
|
|
214
245
|
}
|
|
215
246
|
|
|
216
247
|
const assertion = await navigator.credentials.get({
|
|
@@ -218,13 +249,10 @@ export async function evaluatePasskeyPrf({
|
|
|
218
249
|
challenge,
|
|
219
250
|
...(effectiveRpId ? { rpId: effectiveRpId } : {}),
|
|
220
251
|
allowCredentials,
|
|
221
|
-
userVerification
|
|
252
|
+
userVerification,
|
|
253
|
+
...(hints && hints.length > 0 ? { hints } : {}),
|
|
222
254
|
extensions: {
|
|
223
|
-
prf:
|
|
224
|
-
eval: {
|
|
225
|
-
first: saltBuffer
|
|
226
|
-
}
|
|
227
|
-
}
|
|
255
|
+
prf: prfExtension
|
|
228
256
|
} as any
|
|
229
257
|
}
|
|
230
258
|
}) as PublicKeyCredential;
|
|
@@ -235,11 +263,16 @@ export async function evaluatePasskeyPrf({
|
|
|
235
263
|
|
|
236
264
|
const resolvedCredentialId = bufferToHex(assertion.rawId);
|
|
237
265
|
const extResults: any = assertion.getClientExtensionResults?.() || {};
|
|
238
|
-
console.log(`${lc} WebAuthn assertion clientExtensionResults:`, extResults);
|
|
239
|
-
|
|
266
|
+
console.log(`${lc} WebAuthn assertion clientExtensionResults:`, JSON.stringify(extResults), extResults);
|
|
267
|
+
|
|
268
|
+
let prfResult: ArrayBuffer | undefined = extResults.prf?.results?.first;
|
|
269
|
+
if (!prfResult && credentialIdBase64Url && extResults.prf?.resultsByCredential?.[credentialIdBase64Url]?.first) {
|
|
270
|
+
prfResult = extResults.prf.resultsByCredential[credentialIdBase64Url].first;
|
|
271
|
+
console.log(`${lc} Extracted PRF result from resultsByCredential!`);
|
|
272
|
+
}
|
|
240
273
|
|
|
241
274
|
if (!prfResult) {
|
|
242
|
-
console.error(`${lc} Extension results did not contain PRF evaluation:`, extResults);
|
|
275
|
+
console.error(`${lc} Extension results did not contain PRF evaluation:`, JSON.stringify(extResults), extResults);
|
|
243
276
|
throw new Error('Authenticator did not return a PRF evaluation. PRF extension may not be supported on this authenticator.');
|
|
244
277
|
}
|
|
245
278
|
|
|
@@ -199,4 +199,30 @@ h2 {
|
|
|
199
199
|
input::placeholder {
|
|
200
200
|
color: rgba(255, 255, 255, 0.35);
|
|
201
201
|
opacity: 1;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
.public-directory-notice {
|
|
205
|
+
padding: 0.75rem 1rem;
|
|
206
|
+
border-radius: 8px;
|
|
207
|
+
background: rgba(137, 180, 250, 0.08);
|
|
208
|
+
border: 1px solid rgba(137, 180, 250, 0.25);
|
|
209
|
+
display: flex;
|
|
210
|
+
gap: 0.6rem;
|
|
211
|
+
align-items: flex-start;
|
|
212
|
+
text-align: left;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.notice-icon {
|
|
216
|
+
font-size: 1.1rem;
|
|
217
|
+
line-height: 1.2;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
.notice-body {
|
|
221
|
+
font-size: 0.82rem;
|
|
222
|
+
color: var(--clr-text, #cdd6f4);
|
|
223
|
+
line-height: 1.45;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
.notice-body strong {
|
|
227
|
+
color: #89b4fa;
|
|
202
228
|
}
|
|
@@ -80,6 +80,15 @@
|
|
|
80
80
|
<input type="text" id="input-passkey-name" placeholder="e.g. MacBook Touch ID, YubiKey 5Ci, Android Phone..." class="text-input">
|
|
81
81
|
</div>
|
|
82
82
|
|
|
83
|
+
<div class="input-group">
|
|
84
|
+
<label for="select-passkey-attachment">Authenticator Device Target</label>
|
|
85
|
+
<select id="select-passkey-attachment" class="text-input" style="cursor: pointer;">
|
|
86
|
+
<option value="any" selected>Any / Smart Chooser (All Options, No Forced Windows PIN)</option>
|
|
87
|
+
<option value="cross-platform">Hardware Security Key (USB / NFC YubiKey Directly)</option>
|
|
88
|
+
<option value="platform">Built-in Biometrics (Windows Hello, Touch ID, Fingerprint)</option>
|
|
89
|
+
</select>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
83
92
|
<div class="checkbox-group" style="margin-top: 0.5rem; margin-bottom: 1rem; display: flex; align-items: center; gap: 0.5rem;">
|
|
84
93
|
<input type="checkbox" id="checkbox-passkey-make-active" checked style="cursor: pointer; width: 1rem; height: 1rem;">
|
|
85
94
|
<label for="checkbox-passkey-make-active" style="cursor: pointer; font-size: 0.9rem; user-select: none;">Make this the active identity</label>
|
|
@@ -150,13 +159,21 @@
|
|
|
150
159
|
<p class="warning">⚠️ Store this address safely. It is your root identity for this space.</p>
|
|
151
160
|
</div>
|
|
152
161
|
|
|
162
|
+
<!-- Public Directory Notice Callout -->
|
|
163
|
+
<div class="public-directory-notice">
|
|
164
|
+
<span class="notice-icon">ℹ️</span>
|
|
165
|
+
<div class="notice-body">
|
|
166
|
+
<strong>Public Directory Notice:</strong> Your username and registered email address are associated with your public identity keystone and can be queried on the network (similar to a public PGP key or public DNS record).
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
|
|
153
170
|
<div class="switch-workflow" style="margin-top: 1.2rem; text-align: center; font-size: 0.9rem; color: #a6adc8;">
|
|
154
171
|
Already have a device paired? <button id="btn-switch-sign-in" class="link-btn" style="background: none; border: none; color: #89b4fa; font-weight: 600; cursor: pointer; text-decoration: underline; padding: 0; font-size: 0.9rem;">Pair New Device</button>
|
|
155
172
|
</div>
|
|
156
173
|
|
|
157
174
|
<!-- Privacy Footer Note -->
|
|
158
175
|
<div class="privacy-footer" style="margin-top: 1rem; text-align: center; font-size: 0.78rem; color: #a6adc8; line-height: 1.4;">
|
|
159
|
-
|
|
176
|
+
Identity keystones form part of the public graph for account discovery. Learn more in our <a id="link-privacy-policy" href="/privacy" target="_blank" rel="noopener" style="color: #89b4fa; text-decoration: underline;">Privacy Policy</a>.
|
|
160
177
|
</div>
|
|
161
178
|
</div>
|
|
162
179
|
</div>
|
|
@@ -696,12 +696,21 @@ export class KeystoneCreatorComponentInstance
|
|
|
696
696
|
animationEmoji: '🛡️'
|
|
697
697
|
});
|
|
698
698
|
|
|
699
|
+
const selectAttachment = this.shadowRoot?.querySelector('#select-passkey-attachment') as HTMLSelectElement | null;
|
|
700
|
+
const attachmentVal = selectAttachment?.value;
|
|
701
|
+
const authenticatorAttachment: AuthenticatorAttachment | undefined =
|
|
702
|
+
(attachmentVal === 'cross-platform' || attachmentVal === 'platform')
|
|
703
|
+
? attachmentVal
|
|
704
|
+
: undefined;
|
|
705
|
+
|
|
699
706
|
const rawSalt = crypto.getRandomValues(new Uint8Array(32));
|
|
700
707
|
const saltHex = bufferToHex(rawSalt.buffer as ArrayBuffer);
|
|
701
708
|
const regResult = await registerPasskeyCredential({
|
|
702
709
|
username,
|
|
703
710
|
displayName: username,
|
|
704
|
-
salt: saltHex
|
|
711
|
+
salt: saltHex,
|
|
712
|
+
authenticatorAttachment,
|
|
713
|
+
userVerification: 'preferred'
|
|
705
714
|
});
|
|
706
715
|
const { credentialId, transports } = regResult;
|
|
707
716
|
|
|
@@ -710,7 +719,8 @@ export class KeystoneCreatorComponentInstance
|
|
|
710
719
|
const prfResult = await evaluatePasskeyPrf({
|
|
711
720
|
credentialId,
|
|
712
721
|
salt: saltHex,
|
|
713
|
-
transports
|
|
722
|
+
transports,
|
|
723
|
+
userVerification: 'preferred'
|
|
714
724
|
});
|
|
715
725
|
passkeySecret = prfResult.passkeySecret;
|
|
716
726
|
}
|
|
@@ -97,6 +97,14 @@
|
|
|
97
97
|
</tbody>
|
|
98
98
|
</table>
|
|
99
99
|
</div>
|
|
100
|
+
<div class="form-group" style="margin-top: 1rem; max-width: 360px;">
|
|
101
|
+
<label for="select-passkey-attachment" style="font-size: 0.85rem; color: var(--clr-text-secondary, #b3b3b3);">Authenticator Device Target</label>
|
|
102
|
+
<select id="select-passkey-attachment" class="form-control" style="width: 100%; padding: 0.4rem 0.6rem; margin-top: 0.25rem; border-radius: 4px; background: rgba(0,0,0,0.3); color: inherit; border: 1px solid rgba(255,255,255,0.2);">
|
|
103
|
+
<option value="">Any (Default / Auto-Detect)</option>
|
|
104
|
+
<option value="cross-platform">🔑 Hardware Security Key (USB / NFC)</option>
|
|
105
|
+
<option value="platform">💻 Built-in Biometrics (Fingerprint / Touch ID)</option>
|
|
106
|
+
</select>
|
|
107
|
+
</div>
|
|
100
108
|
<div class="action-row" style="margin-top: 0.5rem;">
|
|
101
109
|
<button id="btn-add-passkey" class="action-btn">
|
|
102
110
|
🛡️ Register New Passkey / Security Key
|