@things-factory/auth-ui 10.1.107 → 10.1.113

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.
@@ -20,6 +20,15 @@ export type ReviewIssue = {
20
20
  field?: string;
21
21
  params?: Record<string, unknown>;
22
22
  };
23
+ /** How many privileges a finding names before it says "and N more" - a role can carry sixty. */
24
+ export declare const PRIVILEGES_NAMED = 5;
25
+ /**
26
+ * The findings in the order a decider should read them: what blocks, then what hands on power, then the rest.
27
+ * A long list of extra privileges must not push "they could give roles to others" out of sight.
28
+ */
29
+ export declare function orderedIssues<T extends ReviewIssue>(issues: T[]): T[];
30
+ /** "a, b, c, d, e and 53 more" - the full list stays in the server's params. */
31
+ export declare function shortList(t: Translate, csv: string, shown?: number): string;
23
32
  /** One review finding as a sentence - `access-request.issue.<code>`, the code itself when the key is missing. */
24
33
  export declare function issueWords(t: Translate, issue: ReviewIssue): string;
25
34
  export declare const blocks: (issues: ReviewIssue[]) => boolean;
@@ -29,9 +29,28 @@ export function requestableFrom(extensions, operationName) {
29
29
  export function privilegeWords(t, category, privilege) {
30
30
  return t(`privilege.allows.${category}.${privilege}`, { defaultValue: '' }) || `${category}:${privilege}`;
31
31
  }
32
+ /** How many privileges a finding names before it says "and N more" - a role can carry sixty. */
33
+ export const PRIVILEGES_NAMED = 5;
34
+ /**
35
+ * The findings in the order a decider should read them: what blocks, then what hands on power, then the rest.
36
+ * A long list of extra privileges must not push "they could give roles to others" out of sight.
37
+ */
38
+ export function orderedIssues(issues) {
39
+ const weight = (issue) => (issue.blocking ? 0 : issue.code === 'role-grants-admin' ? 1 : issue.code === 'smaller-role-exists' ? 2 : 3);
40
+ return [...issues].sort((a, b) => weight(a) - weight(b));
41
+ }
42
+ /** "a, b, c, d, e and 53 more" - the full list stays in the server's params. */
43
+ export function shortList(t, csv, shown = PRIVILEGES_NAMED) {
44
+ const all = String(csv || '').split(',').map(x => x.trim()).filter(Boolean);
45
+ if (all.length <= shown)
46
+ return all.join(', ');
47
+ return t('access-request.text.and more', { list: all.slice(0, shown).join(', '), count: all.length - shown, defaultValue: `${all.slice(0, shown).join(', ')} +${all.length - shown}` });
48
+ }
32
49
  /** One review finding as a sentence - `access-request.issue.<code>`, the code itself when the key is missing. */
33
50
  export function issueWords(t, issue) {
34
51
  const params = { ...(issue.params || {}) };
52
+ if (typeof params.privileges === 'string')
53
+ params.privileges = shortList(t, params.privileges);
35
54
  if (typeof params.state === 'string')
36
55
  params.state = t(`access-request.state.${String(params.state).toLowerCase()}`, { defaultValue: String(params.state) });
37
56
  return t(`access-request.issue.${issue.code}`, { ...params, defaultValue: issue.code });
@@ -1 +1 @@
1
- {"version":3,"file":"access-request.js","sourceRoot":"","sources":["../../client/utils/access-request.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * The access request on the screen - pure, so the rules are tested without a browser (ADR-0103).\n *\n * A refusal's toast gets its button from this package through @operato/layout's registerNotifyAction\n * (bootstrap.ts) - the toast and @operato/graphql do not know what a refusal means (ADR-0103 decision 5).\n */\n\nexport type PrivilegeRequestDetail = { category: string; privilege: string; operationName?: string }\n\n/**\n * What a refusal offers to ask for - only a refusal a role would open. Address, license, ownership and\n * unknown refusals offer nothing: giving a role would not open them.\n */\nexport function requestableFrom(extensions: any, operationName?: string): PrivilegeRequestDetail | null {\n if (!extensions || extensions.code !== 'FORBIDDEN' || extensions.because !== 'role') return null\n /*\n * Only a change the person made (questions Q9 · 4). A page fires several reads as it opens; offering a request on\n * each would put the button in front of someone who did not choose to do anything yet.\n */\n if (extensions.operation !== 'mutation') return null\n const requires = extensions.requires\n if (!requires || typeof requires !== 'object' || !requires.category || !requires.privilege) return null\n return { category: requires.category, privilege: requires.privilege, ...(operationName ? { operationName } : {}) }\n}\n\ntype Translate = (key: string, options?: Record<string, unknown>) => string\n\n/**\n * What a privilege lets one do, in the reader's words - the sentence the role screen shows\n * (`privilege.allows.<category>.<privilege>`), or the name itself when none is written. Never made up.\n */\nexport function privilegeWords(t: Translate, category: string, privilege: string): string {\n return t(`privilege.allows.${category}.${privilege}`, { defaultValue: '' }) || `${category}:${privilege}`\n}\n\nexport type ReviewIssue = { code: string; blocking: boolean; field?: string; params?: Record<string, unknown> }\n\n/** One review finding as a sentence - `access-request.issue.<code>`, the code itself when the key is missing. */\nexport function issueWords(t: Translate, issue: ReviewIssue): string {\n const params = { ...(issue.params || {}) }\n if (typeof params.state === 'string') params.state = t(`access-request.state.${String(params.state).toLowerCase()}`, { defaultValue: String(params.state) })\n return t(`access-request.issue.${issue.code}`, { ...params, defaultValue: issue.code })\n}\n\nexport const blocks = (issues: ReviewIssue[]) => issues.some(issue => issue.blocking)\n\n/** Which buttons a decision popup offers, from the review the server gave. */\nexport function decisionButtons(grantIssues: ReviewIssue[] | null, denyIssues: ReviewIssue[] | null) {\n return {\n grant: !!grantIssues && !blocks(grantIssues),\n deny: !!denyIssues && !blocks(denyIssues)\n }\n}\n\n/** The candidate a popup selects first - the smallest; ties by name, as the server sorts them. */\nexport function firstCandidate<T extends { name: string; privilegeCount: number }>(candidates: T[]): T | undefined {\n return [...(candidates || [])].sort((a, b) => a.privilegeCount - b.privilegeCount || a.name.localeCompare(b.name))[0]\n}\n\n/** How long a denial is shown back before asking again - the server holds the same words against it (auth-base ACCESS_REQUEST_DENIAL_DAYS). */\nexport const DENIAL_SHOWN_DAYS = 7\n\n/** The latest denial of this privilege among the person's own requests, within DENIAL_SHOWN_DAYS. */\nexport function recentDenial<T extends { state: string; category: string; privilege: string; decidedAt?: string | null }>(\n mine: T[],\n category: string,\n privilege: string,\n now = Date.now()\n): T | undefined {\n return (mine || [])\n .filter(r => r.state === 'DENIED' && r.category === category && r.privilege === privilege && r.decidedAt)\n .filter(r => now - new Date(r.decidedAt as string).getTime() <= DENIAL_SHOWN_DAYS * 24 * 60 * 60 * 1000)\n .sort((a, b) => new Date(b.decidedAt as string).getTime() - new Date(a.decidedAt as string).getTime())[0]\n}\n\n/** Send is offered only with a reason - and, after a denial, with words other than the denied ones. */\nexport function maySend(reason: string, denied?: { reason?: string | null }): boolean {\n const said = (reason || '').trim()\n if (!said) return false\n return !(denied && (denied.reason || '').trim() === said)\n}\n"]}
1
+ {"version":3,"file":"access-request.js","sourceRoot":"","sources":["../../client/utils/access-request.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * The access request on the screen - pure, so the rules are tested without a browser (ADR-0103).\n *\n * A refusal's toast gets its button from this package through @operato/layout's registerNotifyAction\n * (bootstrap.ts) - the toast and @operato/graphql do not know what a refusal means (ADR-0103 decision 5).\n */\n\nexport type PrivilegeRequestDetail = { category: string; privilege: string; operationName?: string }\n\n/**\n * What a refusal offers to ask for - only a refusal a role would open. Address, license, ownership and\n * unknown refusals offer nothing: giving a role would not open them.\n */\nexport function requestableFrom(extensions: any, operationName?: string): PrivilegeRequestDetail | null {\n if (!extensions || extensions.code !== 'FORBIDDEN' || extensions.because !== 'role') return null\n /*\n * Only a change the person made (questions Q9 · 4). A page fires several reads as it opens; offering a request on\n * each would put the button in front of someone who did not choose to do anything yet.\n */\n if (extensions.operation !== 'mutation') return null\n const requires = extensions.requires\n if (!requires || typeof requires !== 'object' || !requires.category || !requires.privilege) return null\n return { category: requires.category, privilege: requires.privilege, ...(operationName ? { operationName } : {}) }\n}\n\ntype Translate = (key: string, options?: Record<string, unknown>) => string\n\n/**\n * What a privilege lets one do, in the reader's words - the sentence the role screen shows\n * (`privilege.allows.<category>.<privilege>`), or the name itself when none is written. Never made up.\n */\nexport function privilegeWords(t: Translate, category: string, privilege: string): string {\n return t(`privilege.allows.${category}.${privilege}`, { defaultValue: '' }) || `${category}:${privilege}`\n}\n\nexport type ReviewIssue = { code: string; blocking: boolean; field?: string; params?: Record<string, unknown> }\n\n/** How many privileges a finding names before it says \"and N more\" - a role can carry sixty. */\nexport const PRIVILEGES_NAMED = 5\n\n/**\n * The findings in the order a decider should read them: what blocks, then what hands on power, then the rest.\n * A long list of extra privileges must not push \"they could give roles to others\" out of sight.\n */\nexport function orderedIssues<T extends ReviewIssue>(issues: T[]): T[] {\n const weight = (issue: T) => (issue.blocking ? 0 : issue.code === 'role-grants-admin' ? 1 : issue.code === 'smaller-role-exists' ? 2 : 3)\n return [...issues].sort((a, b) => weight(a) - weight(b))\n}\n\n/** \"a, b, c, d, e and 53 more\" - the full list stays in the server's params. */\nexport function shortList(t: Translate, csv: string, shown = PRIVILEGES_NAMED): string {\n const all = String(csv || '').split(',').map(x => x.trim()).filter(Boolean)\n if (all.length <= shown) return all.join(', ')\n return t('access-request.text.and more', { list: all.slice(0, shown).join(', '), count: all.length - shown, defaultValue: `${all.slice(0, shown).join(', ')} +${all.length - shown}` })\n}\n\n/** One review finding as a sentence - `access-request.issue.<code>`, the code itself when the key is missing. */\nexport function issueWords(t: Translate, issue: ReviewIssue): string {\n const params = { ...(issue.params || {}) }\n if (typeof params.privileges === 'string') params.privileges = shortList(t, params.privileges)\n if (typeof params.state === 'string') params.state = t(`access-request.state.${String(params.state).toLowerCase()}`, { defaultValue: String(params.state) })\n return t(`access-request.issue.${issue.code}`, { ...params, defaultValue: issue.code })\n}\n\nexport const blocks = (issues: ReviewIssue[]) => issues.some(issue => issue.blocking)\n\n/** Which buttons a decision popup offers, from the review the server gave. */\nexport function decisionButtons(grantIssues: ReviewIssue[] | null, denyIssues: ReviewIssue[] | null) {\n return {\n grant: !!grantIssues && !blocks(grantIssues),\n deny: !!denyIssues && !blocks(denyIssues)\n }\n}\n\n/** The candidate a popup selects first - the smallest; ties by name, as the server sorts them. */\nexport function firstCandidate<T extends { name: string; privilegeCount: number }>(candidates: T[]): T | undefined {\n return [...(candidates || [])].sort((a, b) => a.privilegeCount - b.privilegeCount || a.name.localeCompare(b.name))[0]\n}\n\n/** How long a denial is shown back before asking again - the server holds the same words against it (auth-base ACCESS_REQUEST_DENIAL_DAYS). */\nexport const DENIAL_SHOWN_DAYS = 7\n\n/** The latest denial of this privilege among the person's own requests, within DENIAL_SHOWN_DAYS. */\nexport function recentDenial<T extends { state: string; category: string; privilege: string; decidedAt?: string | null }>(\n mine: T[],\n category: string,\n privilege: string,\n now = Date.now()\n): T | undefined {\n return (mine || [])\n .filter(r => r.state === 'DENIED' && r.category === category && r.privilege === privilege && r.decidedAt)\n .filter(r => now - new Date(r.decidedAt as string).getTime() <= DENIAL_SHOWN_DAYS * 24 * 60 * 60 * 1000)\n .sort((a, b) => new Date(b.decidedAt as string).getTime() - new Date(a.decidedAt as string).getTime())[0]\n}\n\n/** Send is offered only with a reason - and, after a denial, with words other than the denied ones. */\nexport function maySend(reason: string, denied?: { reason?: string | null }): boolean {\n const said = (reason || '').trim()\n if (!said) return false\n return !(denied && (denied.reason || '').trim() === said)\n}\n"]}
@@ -1,4 +1,4 @@
1
- import { blocks, decisionButtons, firstCandidate, issueWords, maySend, privilegeWords, recentDenial, requestableFrom } from './access-request';
1
+ import { blocks, decisionButtons, firstCandidate, issueWords, maySend, orderedIssues, privilegeWords, recentDenial, requestableFrom, shortList } from './access-request';
2
2
  /**
3
3
  * The access request on the screen (ADR-0103). The refusal says why; only a missing role offers a request.
4
4
  */
@@ -67,4 +67,20 @@ describe('asking is not a reflex (Q9)', () => {
67
67
  expect(maySend('moved to inbound', { reason: 'inbound' })).toBe(true);
68
68
  });
69
69
  });
