@things-factory/auth-ui 10.1.205 → 10.1.208

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.
Files changed (32) hide show
  1. package/dist-client/auth-style-sign.js +15 -7
  2. package/dist-client/auth-style-sign.js.map +1 -1
  3. package/dist-client/components/abstract-sign.d.ts +9 -1
  4. package/dist-client/components/abstract-sign.js +48 -28
  5. package/dist-client/components/abstract-sign.js.map +1 -1
  6. package/dist-client/components/my-passkeys.d.ts +31 -0
  7. package/dist-client/components/my-passkeys.js +247 -0
  8. package/dist-client/components/my-passkeys.js.map +1 -0
  9. package/dist-client/components/profile-component.d.ts +1 -1
  10. package/dist-client/components/profile-component.js +4 -53
  11. package/dist-client/components/profile-component.js.map +1 -1
  12. package/dist-client/entries/auth/passkey-offer.d.ts +27 -0
  13. package/dist-client/entries/auth/passkey-offer.js +161 -0
  14. package/dist-client/entries/auth/passkey-offer.js.map +1 -0
  15. package/dist-client/tsconfig.tsbuildinfo +1 -1
  16. package/dist-client/utils/audit-words.js +3 -1
  17. package/dist-client/utils/audit-words.js.map +1 -1
  18. package/dist-client/utils/audit-words.test.js +6 -0
  19. package/dist-client/utils/audit-words.test.js.map +1 -1
  20. package/dist-client/utils/passkey.d.ts +38 -0
  21. package/dist-client/utils/passkey.js +84 -0
  22. package/dist-client/utils/passkey.js.map +1 -0
  23. package/dist-client/utils/passkey.test.d.ts +1 -0
  24. package/dist-client/utils/passkey.test.js +75 -0
  25. package/dist-client/utils/passkey.test.js.map +1 -0
  26. package/package.json +5 -5
  27. package/translations/en.json +17 -1
  28. package/translations/es.json +17 -1
  29. package/translations/ja.json +17 -1
  30. package/translations/ko.json +18 -2
  31. package/translations/ms.json +17 -1
  32. package/translations/zh.json +17 -1
@@ -33,7 +33,9 @@ export function actionWords(t, action) {
33
33
  const change = action.kind === 'user.status' && action.detail
34
34
  ? ` ${statusWords(t, action.detail.from)} → ${statusWords(t, action.detail.to)}`
35
35
  : '';
36
- return `${kind}${change}${failed}${times}`;
36
+ /* A password sign-in is the usual one and says nothing more; a passkey says so (ADR-0156). */
37
+ const method = action.kind === 'auth.signin' && action.detail?.method === 'passkey' ? ` (${t('audit.method.passkey')})` : '';
38
+ return `${kind}${method}${change}${failed}${times}`;
37
39
  }