70
+ describe('the decision popup reads well with a big role (found end to end, 2026-10-02)', () => {
71
+ const t = (key, options = {}) => (key === 'access-request.text.and more' ? `${options.list} +${options.count}` : options.defaultValue ?? key);
72
+ it('★ what blocks first, then the power to give roles, then a smaller role, then the rest', () => {
73
+ const issues = [
74
+ { code: 'role-grants-more', blocking: false },
75
+ { code: 'smaller-role-exists', blocking: false },
76
+ { code: 'role-grants-admin', blocking: false },
77
+ { code: 'deny-needs-reason', blocking: true }
78
+ ];
79
+ expect(orderedIssues(issues).map(i => i.code)).toEqual(['deny-needs-reason', 'role-grants-admin', 'smaller-role-exists', 'role-grants-more']);
80
+ });
81
+ it('★ a long privilege list names five and counts the rest', () => {
82
+ expect(shortList(t, 'a, b, c, d, e, f, g')).toBe('a, b, c, d, e +2');
83
+ expect(shortList(t, 'a, b')).toBe('a, b');
84
+ });
85
+ });
70
86
  //# sourceMappingURL=access-request.test.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"access-request.test.js","sourceRoot":"","sources":["../../client/utils/access-request.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { blocks, decisionButtons, firstCandidate, issueWords, maySend, privilegeWords, recentDenial, requestableFrom } from './access-request'\n\n/**\n * The access request on the screen (ADR-0103). The refusal says why; only a missing role offers a request.\n */\n\nconst ROLE = { code: 'FORBIDDEN', requires: { category: 'warehouse', privilege: 'mutation' }, because: 'role', operation: 'mutation' }\n\ndescribe('what a refusal offers to ask for', () => {\n it('★ a refusal a role would open - the privilege it named', () => {\n expect(requestableFrom(ROLE, 'updateInboundPlan')).toEqual({ category: 'warehouse', privilege: 'mutation', operationName: 'updateInboundPlan' })\n })\n\n it('★ address, license, ownership and unknown offer nothing - a role would not open them', () => {\n for (const because of ['address', 'license', 'ownership', 'unknown']) {\n expect(requestableFrom({ ...ROLE, because })).toBeNull()\n }\n })\n\n it('★ a read the page fired offers nothing - only a change the person made (Q9)', () => {\n expect(requestableFrom({ ...ROLE, operation: 'query' })).toBeNull()\n expect(requestableFrom({ ...ROLE, operation: 'subscription' })).toBeNull()\n expect(requestableFrom({ ...ROLE, operation: undefined })).toBeNull()\n })\n\n it('a refusal from before the cause was carried offers nothing - it is not guessed', () => {\n expect(requestableFrom({ code: 'FORBIDDEN', requires: ROLE.requires })).toBeNull()\n })\n\n it('ownership-only and other errors offer nothing', () => {\n expect(requestableFrom({ ...ROLE, requires: 'ownership' })).toBeNull()\n expect(requestableFrom({ code: 'BAD_USER_INPUT' })).toBeNull()\n expect(requestableFrom(undefined)).toBeNull()\n })\n})\n\ndescribe('words', () => {\n const t = (key: string, options: any = {}) => (key === 'privilege.allows.warehouse.mutation' ? '창고 기록을 바꿀 수 있습니다.' : options.defaultValue)\n\n it('★ the sentence the role screen writes, or the name - never a key', () => {\n expect(privilegeWords(t, 'warehouse', 'mutation')).toBe('창고 기록을 바꿀 수 있습니다.')\n expect(privilegeWords(t, 'board', 'query')).toBe('board:query')\n })\n\n it('an issue without a sentence shows its code, not the key', () => {\n expect(issueWords(t, { code: 'own-request', blocking: true })).toBe('own-request')\n })\n})\n\ndescribe('the decision buttons', () => {\n it('★ a blocking finding takes the button away; a warning does not', () => {\n expect(decisionButtons([{ code: 'role-grants-more', blocking: false }], [{ code: 'deny-needs-reason', blocking: true }])).toEqual({ grant: true, deny: false })\n expect(decisionButtons([{ code: 'role-does-not-grant', blocking: true }], [])).toEqual({ grant: false, deny: true })\n expect(decisionButtons(null, [])).toEqual({ grant: false, deny: true })\n expect(blocks([])).toBe(false)\n })\n\n it('the smallest role first, ties by name', () => {\n expect(firstCandidate([{ name: 'b', privilegeCount: 1 }, { name: 'a', privilegeCount: 1 }, { name: 'c', privilegeCount: 0 }])?.name).toBe('c')\n })\n})\n\ndescribe('asking is not a reflex (Q9)', () => {\n const NOW = new Date('2026-10-02T09:00:00Z').getTime()\n const mine = [\n { state: 'DENIED', category: 'warehouse', privilege: 'mutation', decidedAt: '2026-09-30T09:00:00Z', reason: 'inbound', decisionReason: 'IT only' },\n { state: 'DENIED', category: 'warehouse', privilege: 'mutation', decidedAt: '2026-09-01T09:00:00Z', reason: 'old', decisionReason: 'old' },\n { state: 'DENIED', category: 'board', privilege: 'mutation', decidedAt: '2026-10-01T09:00:00Z', reason: 'x', decisionReason: 'y' }\n ]\n\n it('★ the latest denial of the same privilege within seven days is shown back', () => {\n expect(recentDenial(mine, 'warehouse', 'mutation', NOW)?.decisionReason).toBe('IT only')\n expect(recentDenial(mine, 'warehouse', 'query', NOW)).toBeUndefined()\n expect(recentDenial(mine.slice(1, 2), 'warehouse', 'mutation', NOW)).toBeUndefined()\n })\n\n it('★ send needs a reason, and after a denial other words than the denied ones', () => {\n expect(maySend('')).toBe(false)\n expect(maySend(' ')).toBe(false)\n expect(maySend('inbound')).toBe(true)\n expect(maySend('inbound', { reason: 'inbound' })).toBe(false)\n expect(maySend('moved to inbound', { reason: 'inbound' })).toBe(true)\n })\n})\n"]}
1
+ {"version":3,"file":"access-request.test.js","sourceRoot":"","sources":["../../client/utils/access-request.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { blocks, decisionButtons, firstCandidate, issueWords, maySend, orderedIssues, privilegeWords, recentDenial, requestableFrom, shortList } from './access-request'\n\n/**\n * The access request on the screen (ADR-0103). The refusal says why; only a missing role offers a request.\n */\n\nconst ROLE = { code: 'FORBIDDEN', requires: { category: 'warehouse', privilege: 'mutation' }, because: 'role', operation: 'mutation' }\n\ndescribe('what a refusal offers to ask for', () => {\n it('★ a refusal a role would open - the privilege it named', () => {\n expect(requestableFrom(ROLE, 'updateInboundPlan')).toEqual({ category: 'warehouse', privilege: 'mutation', operationName: 'updateInboundPlan' })\n })\n\n it('★ address, license, ownership and unknown offer nothing - a role would not open them', () => {\n for (const because of ['address', 'license', 'ownership', 'unknown']) {\n expect(requestableFrom({ ...ROLE, because })).toBeNull()\n }\n })\n\n it('★ a read the page fired offers nothing - only a change the person made (Q9)', () => {\n expect(requestableFrom({ ...ROLE, operation: 'query' })).toBeNull()\n expect(requestableFrom({ ...ROLE, operation: 'subscription' })).toBeNull()\n expect(requestableFrom({ ...ROLE, operation: undefined })).toBeNull()\n })\n\n it('a refusal from before the cause was carried offers nothing - it is not guessed', () => {\n expect(requestableFrom({ code: 'FORBIDDEN', requires: ROLE.requires })).toBeNull()\n })\n\n it('ownership-only and other errors offer nothing', () => {\n expect(requestableFrom({ ...ROLE, requires: 'ownership' })).toBeNull()\n expect(requestableFrom({ code: 'BAD_USER_INPUT' })).toBeNull()\n expect(requestableFrom(undefined)).toBeNull()\n })\n})\n\ndescribe('words', () => {\n const t = (key: string, options: any = {}) => (key === 'privilege.allows.warehouse.mutation' ? '창고 기록을 바꿀 수 있습니다.' : options.defaultValue)\n\n it('★ the sentence the role screen writes, or the name - never a key', () => {\n expect(privilegeWords(t, 'warehouse', 'mutation')).toBe('창고 기록을 바꿀 수 있습니다.')\n expect(privilegeWords(t, 'board', 'query')).toBe('board:query')\n })\n\n it('an issue without a sentence shows its code, not the key', () => {\n expect(issueWords(t, { code: 'own-request', blocking: true })).toBe('own-request')\n })\n})\n\ndescribe('the decision buttons', () => {\n it('★ a blocking finding takes the button away; a warning does not', () => {\n expect(decisionButtons([{ code: 'role-grants-more', blocking: false }], [{ code: 'deny-needs-reason', blocking: true }])).toEqual({ grant: true, deny: false })\n expect(decisionButtons([{ code: 'role-does-not-grant', blocking: true }], [])).toEqual({ grant: false, deny: true })\n expect(decisionButtons(null, [])).toEqual({ grant: false, deny: true })\n expect(blocks([])).toBe(false)\n })\n\n it('the smallest role first, ties by name', () => {\n expect(firstCandidate([{ name: 'b', privilegeCount: 1 }, { name: 'a', privilegeCount: 1 }, { name: 'c', privilegeCount: 0 }])?.name).toBe('c')\n })\n})\n\ndescribe('asking is not a reflex (Q9)', () => {\n const NOW = new Date('2026-10-02T09:00:00Z').getTime()\n const mine = [\n { state: 'DENIED', category: 'warehouse', privilege: 'mutation', decidedAt: '2026-09-30T09:00:00Z', reason: 'inbound', decisionReason: 'IT only' },\n { state: 'DENIED', category: 'warehouse', privilege: 'mutation', decidedAt: '2026-09-01T09:00:00Z', reason: 'old', decisionReason: 'old' },\n { state: 'DENIED', category: 'board', privilege: 'mutation', decidedAt: '2026-10-01T09:00:00Z', reason: 'x', decisionReason: 'y' }\n ]\n\n it('★ the latest denial of the same privilege within seven days is shown back', () => {\n expect(recentDenial(mine, 'warehouse', 'mutation', NOW)?.decisionReason).toBe('IT only')\n expect(recentDenial(mine, 'warehouse', 'query', NOW)).toBeUndefined()\n expect(recentDenial(mine.slice(1, 2), 'warehouse', 'mutation', NOW)).toBeUndefined()\n })\n\n it('★ send needs a reason, and after a denial other words than the denied ones', () => {\n expect(maySend('')).toBe(false)\n expect(maySend(' ')).toBe(false)\n expect(maySend('inbound')).toBe(true)\n expect(maySend('inbound', { reason: 'inbound' })).toBe(false)\n expect(maySend('moved to inbound', { reason: 'inbound' })).toBe(true)\n })\n})\n\ndescribe('the decision popup reads well with a big role (found end to end, 2026-10-02)', () => {\n const t = (key: string, options: any = {}) => (key === 'access-request.text.and more' ? `${options.list} +${options.count}` : options.defaultValue ?? key)\n\n it('★ what blocks first, then the power to give roles, then a smaller role, then the rest', () => {\n const issues = [\n { code: 'role-grants-more', blocking: false },\n { code: 'smaller-role-exists', blocking: false },\n { code: 'role-grants-admin', blocking: false },\n { code: 'deny-needs-reason', blocking: true }\n ]\n expect(orderedIssues(issues).map(i => i.code)).toEqual(['deny-needs-reason', 'role-grants-admin', 'smaller-role-exists', 'role-grants-more'])\n })\n\n it('★ a long privilege list names five and counts the rest', () => {\n expect(shortList(t, 'a, b, c, d, e, f, g')).toBe('a, b, c, d, e +2')\n expect(shortList(t, 'a, b')).toBe('a, b')\n })\n})\n"]}
@@ -42,3 +42,11 @@ export declare function showsRoleOrigin(roles: RoleWithOrigin[] | null | undefin
42
42
  * labelled, so an unlabelled row reads as "not said" instead of as a domain called something.
43
43
  */
44
44
  export declare function roleOrigin(role: RoleWithOrigin): string;
45
+ /**
46
+ * The parent domain's roles a person here may not change (things-factory ADR-0103 decision 8).
47
+ *
48
+ * Holding a role is not tied to a domain: a parent's role given from a child counts in the parent and in its other
49
+ * children. So only someone who decides roles in the parent may give or take it - the server refuses everyone else
50
+ * (`role-belongs-to-parent`), and the screen locks those boxes rather than let a save fail.
51
+ */
52
+ export declare function lockedParentRoles(parentRoleIds: Iterable<string>, mayChangeParentRoles: boolean): Set<string>;
@@ -44,4 +44,14 @@ export function showsRoleOrigin(roles) {
44
44
  export function roleOrigin(role) {
45
45
  return role?.domain?.name || '';
46
46
  }
47
+ /**
48
+ * The parent domain's roles a person here may not change (things-factory ADR-0103 decision 8).
49
+ *
50
+ * Holding a role is not tied to a domain: a parent's role given from a child counts in the parent and in its other
51
+ * children. So only someone who decides roles in the parent may give or take it - the server refuses everyone else
52
+ * (`role-belongs-to-parent`), and the screen locks those boxes rather than let a save fail.
53
+ */
54
+ export function lockedParentRoles(parentRoleIds, mayChangeParentRoles) {
55
+ return mayChangeParentRoles ? new Set() : new Set(parentRoleIds);
56
+ }
47
57
  //# sourceMappingURL=role-origin.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"role-origin.js","sourceRoot":"","sources":["../../client/utils/role-origin.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAOH;;;;;;GAMG;AACH,MAAM,UAAU,eAAe,CAAC,KAA0C;IACxE,IAAI,CAAC,KAAK,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QAC/B,OAAO,KAAK,CAAA;IACd,CAAC;IAED,MAAM,OAAO,GAAG,IAAI,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,IAAI,EAAE,CAAC,CAAC,CAAA;IAEpE,OAAO,OAAO,CAAC,IAAI,GAAG,CAAC,CAAA;AACzB,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,UAAU,CAAC,IAAoB;IAC7C,OAAO,IAAI,EAAE,MAAM,EAAE,IAAI,IAAI,EAAE,CAAA;AACjC,CAAC","sourcesContent":["/**\n * When a list of roles has to say where each one came from. (Pure rule.)\n *\n * ## The situation it is for\n *\n * Roles are visible one level up: a domain sees its own and its parent's. Nothing stops a\n * domain having a role with the same name as one it inherits — the duplicate checks on the\n * server ask only about the domain itself — so a picker can offer two rows reading `approver`\n * with no way to tell them apart. Whichever gets ticked decides who a gate lets through, and\n * an approval line can end up pointing at the one nobody looks at (ADR-0062 decision 3).\n *\n * ## Why the rule is about the list and not about one role\n *\n * A role does not know whether it is inherited — that depends on which domain is being looked\n * at, and a list of roles from one domain is the ordinary case. So the question is asked of the\n * list: **does this set span more than one domain?** If it does, every row says which, and the\n * rows that shared a name stop being identical. If it does not, nothing is added and the\n * ordinary case stays quiet.\n *\n * That also answers correctly for a role whose name happens to be unique but which still comes\n * from somewhere else — granting one of those is a different act from granting your own, and\n * the operator should be able to see which they are doing.\n */\n\n/** The part of a role this rule reads. `domain` is absent on lists that never asked for it. */\nexport type RoleWithOrigin = {\n domain?: { name?: string | null } | null\n} | null | undefined\n\n/**\n * Should this list show where each role comes from?\n *\n * False when every role names the same domain, when none of them names one at all, and for an\n * empty list. A list that is only partly labelled counts as spanning — one row that cannot say\n * where it came from, beside rows that can, is the ambiguity this exists to remove.\n */\nexport function showsRoleOrigin(roles: RoleWithOrigin[] | null | undefined): boolean {\n if (!roles || roles.length < 2) {\n return false\n }\n\n const origins = new Set(roles.map(role => role?.domain?.name || ''))\n\n return origins.size > 1\n}\n\n/**\n * What to print beside a role, or an empty string.\n *\n * A role with no domain on it prints nothing rather than a placeholder: the list is already\n * labelled, so an unlabelled row reads as \"not said\" instead of as a domain called something.\n */\nexport function roleOrigin(role: RoleWithOrigin): string {\n return role?.domain?.name || ''\n}\n"]}
1
+ {"version":3,"file":"role-origin.js","sourceRoot":"","sources":["../../client/utils/role-origin.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAOH;;;;;;GAMG;AACH,MAAM,UAAU,eAAe,CAAC,KAA0C;IACxE,IAAI,CAAC,KAAK,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QAC/B,OAAO,KAAK,CAAA;IACd,CAAC;IAED,MAAM,OAAO,GAAG,IAAI,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,IAAI,EAAE,CAAC,CAAC,CAAA;IAEpE,OAAO,OAAO,CAAC,IAAI,GAAG,CAAC,CAAA;AACzB,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,UAAU,CAAC,IAAoB;IAC7C,OAAO,IAAI,EAAE,MAAM,EAAE,IAAI,IAAI,EAAE,CAAA;AACjC,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,iBAAiB,CAAC,aAA+B,EAAE,oBAA6B;IAC9F,OAAO,oBAAoB,CAAC,CAAC,CAAC,IAAI,GAAG,EAAE,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,aAAa,CAAC,CAAA;AAClE,CAAC","sourcesContent":["/**\n * When a list of roles has to say where each one came from. (Pure rule.)\n *\n * ## The situation it is for\n *\n * Roles are visible one level up: a domain sees its own and its parent's. Nothing stops a\n * domain having a role with the same name as one it inherits — the duplicate checks on the\n * server ask only about the domain itself — so a picker can offer two rows reading `approver`\n * with no way to tell them apart. Whichever gets ticked decides who a gate lets through, and\n * an approval line can end up pointing at the one nobody looks at (ADR-0062 decision 3).\n *\n * ## Why the rule is about the list and not about one role\n *\n * A role does not know whether it is inherited — that depends on which domain is being looked\n * at, and a list of roles from one domain is the ordinary case. So the question is asked of the\n * list: **does this set span more than one domain?** If it does, every row says which, and the\n * rows that shared a name stop being identical. If it does not, nothing is added and the\n * ordinary case stays quiet.\n *\n * That also answers correctly for a role whose name happens to be unique but which still comes\n * from somewhere else — granting one of those is a different act from granting your own, and\n * the operator should be able to see which they are doing.\n */\n\n/** The part of a role this rule reads. `domain` is absent on lists that never asked for it. */\nexport type RoleWithOrigin = {\n domain?: { name?: string | null } | null\n} | null | undefined\n\n/**\n * Should this list show where each role comes from?\n *\n * False when every role names the same domain, when none of them names one at all, and for an\n * empty list. A list that is only partly labelled counts as spanning — one row that cannot say\n * where it came from, beside rows that can, is the ambiguity this exists to remove.\n */\nexport function showsRoleOrigin(roles: RoleWithOrigin[] | null | undefined): boolean {\n if (!roles || roles.length < 2) {\n return false\n }\n\n const origins = new Set(roles.map(role => role?.domain?.name || ''))\n\n return origins.size > 1\n}\n\n/**\n * What to print beside a role, or an empty string.\n *\n * A role with no domain on it prints nothing rather than a placeholder: the list is already\n * labelled, so an unlabelled row reads as \"not said\" instead of as a domain called something.\n */\nexport function roleOrigin(role: RoleWithOrigin): string {\n return role?.domain?.name || ''\n}\n\n/**\n * The parent domain's roles a person here may not change (things-factory ADR-0103 decision 8).\n *\n * Holding a role is not tied to a domain: a parent's role given from a child counts in the parent and in its other\n * children. So only someone who decides roles in the parent may give or take it - the server refuses everyone else\n * (`role-belongs-to-parent`), and the screen locks those boxes rather than let a save fail.\n */\nexport function lockedParentRoles(parentRoleIds: Iterable<string>, mayChangeParentRoles: boolean): Set<string> {\n return mayChangeParentRoles ? new Set() : new Set(parentRoleIds)\n}\n"]}
@@ -1,4 +1,4 @@
1
- import { roleOrigin, showsRoleOrigin } from './role-origin';
1
+ import { lockedParentRoles, roleOrigin, showsRoleOrigin } from './role-origin';
2
2
  /**
3
3
  * Whether a role picker has to say where each role came from.
4
4
  *
@@ -57,4 +57,10 @@ describe('what is printed beside a role', () => {
57
57
  expect(roleOrigin({ domain: { name: null } })).toEqual('');
58
58
  });
59
59
  });
60
+ describe('the parent domain\'s roles (ADR-0103 decision 8)', () => {
61
+ it('★ locked for someone who does not decide in the parent, open for someone who does', () => {
62
+ expect([...lockedParentRoles(['p1', 'p2'], false)].sort()).toEqual(['p1', 'p2']);
63
+ expect([...lockedParentRoles(['p1', 'p2'], true)]).toEqual([]);
64
+ });
65
+ });
60
66
  //# sourceMappingURL=role-origin.test.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"role-origin.test.js","sourceRoot":"","sources":["../../client/utils/role-origin.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { roleOrigin, showsRoleOrigin } from './role-origin'\n\n/**\n * Whether a role picker has to say where each role came from.\n *\n * The cost of being wrong is not symmetric. Saying nothing when the list spans two domains\n * leaves two identical checkboxes, and ticking the wrong one points a gate — or an approval\n * line — at a role nobody looks at. Saying it when there is nothing to disambiguate only adds\n * a word.\n */\n\nconst OURS = { domain: { name: 'acme' } }\nconst THEIRS = { domain: { name: 'system' } }\n\ndescribe('a list that needs to say', () => {\n it('spans two domains', () => {\n /* ⚠ The case. Two rows can read the same name and only this separates them. */\n expect(showsRoleOrigin([OURS, THEIRS])).toBe(true)\n })\n\n it('says so even when the names do not collide', () => {\n /*\n * Granting an inherited role is a different act from granting your own, whether or not\n * some other row happens to share its name.\n */\n expect(showsRoleOrigin([{ domain: { name: 'acme' } }, { domain: { name: 'system' } }])).toBe(true)\n })\n\n it('counts a row that cannot say where it came from as a difference', () => {\n /*\n * ⚠ A half-labelled list is the ambiguity, not the cure. One blank row beside labelled ones\n * is exactly what the reader cannot resolve.\n */\n expect(showsRoleOrigin([OURS, {}])).toBe(true)\n expect(showsRoleOrigin([OURS, { domain: null }])).toBe(true)\n })\n})\n\ndescribe('a list that does not', () => {\n it('is all one domain', () => {\n expect(showsRoleOrigin([OURS, OURS, OURS])).toBe(false)\n })\n\n it('never asked for the domain at all', () => {\n /* Nothing to say. Labelling every row with nothing would be noise, not information. */\n expect(showsRoleOrigin([{}, {}])).toBe(false)\n })\n\n it('has one role, or none', () => {\n expect(showsRoleOrigin([OURS])).toBe(false)\n expect(showsRoleOrigin([])).toBe(false)\n expect(showsRoleOrigin(null)).toBe(false)\n expect(showsRoleOrigin(undefined)).toBe(false)\n })\n})\n\ndescribe('what is printed beside a role', () => {\n it('is the domain it came from', () => {\n expect(roleOrigin(OURS)).toEqual('acme')\n })\n\n it('is nothing when the role does not carry one', () => {\n /* Not a placeholder. An unlabelled row reads as \"not said\", not as a domain named that. */\n expect(roleOrigin({})).toEqual('')\n expect(roleOrigin({ domain: null })).toEqual('')\n expect(roleOrigin(null)).toEqual('')\n expect(roleOrigin({ domain: { name: null } })).toEqual('')\n })\n})\n"]}
1
+ {"version":3,"file":"role-origin.test.js","sourceRoot":"","sources":["../../client/utils/role-origin.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { lockedParentRoles, roleOrigin, showsRoleOrigin } from './role-origin'\n\n/**\n * Whether a role picker has to say where each role came from.\n *\n * The cost of being wrong is not symmetric. Saying nothing when the list spans two domains\n * leaves two identical checkboxes, and ticking the wrong one points a gate — or an approval\n * line — at a role nobody looks at. Saying it when there is nothing to disambiguate only adds\n * a word.\n */\n\nconst OURS = { domain: { name: 'acme' } }\nconst THEIRS = { domain: { name: 'system' } }\n\ndescribe('a list that needs to say', () => {\n it('spans two domains', () => {\n /* ⚠ The case. Two rows can read the same name and only this separates them. */\n expect(showsRoleOrigin([OURS, THEIRS])).toBe(true)\n })\n\n it('says so even when the names do not collide', () => {\n /*\n * Granting an inherited role is a different act from granting your own, whether or not\n * some other row happens to share its name.\n */\n expect(showsRoleOrigin([{ domain: { name: 'acme' } }, { domain: { name: 'system' } }])).toBe(true)\n })\n\n it('counts a row that cannot say where it came from as a difference', () => {\n /*\n * ⚠ A half-labelled list is the ambiguity, not the cure. One blank row beside labelled ones\n * is exactly what the reader cannot resolve.\n */\n expect(showsRoleOrigin([OURS, {}])).toBe(true)\n expect(showsRoleOrigin([OURS, { domain: null }])).toBe(true)\n })\n})\n\ndescribe('a list that does not', () => {\n it('is all one domain', () => {\n expect(showsRoleOrigin([OURS, OURS, OURS])).toBe(false)\n })\n\n it('never asked for the domain at all', () => {\n /* Nothing to say. Labelling every row with nothing would be noise, not information. */\n expect(showsRoleOrigin([{}, {}])).toBe(false)\n })\n\n it('has one role, or none', () => {\n expect(showsRoleOrigin([OURS])).toBe(false)\n expect(showsRoleOrigin([])).toBe(false)\n expect(showsRoleOrigin(null)).toBe(false)\n expect(showsRoleOrigin(undefined)).toBe(false)\n })\n})\n\ndescribe('what is printed beside a role', () => {\n it('is the domain it came from', () => {\n expect(roleOrigin(OURS)).toEqual('acme')\n })\n\n it('is nothing when the role does not carry one', () => {\n /* Not a placeholder. An unlabelled row reads as \"not said\", not as a domain named that. */\n expect(roleOrigin({})).toEqual('')\n expect(roleOrigin({ domain: null })).toEqual('')\n expect(roleOrigin(null)).toEqual('')\n expect(roleOrigin({ domain: { name: null } })).toEqual('')\n })\n})\n\ndescribe('the parent domain\\'s roles (ADR-0103 decision 8)', () => {\n it('★ locked for someone who does not decide in the parent, open for someone who does', () => {\n expect([...lockedParentRoles(['p1', 'p2'], false)].sort()).toEqual(['p1', 'p2'])\n expect([...lockedParentRoles(['p1', 'p2'], true)]).toEqual([])\n })\n})\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/auth-ui",
3
- "version": "10.1.107",
3
+ "version": "10.1.113",
4
4
  "main": "dist-server/index.js",
5
5
  "browser": "dist-client/index.js",
6
6
  "things-factory": true,
@@ -39,11 +39,11 @@
39
39
  "@operato/popup": "^10.15.0",
40
40
  "@operato/shell": "^10.29.2",
41
41
  "@operato/styles": "^10.0.0",
42
- "@things-factory/auth-base": "^10.1.107",
43
- "@things-factory/component-ui": "^10.1.107",
42
+ "@things-factory/auth-base": "^10.1.113",
43
+ "@things-factory/component-ui": "^10.1.113",
44
44
  "@things-factory/more-base": "^10.1.106",
45
45
  "base64url": "^3.0.1",
46
46
  "clipboard": "^2.0.6"
47
47
  },
48
- "gitHead": "65a3d5889e687d8122a65f029b926b1a4769d1c0"
48
+ "gitHead": "9da1e467ddce23e600e64f08454289cb3b806212"
49
49
  }
@@ -409,5 +409,14 @@
409
409
  "access-request.text.recorded and told": "the request is recorded and {count} people who decide access are told.",
410
410
  "access-request.text.nobody to tell": "nobody in this domain decides access requests, so it cannot be sent. ask your administrator directly.",
411
411
  "access-request.text.denied lately": "denied on {at}: {reason}. to ask again, write what has changed.",
412
- "access-request.button.request": "request access…"
412
+ "access-request.button.request": "request access…",
413
+ "text.parent roles are changed by the parent domain": "roles of the parent domain can be changed only by someone who decides roles there.",
414
+ "access-request.issue.role-belongs-to-parent": "{role} belongs to the parent domain - only someone who decides roles there can give it.",
415
+ "access-request.dock.intro title": "decide access requests with the assistant",
416
+ "access-request.dock.intro subtitle": "it reads the waiting requests and drafts decisions; you press the card.",
417
+ "access-request.dock.examples": "examples",
418
+ "access-request.dock.example today": "sort out today's access requests",
419
+ "access-request.dock.example smallest": "grant each with the smallest role that gives it",
420
+ "access-request.dock.example one person": "show what one person asked for and what each role would give",
421
+ "access-request.text.and more": "{list} and {count} more"
413
422
  }
@@ -409,5 +409,14 @@
409
409
  "access-request.text.recorded and told": "このリクエストは記録され、権限を決める管理者 {count} 名に通知されます。",
410
410
  "access-request.text.nobody to tell": "この$t(term.domain)には権限を決める管理者がいないため送信できません。管理者に直接問い合わせてください。",
411
411
  "access-request.text.denied lately": "{at} に拒否されました: {reason}。再度リクエストするには、何が変わったかを書いてください。",
412
- "access-request.button.request": "権限をリクエスト…"
412
+ "access-request.button.request": "権限をリクエスト…",
413
+ "text.parent roles are changed by the parent domain": "上位$t(term.domain)のロールは、その$t(term.domain)で権限を決める人だけが変更できます。",
414
+ "access-request.issue.role-belongs-to-parent": "{role} は上位$t(term.domain)のロールです。その$t(term.domain)で権限を決める人だけが付与できます。",
415
+ "access-request.dock.intro title": "AI と一緒に権限リクエストを整理しましょう",
416
+ "access-request.dock.intro subtitle": "保留中のリクエストを読んで決定案を作ります。決定はカードで押します。",
417
+ "access-request.dock.examples": "例",
418
+ "access-request.dock.example today": "今日届いた権限リクエストを整理して",
419
+ "access-request.dock.example smallest": "リクエストごとに最小のロールで付与案を作って",
420
+ "access-request.dock.example one person": "一人のリクエストと、ロールごとに追加で与えるものを見せて",
421
+ "access-request.text.and more": "{list} ほか {count} 件"
413
422
  }
@@ -409,5 +409,14 @@
409
409
  "access-request.text.recorded and told": "이 요청은 기록되고, 권한을 정하는 관리자 {count}명에게 알림이 갑니다.",
410
410
  "access-request.text.nobody to tell": "이 $t(term.domain)에는 권한을 정할 관리자가 없어 보낼 수 없습니다. 관리자에게 직접 문의하세요.",
411
411
  "access-request.text.denied lately": "{at}에 거부되었습니다: {reason}. 다시 요청하려면 무엇이 달라졌는지 적어 주세요.",
412
- "access-request.button.request": "권한 요청…"
412
+ "access-request.button.request": "권한 요청…",
413
+ "text.parent roles are changed by the parent domain": "상위 $t(term.domain)의 역할은 그 $t(term.domain)에서 권한을 정하는 사람만 바꿀 수 있습니다.",
414
+ "access-request.issue.role-belongs-to-parent": "{role} 역할은 상위 $t(term.domain)의 것입니다. 그 $t(term.domain)에서 권한을 정하는 사람만 줄 수 있습니다.",
415
+ "access-request.dock.intro title": "AI 와 함께 권한 요청을 정리하세요",
416
+ "access-request.dock.intro subtitle": "기다리는 요청을 읽고 결정안을 만듭니다. 결정은 카드에서 직접 누릅니다.",
417
+ "access-request.dock.examples": "예시",
418
+ "access-request.dock.example today": "오늘 들어온 권한 요청 정리해 줘",
419
+ "access-request.dock.example smallest": "요청마다 가장 작은 역할로 부여안을 만들어 줘",
420
+ "access-request.dock.example one person": "한 사람이 요청한 것과 역할마다 더 주는 것을 보여 줘",
421
+ "access-request.text.and more": "{list} 외 {count}개"
413
422
  }