38
40
  /**
39
41
  * A request's actions in one line - refusals and errors first, then events, then the doors passed.
@@ -1 +1 @@
1
- {"version":3,"file":"audit-words.js","sourceRoot":"","sources":["../../client/utils/audit-words.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * How the audit screen says what an audit row holds (ADR-0141 §6). The rows hold codes; the screen\n * shows words. Pure, so the words are tested without a screen.\n */\n\ntype Translate = (key: string, options?: Record<string, unknown>) => string\n\nexport interface AuditActionRow {\n kind: string\n door?: string | null\n decision?: string | null\n outcome?: string | null\n code?: string | null\n count?: number | null\n objectId?: string | null\n detail?: Record<string, unknown> | null\n}\n\n/** The door in words: the category's name and the declared axis - `payroll:query` → 「급여 query」. */\nexport function doorWords(t: Translate, door?: string | null): string {\n if (!door) return ''\n if (!door.includes(':')) {\n return door\n .split('+')\n .map(part => t(`audit.door.${part}`, { defaultValue: part }))\n .join(' · ')\n }\n const [category, privilege] = door.split(':')\n return `${t(`privilege.category.${category}`, { defaultValue: category })} ${privilege}`\n}\n\nfunction statusWords(t: Translate, status: unknown) {\n return t(`label.${status}-user`, { defaultValue: String(status ?? '') })\n}\n\n/** One action in words, with how many times when more than once. */\nexport function actionWords(t: Translate, action: AuditActionRow): string {\n const times = (action.count ?? 1) > 1 ? ` ×${action.count}` : ''\n\n if (action.kind === 'door.check') {\n const refused = action.decision === 'denied' ? ` ${t('audit.outcome.denied')}` : ''\n return `${doorWords(t, action.door)}${refused}${times}`\n }\n if (action.kind === 'door.result') {\n return action.outcome === 'error' ? `${doorWords(t, action.door)} ${t('audit.outcome.error')}${times}` : ''\n }\n\n const kind = t(`audit.kind.${action.kind}`, { defaultValue: action.kind })\n const failed = action.outcome === 'error' ? ` ${t('audit.outcome.failed')}` : ''\n const change =\n action.kind === 'user.status' && action.detail\n ? ` ${statusWords(t, action.detail.from)} → ${statusWords(t, action.detail.to)}`\n : ''\n return `${kind}${change}${failed}${times}`\n}\n\n/**\n * A request's actions in one line - refusals and errors first, then events, then the doors passed.\n * A door passed and its result say the same thing twice, so only an error result is shown.\n */\nexport function recordWords(t: Translate, actions: AuditActionRow[]): string {\n const weight = (action: AuditActionRow) =>\n action.decision === 'denied' || action.outcome === 'error' ? 0 : action.kind.startsWith('door.') ? 2 : 1\n return [...actions]\n .sort((a, b) => weight(a) - weight(b))\n .map(action => actionWords(t, action))\n .filter(Boolean)\n .join(', ')\n}\n\n/** The last `days` days up to `now`. */\nexport function lastDays(days: number, now = new Date()): { from: Date; to: Date } {\n const from = new Date(now)\n from.setHours(0, 0, 0, 0)\n from.setDate(from.getDate() - (days - 1))\n return { from, to: now }\n}\n"]}
1
+ {"version":3,"file":"audit-words.js","sourceRoot":"","sources":["../../client/utils/audit-words.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * How the audit screen says what an audit row holds (ADR-0141 §6). The rows hold codes; the screen\n * shows words. Pure, so the words are tested without a screen.\n */\n\ntype Translate = (key: string, options?: Record<string, unknown>) => string\n\nexport interface AuditActionRow {\n kind: string\n door?: string | null\n decision?: string | null\n outcome?: string | null\n code?: string | null\n count?: number | null\n objectId?: string | null\n detail?: Record<string, unknown> | null\n}\n\n/** The door in words: the category's name and the declared axis - `payroll:query` → 「급여 query」. */\nexport function doorWords(t: Translate, door?: string | null): string {\n if (!door) return ''\n if (!door.includes(':')) {\n return door\n .split('+')\n .map(part => t(`audit.door.${part}`, { defaultValue: part }))\n .join(' · ')\n }\n const [category, privilege] = door.split(':')\n return `${t(`privilege.category.${category}`, { defaultValue: category })} ${privilege}`\n}\n\nfunction statusWords(t: Translate, status: unknown) {\n return t(`label.${status}-user`, { defaultValue: String(status ?? '') })\n}\n\n/** One action in words, with how many times when more than once. */\nexport function actionWords(t: Translate, action: AuditActionRow): string {\n const times = (action.count ?? 1) > 1 ? ` ×${action.count}` : ''\n\n if (action.kind === 'door.check') {\n const refused = action.decision === 'denied' ? ` ${t('audit.outcome.denied')}` : ''\n return `${doorWords(t, action.door)}${refused}${times}`\n }\n if (action.kind === 'door.result') {\n return action.outcome === 'error' ? `${doorWords(t, action.door)} ${t('audit.outcome.error')}${times}` : ''\n }\n\n const kind = t(`audit.kind.${action.kind}`, { defaultValue: action.kind })\n const failed = action.outcome === 'error' ? ` ${t('audit.outcome.failed')}` : ''\n const change =\n action.kind === 'user.status' && action.detail\n ? ` ${statusWords(t, action.detail.from)} → ${statusWords(t, action.detail.to)}`\n : ''\n /* A password sign-in is the usual one and says nothing more; a passkey says so (ADR-0156). */\n const method = action.kind === 'auth.signin' && action.detail?.method === 'passkey' ? ` (${t('audit.method.passkey')})` : ''\n return `${kind}${method}${change}${failed}${times}`\n}\n\n/**\n * A request's actions in one line - refusals and errors first, then events, then the doors passed.\n * A door passed and its result say the same thing twice, so only an error result is shown.\n */\nexport function recordWords(t: Translate, actions: AuditActionRow[]): string {\n const weight = (action: AuditActionRow) =>\n action.decision === 'denied' || action.outcome === 'error' ? 0 : action.kind.startsWith('door.') ? 2 : 1\n return [...actions]\n .sort((a, b) => weight(a) - weight(b))\n .map(action => actionWords(t, action))\n .filter(Boolean)\n .join(', ')\n}\n\n/** The last `days` days up to `now`. */\nexport function lastDays(days: number, now = new Date()): { from: Date; to: Date } {\n const from = new Date(now)\n from.setHours(0, 0, 0, 0)\n from.setDate(from.getDate() - (days - 1))\n return { from, to: now }\n}\n"]}
@@ -6,6 +6,7 @@ const WORDS = {
6
6
  'audit.outcome.error': '오류',
7
7
  'audit.outcome.failed': '실패',
8
8
  'audit.kind.auth.signin': '로그인',
9
+ 'audit.method.passkey': '패스키',
9
10
  'audit.kind.role.grant': '역할 부여',
10
11
  'audit.kind.user.status': '계정 상태',
11
12
  'label.activated-user': '정상',
@@ -29,6 +30,11 @@ describe('the audit screen says what a row holds', () => {
29
30
  expect(actionWords(t, { kind: 'auth.signin', outcome: 'error', code: 'user not found' })).toBe('로그인 실패');
30
31
  expect(actionWords(t, { kind: 'user.status', detail: { from: 'activated', to: 'locked' } })).toBe('계정 상태 정상 → 잠김');
31
32
  });
33
+ it('a passkey sign-in says so; a password sign-in says nothing more', () => {
34
+ expect(actionWords(t, { kind: 'auth.signin', outcome: 'ok', detail: { method: 'passkey' } })).toBe('로그인 (패스키)');
35
+ expect(actionWords(t, { kind: 'auth.signin', outcome: 'error', detail: { method: 'passkey' } })).toBe('로그인 (패스키) 실패');
36
+ expect(actionWords(t, { kind: 'auth.signin', outcome: 'ok', detail: { method: 'password' } })).toBe('로그인');
37
+ });
32
38
  it('a request reads refusals first, then events, then doors passed', () => {
33
39
  expect(recordWords(t, [
34
40
  { kind: 'door.check', door: 'payroll:query', decision: 'granted' },
@@ -1 +1 @@
1
- {"version":3,"file":"audit-words.test.js","sourceRoot":"","sources":["../../client/utils/audit-words.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { actionWords, doorWords, lastDays, recordWords } from './audit-words'\n\nconst WORDS: Record<string, string> = {\n 'privilege.category.payroll': '급여',\n 'audit.door.super': '시스템 소유자',\n 'audit.outcome.denied': '거절',\n 'audit.outcome.error': '오류',\n 'audit.outcome.failed': '실패',\n 'audit.kind.auth.signin': '로그인',\n 'audit.kind.role.grant': '역할 부여',\n 'audit.kind.user.status': '계정 상태',\n 'label.activated-user': '정상',\n 'label.locked-user': '잠김'\n}\nconst t = (key: string, options?: any) => WORDS[key] ?? options?.defaultValue ?? key\n\ndescribe('the audit screen says what a row holds', () => {\n it('a door is its category in words and the declared axis', () => {\n expect(doorWords(t, 'payroll:query')).toBe('급여 query')\n expect(doorWords(t, 'super')).toBe('시스템 소유자')\n expect(doorWords(t, 'unknown-category:query')).toBe('unknown-category query')\n })\n\n it('a refusal says so, with how many times', () => {\n expect(actionWords(t, { kind: 'door.check', door: 'payroll:query', decision: 'denied', count: 3 })).toBe('급여 query 거절 ×3')\n })\n\n it('a result is shown only when it failed - passing already said it ran', () => {\n expect(actionWords(t, { kind: 'door.result', door: 'payroll:query', outcome: 'ok' })).toBe('')\n expect(actionWords(t, { kind: 'door.result', door: 'payroll:query', outcome: 'error' })).toBe('급여 query 오류')\n })\n\n it('an event in words, a status change with both states', () => {\n expect(actionWords(t, { kind: 'auth.signin', outcome: 'error', code: 'user not found' })).toBe('로그인 실패')\n expect(actionWords(t, { kind: 'user.status', detail: { from: 'activated', to: 'locked' } })).toBe('계정 상태 정상 → 잠김')\n })\n\n it('a request reads refusals first, then events, then doors passed', () => {\n expect(\n recordWords(t, [\n { kind: 'door.check', door: 'payroll:query', decision: 'granted' },\n { kind: 'door.result', door: 'payroll:query', outcome: 'ok' },\n { kind: 'role.grant' },\n { kind: 'door.check', door: 'payroll:mutation', decision: 'denied' }\n ])\n ).toBe('급여 mutation 거절, 역할 부여, 급여 query')\n })\n\n it('the last seven days start at midnight six days back', () => {\n const now = new Date(2026, 9, 19, 15, 30)\n const { from, to } = lastDays(7, now)\n expect(from).toEqual(new Date(2026, 9, 13, 0, 0))\n expect(to).toBe(now)\n })\n})\n"]}
1
+ {"version":3,"file":"audit-words.test.js","sourceRoot":"","sources":["../../client/utils/audit-words.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { actionWords, doorWords, lastDays, recordWords } from './audit-words'\n\nconst WORDS: Record<string, string> = {\n 'privilege.category.payroll': '급여',\n 'audit.door.super': '시스템 소유자',\n 'audit.outcome.denied': '거절',\n 'audit.outcome.error': '오류',\n 'audit.outcome.failed': '실패',\n 'audit.kind.auth.signin': '로그인',\n 'audit.method.passkey': '패스키',\n 'audit.kind.role.grant': '역할 부여',\n 'audit.kind.user.status': '계정 상태',\n 'label.activated-user': '정상',\n 'label.locked-user': '잠김'\n}\nconst t = (key: string, options?: any) => WORDS[key] ?? options?.defaultValue ?? key\n\ndescribe('the audit screen says what a row holds', () => {\n it('a door is its category in words and the declared axis', () => {\n expect(doorWords(t, 'payroll:query')).toBe('급여 query')\n expect(doorWords(t, 'super')).toBe('시스템 소유자')\n expect(doorWords(t, 'unknown-category:query')).toBe('unknown-category query')\n })\n\n it('a refusal says so, with how many times', () => {\n expect(actionWords(t, { kind: 'door.check', door: 'payroll:query', decision: 'denied', count: 3 })).toBe('급여 query 거절 ×3')\n })\n\n it('a result is shown only when it failed - passing already said it ran', () => {\n expect(actionWords(t, { kind: 'door.result', door: 'payroll:query', outcome: 'ok' })).toBe('')\n expect(actionWords(t, { kind: 'door.result', door: 'payroll:query', outcome: 'error' })).toBe('급여 query 오류')\n })\n\n it('an event in words, a status change with both states', () => {\n expect(actionWords(t, { kind: 'auth.signin', outcome: 'error', code: 'user not found' })).toBe('로그인 실패')\n expect(actionWords(t, { kind: 'user.status', detail: { from: 'activated', to: 'locked' } })).toBe('계정 상태 정상 → 잠김')\n })\n\n it('a passkey sign-in says so; a password sign-in says nothing more', () => {\n expect(actionWords(t, { kind: 'auth.signin', outcome: 'ok', detail: { method: 'passkey' } })).toBe('로그인 (패스키)')\n expect(actionWords(t, { kind: 'auth.signin', outcome: 'error', detail: { method: 'passkey' } })).toBe('로그인 (패스키) 실패')\n expect(actionWords(t, { kind: 'auth.signin', outcome: 'ok', detail: { method: 'password' } })).toBe('로그인')\n })\n\n it('a request reads refusals first, then events, then doors passed', () => {\n expect(\n recordWords(t, [\n { kind: 'door.check', door: 'payroll:query', decision: 'granted' },\n { kind: 'door.result', door: 'payroll:query', outcome: 'ok' },\n { kind: 'role.grant' },\n { kind: 'door.check', door: 'payroll:mutation', decision: 'denied' }\n ])\n ).toBe('급여 mutation 거절, 역할 부여, 급여 query')\n })\n\n it('the last seven days start at midnight six days back', () => {\n const now = new Date(2026, 9, 19, 15, 30)\n const { from, to } = lastDays(7, now)\n expect(from).toEqual(new Date(2026, 9, 13, 0, 0))\n expect(to).toBe(now)\n })\n})\n"]}
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The rules the passkey screens follow (ADR-0156), apart from the screens so they are tested.
3
+ */
4
+ /**
5
+ * A label for a passkey made here - the system and the browser, 「macOS · Chrome」. The person can
6
+ * rename it; this is only what the list shows until they do.
7
+ */
8
+ export declare function deviceLabel(userAgent: string): string;
9
+ export type SigninAnswer = {
10
+ outcome: 'signed-in';
11
+ redirectURL: string;
12
+ } | {
13
+ outcome: 'refused';
14
+ message: string;
15
+ };
16
+ /**
17
+ * What the passkey sign-in answered. A refusal comes back as 401 with the refusal, already in the
18
+ * person's language, as a plain-text body (`authenticate401Middleware`). Never throws.
19
+ */
20
+ export declare function readSigninAnswer(response: Response): Promise<SigninAnswer>;
21
+ /**
22
+ * The browser said no: the person closed the prompt, or let it time out. Nothing to report - they
23
+ * know. Other errors are worth a word.
24
+ */
25
+ export declare function wasCancelled(error: any): boolean;
26
+ /** The authenticator already holds one of this person's passkeys for this site. */
27
+ export declare function alreadyRegistered(error: any): boolean;
28
+ /** Where 「do not ask again on this device」 is kept - per browser, per person. */
29
+ export declare function offerDeclinedKey(username: string): string;
30
+ /**
31
+ * Whether the offer screen has anything to offer. It needs an authenticator built into this device -
32
+ * a person on someone else's computer is not asked to put a passkey on it - and the person must not
33
+ * have said no on this browser.
34
+ */
35
+ export declare function shouldOffer({ platformAuthenticator, declined }: {
36
+ platformAuthenticator: boolean;
37
+ declined: boolean;
38
+ }): boolean;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The rules the passkey screens follow (ADR-0156), apart from the screens so they are tested.
3
+ */
4
+ /**
5
+ * A label for a passkey made here - the system and the browser, 「macOS · Chrome」. The person can
6
+ * rename it; this is only what the list shows until they do.
7
+ */
8
+ export function deviceLabel(userAgent) {
9
+ const system = /iPhone/.test(userAgent)
10
+ ? 'iPhone'
11
+ : /iPad/.test(userAgent)
12
+ ? 'iPad'
13
+ : /Android/.test(userAgent)
14
+ ? 'Android'
15
+ : /Windows/.test(userAgent)
16
+ ? 'Windows'
17
+ : /Mac OS X|Macintosh/.test(userAgent)
18
+ ? 'macOS'
19
+ : /CrOS/.test(userAgent)
20
+ ? 'ChromeOS'
21
+ : /Linux/.test(userAgent)
22
+ ? 'Linux'
23
+ : '';
24
+ /* Order matters: Edge and Opera say Chrome too, Chrome says Safari too. */
25
+ const browser = /Edg\//.test(userAgent)
26
+ ? 'Edge'
27
+ : /OPR\//.test(userAgent)
28
+ ? 'Opera'
29
+ : /SamsungBrowser\//.test(userAgent)
30
+ ? 'Samsung Internet'
31
+ : /Firefox\/|FxiOS\//.test(userAgent)
32
+ ? 'Firefox'
33
+ : /Chrome\/|CriOS\//.test(userAgent)
34
+ ? 'Chrome'
35
+ : /Safari\//.test(userAgent)
36
+ ? 'Safari'
37
+ : '';
38
+ return [system, browser].filter(Boolean).join(' · ');
39
+ }
40
+ /**
41
+ * What the passkey sign-in answered. A refusal comes back as 401 with the refusal, already in the
42
+ * person's language, as a plain-text body (`authenticate401Middleware`). Never throws.
43
+ */
44
+ export async function readSigninAnswer(response) {
45
+ let body = '';
46
+ try {
47
+ body = await response.text();
48
+ }
49
+ catch { }
50
+ if (response.ok) {
51
+ try {
52
+ const parsed = JSON.parse(body);
53
+ if (parsed?.verified === true && typeof parsed.redirectURL === 'string') {
54
+ return { outcome: 'signed-in', redirectURL: parsed.redirectURL };
55
+ }
56
+ }
57
+ catch { }
58
+ }
59
+ return { outcome: 'refused', message: body.trim() };
60
+ }
61
+ /**
62
+ * The browser said no: the person closed the prompt, or let it time out. Nothing to report - they
63
+ * know. Other errors are worth a word.
64
+ */
65
+ export function wasCancelled(error) {
66
+ return error?.name === 'NotAllowedError' || error?.name === 'AbortError' || error?.code === 'ERROR_CEREMONY_ABORTED';
67
+ }
68
+ /** The authenticator already holds one of this person's passkeys for this site. */
69
+ export function alreadyRegistered(error) {
70
+ return error?.name === 'InvalidStateError' || error?.code === 'ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED';
71
+ }
72
+ /** Where 「do not ask again on this device」 is kept - per browser, per person. */
73
+ export function offerDeclinedKey(username) {
74
+ return `passkey-offer-declined:${username}`;
75
+ }
76
+ /**
77
+ * Whether the offer screen has anything to offer. It needs an authenticator built into this device -
78
+ * a person on someone else's computer is not asked to put a passkey on it - and the person must not
79
+ * have said no on this browser.
80
+ */
81
+ export function shouldOffer({ platformAuthenticator, declined }) {
82
+ return platformAuthenticator && !declined;
83
+ }
84
+ //# sourceMappingURL=passkey.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"passkey.js","sourceRoot":"","sources":["../../client/utils/passkey.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The rules the passkey screens follow (ADR-0156), apart from the screens so they are tested.\n */\n\n/**\n * A label for a passkey made here - the system and the browser, 「macOS · Chrome」. The person can\n * rename it; this is only what the list shows until they do.\n */\nexport function deviceLabel(userAgent: string): string {\n const system = /iPhone/.test(userAgent)\n ? 'iPhone'\n : /iPad/.test(userAgent)\n ? 'iPad'\n : /Android/.test(userAgent)\n ? 'Android'\n : /Windows/.test(userAgent)\n ? 'Windows'\n : /Mac OS X|Macintosh/.test(userAgent)\n ? 'macOS'\n : /CrOS/.test(userAgent)\n ? 'ChromeOS'\n : /Linux/.test(userAgent)\n ? 'Linux'\n : ''\n\n /* Order matters: Edge and Opera say Chrome too, Chrome says Safari too. */\n const browser = /Edg\\//.test(userAgent)\n ? 'Edge'\n : /OPR\\//.test(userAgent)\n ? 'Opera'\n : /SamsungBrowser\\//.test(userAgent)\n ? 'Samsung Internet'\n : /Firefox\\/|FxiOS\\//.test(userAgent)\n ? 'Firefox'\n : /Chrome\\/|CriOS\\//.test(userAgent)\n ? 'Chrome'\n : /Safari\\//.test(userAgent)\n ? 'Safari'\n : ''\n\n return [system, browser].filter(Boolean).join(' · ')\n}\n\nexport type SigninAnswer = { outcome: 'signed-in'; redirectURL: string } | { outcome: 'refused'; message: string }\n\n/**\n * What the passkey sign-in answered. A refusal comes back as 401 with the refusal, already in the\n * person's language, as a plain-text body (`authenticate401Middleware`). Never throws.\n */\nexport async function readSigninAnswer(response: Response): Promise<SigninAnswer> {\n let body = ''\n try {\n body = await response.text()\n } catch {}\n\n if (response.ok) {\n try {\n const parsed = JSON.parse(body)\n if (parsed?.verified === true && typeof parsed.redirectURL === 'string') {\n return { outcome: 'signed-in', redirectURL: parsed.redirectURL }\n }\n } catch {}\n }\n\n return { outcome: 'refused', message: body.trim() }\n}\n\n/**\n * The browser said no: the person closed the prompt, or let it time out. Nothing to report - they\n * know. Other errors are worth a word.\n */\nexport function wasCancelled(error: any): boolean {\n return error?.name === 'NotAllowedError' || error?.name === 'AbortError' || error?.code === 'ERROR_CEREMONY_ABORTED'\n}\n\n/** The authenticator already holds one of this person's passkeys for this site. */\nexport function alreadyRegistered(error: any): boolean {\n return error?.name === 'InvalidStateError' || error?.code === 'ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED'\n}\n\n/** Where 「do not ask again on this device」 is kept - per browser, per person. */\nexport function offerDeclinedKey(username: string): string {\n return `passkey-offer-declined:${username}`\n}\n\n/**\n * Whether the offer screen has anything to offer. It needs an authenticator built into this device -\n * a person on someone else's computer is not asked to put a passkey on it - and the person must not\n * have said no on this browser.\n */\nexport function shouldOffer({ platformAuthenticator, declined }: { platformAuthenticator: boolean; declined: boolean }): boolean {\n return platformAuthenticator && !declined\n}\n"]}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,75 @@
1
+ import { alreadyRegistered, deviceLabel, offerDeclinedKey, readSigninAnswer, shouldOffer, wasCancelled } from './passkey';
2
+ /** Just enough of `Response` for the reader. */
3
+ function response({ ok = true, status = 200, body = '', unreadable = false } = {}) {
4
+ return {
5
+ ok,
6
+ status,
7
+ text: async () => {
8
+ if (unreadable)
9
+ throw new TypeError('body already consumed');
10
+ return body;
11
+ }
12
+ };
13
+ }
14
+ describe('a label for a passkey made here', () => {
15
+ it.each([
16
+ ['Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36', 'macOS · Chrome'],
17
+ ['Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15', 'macOS · Safari'],
18
+ ['Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36 Edg/141.0.0.0', 'Windows · Edge'],
19
+ ['Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1', 'iPhone · Safari'],
20
+ ['Mozilla/5.0 (Linux; Android 14; SM-S918N) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/26.0 Chrome/122.0.0.0 Mobile Safari/537.36', 'Android · Samsung Internet'],
21
+ ['Mozilla/5.0 (X11; Linux x86_64; rv:131.0) Gecko/20100101 Firefox/131.0', 'Linux · Firefox']
22
+ ])('%s', (userAgent, label) => {
23
+ expect(deviceLabel(userAgent)).toBe(label);
24
+ });
25
+ it('says nothing it cannot tell', () => {
26
+ expect(deviceLabel('curl/8.0')).toBe('');
27
+ });
28
+ });
29
+ describe('what the passkey sign-in answered', () => {
30
+ it('signed in, with where to go next', async () => {
31
+ expect(await readSigninAnswer(response({ body: '{"verified":true,"redirectURL":"/auth/checkin?redirect_to=%2F"}' }))).toEqual({
32
+ outcome: 'signed-in',
33
+ redirectURL: '/auth/checkin?redirect_to=%2F'
34
+ });
35
+ });
36
+ it("a refusal carries the server's words, already in the person's language", async () => {
37
+ expect(await readSigninAnswer(response({ ok: false, status: 401, body: '계정이 잠겨 있습니다.' }))).toEqual({
38
+ outcome: 'refused',
39
+ message: '계정이 잠겨 있습니다.'
40
+ });
41
+ });
42
+ it('an answer that is ok but not a sign-in is not taken for one', async () => {
43
+ expect((await readSigninAnswer(response({ body: '{"verified":false}' }))).outcome).toBe('refused');
44
+ expect((await readSigninAnswer(response({ body: '<html>' }))).outcome).toBe('refused');
45
+ expect((await readSigninAnswer(response({ ok: false, status: 401, body: '{"verified":true,"redirectURL":"/x"}' }))).outcome).toBe('refused');
46
+ });
47
+ it('an unreadable body is a refusal with nothing to say, never a throw', async () => {
48
+ expect(await readSigninAnswer(response({ unreadable: true }))).toEqual({ outcome: 'refused', message: '' });
49
+ });
50
+ });
51
+ describe('what the browser said', () => {
52
+ it('a closed or cancelled prompt is not an error worth reporting', () => {
53
+ expect(wasCancelled({ name: 'NotAllowedError' })).toBe(true);
54
+ expect(wasCancelled({ name: 'AbortError' })).toBe(true);
55
+ expect(wasCancelled({ code: 'ERROR_CEREMONY_ABORTED' })).toBe(true);
56
+ expect(wasCancelled({ name: 'SecurityError' })).toBe(false);
57
+ expect(wasCancelled(undefined)).toBe(false);
58
+ });
59
+ it('an authenticator that already holds this person’s passkey', () => {
60
+ expect(alreadyRegistered({ name: 'InvalidStateError' })).toBe(true);
61
+ expect(alreadyRegistered({ code: 'ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED' })).toBe(true);
62
+ expect(alreadyRegistered({ name: 'NotAllowedError' })).toBe(false);
63
+ });
64
+ });
65
+ describe('the offer after a password sign-in', () => {
66
+ it('is made only on a device with its own authenticator, to a person who has not said no here', () => {
67
+ expect(shouldOffer({ platformAuthenticator: true, declined: false })).toBe(true);
68
+ expect(shouldOffer({ platformAuthenticator: false, declined: false })).toBe(false);
69
+ expect(shouldOffer({ platformAuthenticator: true, declined: true })).toBe(false);
70
+ });
71
+ it('a no is kept per person on this browser', () => {
72
+ expect(offerDeclinedKey('kim')).not.toBe(offerDeclinedKey('lee'));
73
+ });
74
+ });
75
+ //# sourceMappingURL=passkey.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"passkey.test.js","sourceRoot":"","sources":["../../client/utils/passkey.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { alreadyRegistered, deviceLabel, offerDeclinedKey, readSigninAnswer, shouldOffer, wasCancelled } from './passkey'\n\n/** Just enough of `Response` for the reader. */\nfunction response({ ok = true, status = 200, body = '', unreadable = false } = {}): Response {\n return {\n ok,\n status,\n text: async () => {\n if (unreadable) throw new TypeError('body already consumed')\n return body\n }\n } as unknown as Response\n}\n\ndescribe('a label for a passkey made here', () => {\n it.each([\n ['Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36', 'macOS · Chrome'],\n ['Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15', 'macOS · Safari'],\n ['Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36 Edg/141.0.0.0', 'Windows · Edge'],\n ['Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1', 'iPhone · Safari'],\n ['Mozilla/5.0 (Linux; Android 14; SM-S918N) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/26.0 Chrome/122.0.0.0 Mobile Safari/537.36', 'Android · Samsung Internet'],\n ['Mozilla/5.0 (X11; Linux x86_64; rv:131.0) Gecko/20100101 Firefox/131.0', 'Linux · Firefox']\n ])('%s', (userAgent, label) => {\n expect(deviceLabel(userAgent)).toBe(label)\n })\n\n it('says nothing it cannot tell', () => {\n expect(deviceLabel('curl/8.0')).toBe('')\n })\n})\n\ndescribe('what the passkey sign-in answered', () => {\n it('signed in, with where to go next', async () => {\n expect(await readSigninAnswer(response({ body: '{\"verified\":true,\"redirectURL\":\"/auth/checkin?redirect_to=%2F\"}' }))).toEqual({\n outcome: 'signed-in',\n redirectURL: '/auth/checkin?redirect_to=%2F'\n })\n })\n\n it(\"a refusal carries the server's words, already in the person's language\", async () => {\n expect(await readSigninAnswer(response({ ok: false, status: 401, body: '계정이 잠겨 있습니다.' }))).toEqual({\n outcome: 'refused',\n message: '계정이 잠겨 있습니다.'\n })\n })\n\n it('an answer that is ok but not a sign-in is not taken for one', async () => {\n expect((await readSigninAnswer(response({ body: '{\"verified\":false}' }))).outcome).toBe('refused')\n expect((await readSigninAnswer(response({ body: '<html>' }))).outcome).toBe('refused')\n expect((await readSigninAnswer(response({ ok: false, status: 401, body: '{\"verified\":true,\"redirectURL\":\"/x\"}' }))).outcome).toBe('refused')\n })\n\n it('an unreadable body is a refusal with nothing to say, never a throw', async () => {\n expect(await readSigninAnswer(response({ unreadable: true }))).toEqual({ outcome: 'refused', message: '' })\n })\n})\n\ndescribe('what the browser said', () => {\n it('a closed or cancelled prompt is not an error worth reporting', () => {\n expect(wasCancelled({ name: 'NotAllowedError' })).toBe(true)\n expect(wasCancelled({ name: 'AbortError' })).toBe(true)\n expect(wasCancelled({ code: 'ERROR_CEREMONY_ABORTED' })).toBe(true)\n expect(wasCancelled({ name: 'SecurityError' })).toBe(false)\n expect(wasCancelled(undefined)).toBe(false)\n })\n\n it('an authenticator that already holds this person’s passkey', () => {\n expect(alreadyRegistered({ name: 'InvalidStateError' })).toBe(true)\n expect(alreadyRegistered({ code: 'ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED' })).toBe(true)\n expect(alreadyRegistered({ name: 'NotAllowedError' })).toBe(false)\n })\n})\n\ndescribe('the offer after a password sign-in', () => {\n it('is made only on a device with its own authenticator, to a person who has not said no here', () => {\n expect(shouldOffer({ platformAuthenticator: true, declined: false })).toBe(true)\n expect(shouldOffer({ platformAuthenticator: false, declined: false })).toBe(false)\n expect(shouldOffer({ platformAuthenticator: true, declined: true })).toBe(false)\n })\n\n it('a no is kept per person on this browser', () => {\n expect(offerDeclinedKey('kim')).not.toBe(offerDeclinedKey('lee'))\n })\n})\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/auth-ui",
3
- "version": "10.1.205",
3
+ "version": "10.1.208",
4
4
  "main": "dist-server/index.js",
5
5
  "browser": "dist-client/index.js",
6
6
  "things-factory": true,
@@ -40,11 +40,11 @@
40
40
  "@operato/shell": "^10.29.39",
41
41
  "@operato/styles": "^10.0.0",
42
42
  "@operato/utils": "^10.29.39",
43
- "@things-factory/auth-base": "^10.1.205",
44
- "@things-factory/component-ui": "^10.1.205",
45
- "@things-factory/more-base": "^10.1.205",
43
+ "@things-factory/auth-base": "^10.1.208",
44
+ "@things-factory/component-ui": "^10.1.208",
45
+ "@things-factory/more-base": "^10.1.208",
46
46
  "base64url": "^3.0.1",
47
47
  "clipboard": "^2.0.6"
48
48
  },
49
- "gitHead": "7311492aa5eaa00993c0325053f5f7ae8b1fb024"
49
+ "gitHead": "ffc1ceaa9471b9f4a9b86f46c3021f2fed72d17d"
50
50
  }
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "button.accept invitation": "accept invitation",
3
3
  "button.activate": "activate",
4
+ "button.add passkey": "Add a passkey",
4
5
  "button.add what it can do": "Add what it can do",
5
6
  "button.close": "Close",
6
7
  "button.confirm": "confirm",
@@ -13,15 +14,18 @@
13
14
  "button.delete this appliance": "delete this appliance",
14
15
  "button.delete user": "delete user",
15
16
  "button.detail": "detail",
17
+ "button.do not ask on this device": "Don't ask on this device",
16
18
  "button.drop": "Take away",
17
19
  "button.edit privileges": "Edit privileges",
18
20
  "button.generate new access token": "generate new access token",
19
21
  "button.generate new secret": "generate new secret",
20
22
  "button.go to home": "go to home",
21
23
  "button.hide": "hide",
24
+ "button.later": "Later",
22
25
  "button.logout": "logout",
23
26
  "button.name it yourself": "name it yourself",
24
27
  "button.register": "register",
28
+ "button.register on this device": "Register on this device",
25
29
  "button.remove from domain": "Remove from domain",
26
30
  "button.remove owner": "remove owner",
27
31
  "button.renew access token": "renew access token",
@@ -29,11 +33,11 @@
29
33
  "button.reveal": "reveal",
30
34
  "button.revoke credential": "revoke credential",
31
35
  "button.save": "save",
32
- "button.security-key registration": "security key registration",
33
36
  "button.show-all": "Show all",
34
37
  "button.show-orphans-only": "Show orphans only",
35
38
  "button.sign in": "sign in",
36
39
  "button.sign_in": "sign in",
40
+ "button.signin with passkey": "sign in with a passkey",
37
41
  "button.stand it here": "stand it here",
38
42
  "button.submit": "submit",
39
43
  "button.terminate contract": "terminate",
@@ -143,6 +147,7 @@
143
147
  "label.locked-user": "locked",
144
148
  "label.login_history": "login history",
145
149
  "label.ours": "Ours",
150
+ "label.passkey": "Passkey",
146
151
  "label.password": "password",
147
152
  "label.privilege": "privilege",
148
153
  "label.protected-request-ip": "safe request IP",
@@ -244,6 +249,7 @@
244
249
  "text.no domain available": "no domain available",
245
250
  "text.no one holds this role yet": "No one holds this role yet.",
246
251
  "text.no other user yet": "no other user yet",
252
+ "text.no passkey yet": "No passkeys yet.",
247
253
  "text.no privilege declared in this deployment": "No privilege is declared in this deployment yet, so there is nothing to grant.",
248
254
  "text.no privilege granted": "grants nothing",
249
255
  "text.no role assigned": "no role assigned",
@@ -273,6 +279,7 @@
273
279
  "text.registered roles": "registered roles",
274
280
  "text.remaining time": "{days} days {hours} hours {mins} mins {secs} seconds remain",
275
281
  "text.remove the role from x people first": "To delete this role, first take it away from the {x} people who hold it.",
282
+ "text.removed passkey cannot sign in": "Once {{name}} is removed it can no longer sign you in.",
276
283
  "text.revoking stops every token this appliance holds": "this appliance stops being able to reach the system from the next request. nothing it holds keeps working, and issuing a new access token is the only way back.",
277
284
  "text.role belongs to x domain and is edited there": "This role is defined in {x} and can only be edited there. It is shown here because it can be granted here.",
278
285
  "text.role_management": "role management",
@@ -345,7 +352,9 @@
345
352
  "title.open attributes view": "open attributes view",
346
353
  "title.owner_transfer": "owner transfer",
347
354
  "title.partners": "partners",
355
+ "title.passkey offer": "Add a passkey",
348
356
  "title.protected-privileges": "protected privileges",
357
+ "title.remove passkey": "Remove passkey",
349
358
  "title.reset password": "reset password",
350
359
  "title.role privileges": "Role privileges",
351
360
  "title.secure-ip-list": "secured IP list",
@@ -413,6 +422,11 @@
413
422
  "access-request.text.denied lately": "denied on {at}: {reason}. to ask again, write what has changed.",
414
423
  "access-request.button.request": "request access…",
415
424
  "text.parent roles are changed by the parent domain": "roles of the parent domain can be changed only by someone who decides roles there.",
425
+ "text.passkey added on": "Added {{date}}",
426
+ "text.passkey already on this device": "This device already has your passkey.",
427
+ "text.passkey last used on": "last used {{date}}",
428
+ "text.passkey name": "Passkey name",
429
+ "text.passkey offer": "With a passkey on this device you sign in with your fingerprint, face or device PIN instead of a password. A passkey on your phone signs you in on other computers too.",
416
430
  "access-request.issue.role-belongs-to-parent": "{role} belongs to the parent domain - only someone who decides roles there can give it.",
417
431
  "access-request.dock.intro title": "decide access requests with the assistant",
418
432
  "access-request.dock.intro subtitle": "it reads the waiting requests and drafts decisions; you press the card.",
@@ -451,6 +465,7 @@
451
465
  "audit.outcome.denied": "Refused",
452
466
  "audit.outcome.error": "Error",
453
467
  "audit.outcome.failed": "Failed",
468
+ "audit.method.passkey": "passkey",
454
469
  "audit.button.all-people": "All people",
455
470
  "audit.button.all-privileges": "All privileges",
456
471
  "audit.health.failures": "{count} not recorded (since {since})",
@@ -477,6 +492,7 @@
477
492
  "audit.kind.password.reset-by-admin": "Password reset",
478
493
  "audit.kind.credential.issue": "Credential reissued",
479
494
  "audit.kind.webauthn.register": "Passkey registered",
495
+ "audit.kind.webauthn.remove": "Passkey removed",
480
496
  "audit.kind.role.grant": "Role granted",
481
497
  "audit.kind.role.revoke": "Role revoked",
482
498
  "audit.kind.role.privilege.add": "Privilege added to role",
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "button.accept invitation": "aceptar invitación",
3
3
  "button.activate": "activar",
4
+ "button.add passkey": "Añadir una llave de acceso",
4
5
  "button.add what it can do": "Añadir lo que puede hacer",
5
6
  "button.close": "Cerrar",
6
7
  "button.confirm": "confirmar",
@@ -13,15 +14,18 @@
13
14
  "button.delete this appliance": "eliminar este dispositivo",
14
15
  "button.delete user": "eliminar usuario",
15
16
  "button.detail": "detalle",
17
+ "button.do not ask on this device": "No volver a preguntar en este dispositivo",
16
18
  "button.drop": "Quitar",
17
19
  "button.edit privileges": "Editar privilegios",
18
20
  "button.generate new access token": "generar nuevo token de acceso",
19
21
  "button.generate new secret": "generar nuevo secreto",
20
22
  "button.go to home": "ir al inicio",
21
23
  "button.hide": "ocultar",
24
+ "button.later": "Más tarde",
22
25
  "button.logout": "cerrar sesión",
23
26
  "button.name it yourself": "asignar nombre manualmente",
24
27
  "button.register": "registrar",
28
+ "button.register on this device": "Registrar en este dispositivo",
25
29
  "button.remove from domain": "Quitar del dominio",
26
30
  "button.remove owner": "quitar propietario",
27
31
  "button.renew access token": "renovar token de acceso",
@@ -29,11 +33,11 @@
29
33
  "button.reveal": "mostrar",
30
34
  "button.revoke credential": "revocar credencial",
31
35
  "button.save": "guardar",
32
- "button.security-key registration": "registro de llave de seguridad",
33
36
  "button.show-all": "Mostrar todo",
34
37
  "button.show-orphans-only": "Mostrar solo huérfanos",
35
38
  "button.sign in": "iniciar sesión",
36
39
  "button.sign_in": "iniciar sesión",
40
+ "button.signin with passkey": "Iniciar sesión con una llave de acceso",
37
41
  "button.stand it here": "colocarlo aquí",
38
42
  "button.submit": "enviar",
39
43
  "button.terminate contract": "rescindir",
@@ -143,6 +147,7 @@
143
147
  "label.locked-user": "bloqueado",
144
148
  "label.login_history": "historial de inicio de sesión",
145
149
  "label.ours": "Propios",
150
+ "label.passkey": "Llave de acceso",
146
151
  "label.password": "contraseña",
147
152
  "label.privilege": "privilegio",
148
153
  "label.protected-request-ip": "IP de solicitud segura",
@@ -244,6 +249,7 @@
244
249
  "text.no domain available": "no hay ningún dominio disponible",
245
250
  "text.no one holds this role yet": "Nadie tiene este rol todavía.",
246
251
  "text.no other user yet": "todavía no hay otros usuarios",
252
+ "text.no passkey yet": "Aún no hay llaves de acceso.",
247
253
  "text.no privilege declared in this deployment": "Todavía no se ha declarado ningún privilegio en esta implantación, así que no hay nada que conceder.",
248
254
  "text.no privilege granted": "no concede nada",
249
255
  "text.no role assigned": "sin rol asignado",
@@ -273,6 +279,7 @@
273
279
  "text.registered roles": "roles registrados",
274
280
  "text.remaining time": "quedan {days} días {hours} horas {mins} minutos {secs} segundos",
275
281
  "text.remove the role from x people first": "Para eliminar este rol, quíteselo primero a las {x} personas que lo tienen.",
282
+ "text.removed passkey cannot sign in": "Después de eliminar {{name}} ya no podrás iniciar sesión con ella.",
276
283
  "text.revoking stops every token this appliance holds": "este dispositivo dejará de poder acceder al sistema a partir de la siguiente solicitud. nada de lo que tiene seguirá funcionando, y la única forma de recuperar el acceso es emitir un nuevo token de acceso.",
277
284
  "text.role belongs to x domain and is edited there": "Este rol está definido en {x} y solo se puede editar allí. Se muestra aquí porque se puede conceder aquí.",
278
285
  "text.role_management": "gestión de roles",
@@ -345,7 +352,9 @@
345
352
  "title.open attributes view": "abrir vista de atributos",
346
353
  "title.owner_transfer": "transferencia de propiedad",
347
354
  "title.partners": "socios",
355
+ "title.passkey offer": "Añadir una llave de acceso",
348
356
  "title.protected-privileges": "privilegios protegidos",
357
+ "title.remove passkey": "Eliminar llave de acceso",
349
358
  "title.reset password": "restablecer contraseña",
350
359
  "title.role privileges": "Privilegios del rol",
351
360
  "title.secure-ip-list": "lista de IP seguras",
@@ -413,6 +422,11 @@
413
422
  "access-request.text.denied lately": "rechazada el {at}: {reason}. para volver a solicitarlo, indique qué ha cambiado.",
414
423
  "access-request.button.request": "solicitar acceso…",
415
424
  "text.parent roles are changed by the parent domain": "los roles del dominio principal solo los puede cambiar quien decide los roles allí.",
425
+ "text.passkey added on": "Añadida el {{date}}",
426
+ "text.passkey already on this device": "Este dispositivo ya tiene tu llave de acceso.",
427
+ "text.passkey last used on": "último uso el {{date}}",
428
+ "text.passkey name": "Nombre de la llave de acceso",
429
+ "text.passkey offer": "Con una llave de acceso en este dispositivo inicias sesión con tu huella, tu cara o el PIN del dispositivo en lugar de la contraseña. Una llave de acceso en tu teléfono también te permite iniciar sesión en otros ordenadores.",
416
430
  "access-request.issue.role-belongs-to-parent": "{role} pertenece al dominio principal - solo quien decide los roles allí puede concederlo.",
417
431
  "access-request.dock.intro title": "resuelva solicitudes de acceso con el asistente",
418
432
  "access-request.dock.intro subtitle": "lee las solicitudes pendientes y redacta las decisiones; usted pulsa la tarjeta.",
@@ -451,6 +465,7 @@
451
465
  "audit.outcome.denied": "Rechazado",
452
466
  "audit.outcome.error": "Error",
453
467
  "audit.outcome.failed": "Fallido",
468
+ "audit.method.passkey": "llave de acceso",
454
469
  "audit.button.all-people": "Todas las personas",
455
470
  "audit.button.all-privileges": "Todos los privilegios",
456
471
  "audit.health.failures": "{count} sin registrar (desde {since})",
@@ -477,6 +492,7 @@
477
492
  "audit.kind.password.reset-by-admin": "Restablecimiento de contraseña",
478
493
  "audit.kind.credential.issue": "Credencial reemitida",
479
494
  "audit.kind.webauthn.register": "Llave de acceso registrada",
495
+ "audit.kind.webauthn.remove": "Llave de acceso eliminada",
480
496
  "audit.kind.role.grant": "Rol concedido",
481
497
  "audit.kind.role.revoke": "Rol retirado",
482
498
  "audit.kind.role.privilege.add": "Privilegio añadido al rol",