@@ -409,5 +409,14 @@
409
409
  "access-request.text.recorded and told": "permintaan ini direkodkan dan {count} orang yang memutuskan akses akan dimaklumkan.",
410
410
  "access-request.text.nobody to tell": "tiada sesiapa dalam $t(term.domain) ini yang memutuskan akses, jadi ia tidak boleh dihantar. hubungi pentadbir anda terus.",
411
411
  "access-request.text.denied lately": "ditolak pada {at}: {reason}. untuk memohon semula, tulis apa yang telah berubah.",
412
- "access-request.button.request": "mohon akses…"
412
+ "access-request.button.request": "mohon akses…",
413
+ "text.parent roles are changed by the parent domain": "peranan $t(term.domain) induk hanya boleh diubah oleh mereka yang memutuskan peranan di sana.",
414
+ "access-request.issue.role-belongs-to-parent": "{role} milik $t(term.domain) induk - hanya mereka yang memutuskan peranan di sana boleh memberikannya.",
415
+ "access-request.dock.intro title": "putuskan permintaan akses bersama pembantu",
416
+ "access-request.dock.intro subtitle": "ia membaca permintaan yang menunggu dan menyediakan draf keputusan; anda menekan kad.",
417
+ "access-request.dock.examples": "contoh",
418
+ "access-request.dock.example today": "susun permintaan akses hari ini",
419
+ "access-request.dock.example smallest": "sediakan draf pemberian dengan peranan terkecil bagi setiap permintaan",
420
+ "access-request.dock.example one person": "tunjukkan apa yang diminta seseorang dan apa yang diberi oleh setiap peranan",
421
+ "access-request.text.and more": "{list} dan {count} lagi"
413
422
  }
@@ -409,5 +409,14 @@
409
409
  "access-request.text.recorded and told": "此请求会被记录,并通知 {count} 位负责权限的管理员。",
410
410
  "access-request.text.nobody to tell": "此$t(term.domain)没有负责权限的管理员,无法发送。请直接联系管理员。",
411
411
  "access-request.text.denied lately": "已于 {at} 被拒绝: {reason}。如需再次请求,请写明有何变化。",
412
- "access-request.button.request": "请求权限…"
412
+ "access-request.button.request": "请求权限…",
413
+ "text.parent roles are changed by the parent domain": "上级$t(term.domain)的角色只能由该$t(term.domain)负责权限的人更改。",
414
+ "access-request.issue.role-belongs-to-parent": "{role} 属于上级$t(term.domain),只有在该$t(term.domain)负责权限的人才能授予。",
415
+ "access-request.dock.intro title": "与 AI 一起处理权限请求",
416
+ "access-request.dock.intro subtitle": "读取待处理的请求并起草处理方案,由您在卡片上确认。",
417
+ "access-request.dock.examples": "示例",
418
+ "access-request.dock.example today": "整理今天的权限请求",
419
+ "access-request.dock.example smallest": "为每个请求用最小的角色起草授予方案",
420
+ "access-request.dock.example one person": "显示某人的请求以及每个角色额外授予的内容",
421
+ "access-request.text.and more": "{list} 等另外 {count} 项"
413
422
  }