@things-factory/auth-ui 10.1.13 → 10.1.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-client/components/role-template-picker.js +6 -8
- package/dist-client/components/role-template-picker.js.map +1 -1
- package/dist-client/components/secret-field.d.ts +35 -0
- package/dist-client/components/secret-field.js +149 -0
- package/dist-client/components/secret-field.js.map +1 -0
- package/dist-client/pages/app-binding/app-binding-list-query.test.d.ts +1 -0
- package/dist-client/pages/app-binding/app-binding-list-query.test.js +46 -0
- package/dist-client/pages/app-binding/app-binding-list-query.test.js.map +1 -0
- package/dist-client/pages/app-binding/app-binding.d.ts +1 -0
- package/dist-client/pages/app-binding/app-binding.js +8 -8
- package/dist-client/pages/app-binding/app-binding.js.map +1 -1
- package/dist-client/pages/app-binding/app-bindings.d.ts +63 -2
- package/dist-client/pages/app-binding/app-bindings.js +199 -117
- package/dist-client/pages/app-binding/app-bindings.js.map +1 -1
- package/dist-client/pages/appliance/appliance-list-query.test.d.ts +1 -0
- package/dist-client/pages/appliance/appliance-list-query.test.js +58 -0
- package/dist-client/pages/appliance/appliance-list-query.test.js.map +1 -0
- package/dist-client/pages/appliance/appliance.d.ts +1 -0
- package/dist-client/pages/appliance/appliance.js +2 -4
- package/dist-client/pages/appliance/appliance.js.map +1 -1
- package/dist-client/pages/appliance/home.d.ts +61 -2
- package/dist-client/pages/appliance/home.js +200 -122
- package/dist-client/pages/appliance/home.js.map +1 -1
- package/dist-client/pages/application/application.d.ts +1 -0
- package/dist-client/pages/application/application.js +10 -3
- package/dist-client/pages/application/application.js.map +1 -1
- package/dist-client/pages/application/applications.d.ts +61 -2
- package/dist-client/pages/application/applications.js +193 -128
- package/dist-client/pages/application/applications.js.map +1 -1
- package/dist-client/pages/application/register.js +81 -27
- package/dist-client/pages/application/register.js.map +1 -1
- package/dist-client/pages/auth-provider/auth-provider-management.d.ts +4 -2
- package/dist-client/pages/auth-provider/auth-provider-management.js +35 -6
- package/dist-client/pages/auth-provider/auth-provider-management.js.map +1 -1
- package/dist-client/pages/domain-owner/domain-owner-management.d.ts +2 -0
- package/dist-client/pages/domain-owner/domain-owner-management.js +34 -7
- package/dist-client/pages/domain-owner/domain-owner-management.js.map +1 -1
- package/dist-client/pages/role/role-list-page.d.ts +20 -0
- package/dist-client/pages/role/role-list-page.js +58 -1
- package/dist-client/pages/role/role-list-page.js.map +1 -1
- package/dist-client/pages/role/role-privilege-page.js +1 -10
- package/dist-client/pages/role/role-privilege-page.js.map +1 -1
- package/dist-client/pages/secrets-are-not-printed.test.d.ts +1 -0
- package/dist-client/pages/secrets-are-not-printed.test.js +70 -0
- package/dist-client/pages/secrets-are-not-printed.test.js.map +1 -0
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/utils/application-status.d.ts +32 -0
- package/dist-client/utils/application-status.js +45 -0
- package/dist-client/utils/application-status.js.map +1 -0
- package/dist-client/utils/application-status.test.d.ts +1 -0
- package/dist-client/utils/application-status.test.js +45 -0
- package/dist-client/utils/application-status.test.js.map +1 -0
- package/dist-client/utils/form-values.d.ts +42 -0
- package/dist-client/utils/form-values.js +57 -0
- package/dist-client/utils/form-values.js.map +1 -0
- package/dist-client/utils/form-values.test.d.ts +1 -0
- package/dist-client/utils/form-values.test.js +62 -0
- package/dist-client/utils/form-values.test.js.map +1 -0
- package/dist-client/utils/list-result.d.ts +62 -0
- package/dist-client/utils/list-result.js +92 -0
- package/dist-client/utils/list-result.js.map +1 -0
- package/dist-client/utils/list-result.test.d.ts +1 -0
- package/dist-client/utils/list-result.test.js +160 -0
- package/dist-client/utils/list-result.test.js.map +1 -0
- package/dist-client/utils/privilege-axis.d.ts +52 -0
- package/dist-client/utils/privilege-axis.js +55 -0
- package/dist-client/utils/privilege-axis.js.map +1 -0
- package/dist-client/utils/privilege-axis.test.d.ts +1 -0
- package/dist-client/utils/privilege-axis.test.js +39 -0
- package/dist-client/utils/privilege-axis.test.js.map +1 -0
- package/dist-client/utils/secret-display.d.ts +51 -0
- package/dist-client/utils/secret-display.js +61 -0
- package/dist-client/utils/secret-display.js.map +1 -0
- package/dist-client/utils/secret-display.test.d.ts +1 -0
- package/dist-client/utils/secret-display.test.js +59 -0
- package/dist-client/utils/secret-display.test.js.map +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/translations/en.json +33 -0
- package/translations/ja.json +33 -0
- package/translations/ko.json +33 -0
- package/translations/ms.json +33 -0
- package/translations/zh.json +33 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { arrayFromResponse, listOutcome, pageFromResponse } from './list-result';
|
|
2
|
+
/**
|
|
3
|
+
* A list screen may only claim what the server actually answered.
|
|
4
|
+
*
|
|
5
|
+
* The three auth list screens each carried this rule inline and each got it wrong the same
|
|
6
|
+
* way: a failed query produced an empty table with nothing said. These cases are the ones
|
|
7
|
+
* that were failing in production, written down so they stay fixed.
|
|
8
|
+
*/
|
|
9
|
+
describe('reading a list page out of a response', () => {
|
|
10
|
+
it('reads the page when the query answered', () => {
|
|
11
|
+
const response = { data: { applications: { items: [{ id: 'a' }], total: 1 } } };
|
|
12
|
+
expect(pageFromResponse(response, 'applications')).toEqual({ items: [{ id: 'a' }], total: 1 });
|
|
13
|
+
});
|
|
14
|
+
it('refuses a response carrying errors', () => {
|
|
15
|
+
/*
|
|
16
|
+
* The shared client runs with errorPolicy 'all', so errors arrive here instead of
|
|
17
|
+
* throwing. This is the case that used to slip through.
|
|
18
|
+
*/
|
|
19
|
+
const response = { errors: [{ message: 'user:query 권한이 필요합니다' }], data: null };
|
|
20
|
+
expect(pageFromResponse(response, 'applications')).toBeUndefined();
|
|
21
|
+
});
|
|
22
|
+
it('refuses a response that carries errors even when the page looks complete', () => {
|
|
23
|
+
/*
|
|
24
|
+
* ⚠ The case that makes the error check load-bearing.
|
|
25
|
+
*
|
|
26
|
+
* errorPolicy 'all' can return `data` **and** `errors` together — a list that came back
|
|
27
|
+
* while some field resolver failed. Every other case here is caught by the data checks,
|
|
28
|
+
* so without this one the error branch could be deleted and the suite stayed green. It
|
|
29
|
+
* was: removing it changed nothing until this test existed.
|
|
30
|
+
*
|
|
31
|
+
* Refusing is the lean. A partial list rendered as a whole one is a claim the screen
|
|
32
|
+
* cannot support, and the person reading it has no way to tell which rows are missing.
|
|
33
|
+
*/
|
|
34
|
+
const response = {
|
|
35
|
+
errors: [{ message: 'privileges field failed' }],
|
|
36
|
+
data: { applications: { items: [{ id: 'a' }], total: 1 } }
|
|
37
|
+
};
|
|
38
|
+
expect(pageFromResponse(response, 'applications')).toBeUndefined();
|
|
39
|
+
});
|
|
40
|
+
it('refuses a partial response where the field is null but errors explain why', () => {
|
|
41
|
+
/*
|
|
42
|
+
* The quiet one. `data` exists, so a check that only asks "is data there" passes, and
|
|
43
|
+
* the next line reads `.items` off null.
|
|
44
|
+
*/
|
|
45
|
+
const response = { errors: [{ message: 'forbidden' }], data: { applications: null } };
|
|
46
|
+
expect(pageFromResponse(response, 'applications')).toBeUndefined();
|
|
47
|
+
});
|
|
48
|
+
it('refuses a response with no errors but no field either', () => {
|
|
49
|
+
expect(pageFromResponse({ data: {} }, 'applications')).toBeUndefined();
|
|
50
|
+
expect(pageFromResponse({ data: { applications: null } }, 'applications')).toBeUndefined();
|
|
51
|
+
});
|
|
52
|
+
it('refuses a field that is not a page', () => {
|
|
53
|
+
/* `items` missing means we cannot list it, whatever else came back. */
|
|
54
|
+
expect(pageFromResponse({ data: { applications: { total: 3 } } }, 'applications')).toBeUndefined();
|
|
55
|
+
});
|
|
56
|
+
it('refuses nothing at all', () => {
|
|
57
|
+
expect(pageFromResponse(undefined, 'applications')).toBeUndefined();
|
|
58
|
+
expect(pageFromResponse(null, 'applications')).toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
describe('telling a refused query from a withheld column', () => {
|
|
62
|
+
/*
|
|
63
|
+
* This house gates individual columns: `Appliance.accessToken` and
|
|
64
|
+
* `AuthProvider.clientSecret` each carry their own @privilege. A viewer without it gets
|
|
65
|
+
* the whole list plus one error pointing at that value.
|
|
66
|
+
*
|
|
67
|
+
* Reading the error's path is what separates the two. Getting it wrong is visible either
|
|
68
|
+
* way: black out a list that arrived, or show a refused query as empty.
|
|
69
|
+
*/
|
|
70
|
+
it('keeps the page when only a value inside it was refused', () => {
|
|
71
|
+
const response = {
|
|
72
|
+
errors: [{ message: 'security:query 권한이 필요합니다', path: ['authProviders', 'items', 2, 'clientSecret'] }],
|
|
73
|
+
data: { authProviders: { items: [{ id: 'a', clientSecret: null }], total: 1 } }
|
|
74
|
+
};
|
|
75
|
+
expect(pageFromResponse(response, 'authProviders')).toEqual({
|
|
76
|
+
items: [{ id: 'a', clientSecret: null }],
|
|
77
|
+
total: 1
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
it('refuses when the query itself was the thing rejected', () => {
|
|
81
|
+
const response = {
|
|
82
|
+
errors: [{ message: 'user:query 권한이 필요합니다', path: ['authProviders'] }],
|
|
83
|
+
data: { authProviders: null }
|
|
84
|
+
};
|
|
85
|
+
expect(pageFromResponse(response, 'authProviders')).toBeUndefined();
|
|
86
|
+
});
|
|
87
|
+
it('refuses an error that does not say where it happened', () => {
|
|
88
|
+
/* Unknown scope leans to the safe side: a network or parse failure stops the list. */
|
|
89
|
+
const response = {
|
|
90
|
+
errors: [{ message: 'Failed to fetch' }],
|
|
91
|
+
data: { authProviders: { items: [{ id: 'a' }], total: 1 } }
|
|
92
|
+
};
|
|
93
|
+
expect(pageFromResponse(response, 'authProviders')).toBeUndefined();
|
|
94
|
+
});
|
|
95
|
+
it('refuses when any one error is fatal, even beside a harmless one', () => {
|
|
96
|
+
const response = {
|
|
97
|
+
errors: [
|
|
98
|
+
{ message: 'withheld', path: ['authProviders', 'items', 0, 'clientSecret'] },
|
|
99
|
+
{ message: 'refused', path: ['authProviders'] }
|
|
100
|
+
],
|
|
101
|
+
data: { authProviders: { items: [], total: 0 } }
|
|
102
|
+
};
|
|
103
|
+
expect(pageFromResponse(response, 'authProviders')).toBeUndefined();
|
|
104
|
+
});
|
|
105
|
+
it('applies the same reading to a bare array', () => {
|
|
106
|
+
const response = {
|
|
107
|
+
errors: [{ message: 'withheld', path: ['domainOwners', 0, 'reason'] }],
|
|
108
|
+
data: { domainOwners: [{ id: 'a', reason: null }] }
|
|
109
|
+
};
|
|
110
|
+
expect(arrayFromResponse(response, 'domainOwners')).toEqual({ items: [{ id: 'a', reason: null }], total: 1 });
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
describe('a query that answers with a bare array', () => {
|
|
114
|
+
it('reads the rows and counts them', () => {
|
|
115
|
+
const response = { data: { domainOwners: [{ id: 'a' }, { id: 'b' }] } };
|
|
116
|
+
expect(arrayFromResponse(response, 'domainOwners')).toEqual({ items: [{ id: 'a' }, { id: 'b' }], total: 2 });
|
|
117
|
+
});
|
|
118
|
+
it('keeps an answer of none as an answer', () => {
|
|
119
|
+
expect(arrayFromResponse({ data: { domainOwners: [] } }, 'domainOwners')).toEqual({ items: [], total: 0 });
|
|
120
|
+
});
|
|
121
|
+
it('refuses a response carrying errors even when rows came back', () => {
|
|
122
|
+
/*
|
|
123
|
+
* ⚠ The load-bearing case, same as for pages. The screen this replaces wrote
|
|
124
|
+
* `response.data?.domainOwners || []`, so a refused query read as "this domain has no
|
|
125
|
+
* owners" — a sentence an administrator may act on by adding one.
|
|
126
|
+
*/
|
|
127
|
+
const response = { errors: [{ message: 'forbidden' }], data: { domainOwners: [{ id: 'a' }] } };
|
|
128
|
+
expect(arrayFromResponse(response, 'domainOwners')).toBeUndefined();
|
|
129
|
+
});
|
|
130
|
+
it('refuses a missing or non-array field', () => {
|
|
131
|
+
expect(arrayFromResponse({ data: {} }, 'domainOwners')).toBeUndefined();
|
|
132
|
+
expect(arrayFromResponse({ data: { domainOwners: null } }, 'domainOwners')).toBeUndefined();
|
|
133
|
+
expect(arrayFromResponse({ data: { domainOwners: { items: [] } } }, 'domainOwners')).toBeUndefined();
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
describe('what the screen may then say', () => {
|
|
137
|
+
it('shows the rows when there are rows', () => {
|
|
138
|
+
expect(listOutcome({ items: [{ id: 'a' }, { id: 'b' }], total: 2 })).toEqual({
|
|
139
|
+
items: [{ id: 'a' }, { id: 'b' }],
|
|
140
|
+
total: 2,
|
|
141
|
+
failed: false
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
it('an answer of none is not a failure', () => {
|
|
145
|
+
/* The distinction this whole file exists for, from the other side. */
|
|
146
|
+
expect(listOutcome({ items: [], total: 0 })).toEqual({ items: [], total: 0, failed: false });
|
|
147
|
+
});
|
|
148
|
+
it('no answer is a failure, and the rows are empty without claiming emptiness', () => {
|
|
149
|
+
expect(listOutcome(undefined)).toEqual({ items: [], total: 0, failed: true });
|
|
150
|
+
expect(listOutcome(null)).toEqual({ items: [], total: 0, failed: true });
|
|
151
|
+
});
|
|
152
|
+
it('counts the rows when the server sent no total', () => {
|
|
153
|
+
expect(listOutcome({ items: [{ id: 'a' }] }).total).toBe(1);
|
|
154
|
+
});
|
|
155
|
+
it('keeps a total of zero rather than recounting it', () => {
|
|
156
|
+
/* `?? ` and not `||` — a real zero must survive, or an empty list reports its length. */
|
|
157
|
+
expect(listOutcome({ items: [], total: 0 }).total).toBe(0);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
//# sourceMappingURL=list-result.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"list-result.test.js","sourceRoot":"","sources":["../../client/utils/list-result.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { arrayFromResponse, listOutcome, pageFromResponse } from './list-result'\n\n/**\n * A list screen may only claim what the server actually answered.\n *\n * The three auth list screens each carried this rule inline and each got it wrong the same\n * way: a failed query produced an empty table with nothing said. These cases are the ones\n * that were failing in production, written down so they stay fixed.\n */\n\ndescribe('reading a list page out of a response', () => {\n it('reads the page when the query answered', () => {\n const response = { data: { applications: { items: [{ id: 'a' }], total: 1 } } }\n\n expect(pageFromResponse(response, 'applications')).toEqual({ items: [{ id: 'a' }], total: 1 })\n })\n\n it('refuses a response carrying errors', () => {\n /*\n * The shared client runs with errorPolicy 'all', so errors arrive here instead of\n * throwing. This is the case that used to slip through.\n */\n const response = { errors: [{ message: 'user:query 권한이 필요합니다' }], data: null }\n\n expect(pageFromResponse(response, 'applications')).toBeUndefined()\n })\n\n it('refuses a response that carries errors even when the page looks complete', () => {\n /*\n * ⚠ The case that makes the error check load-bearing.\n *\n * errorPolicy 'all' can return `data` **and** `errors` together — a list that came back\n * while some field resolver failed. Every other case here is caught by the data checks,\n * so without this one the error branch could be deleted and the suite stayed green. It\n * was: removing it changed nothing until this test existed.\n *\n * Refusing is the lean. A partial list rendered as a whole one is a claim the screen\n * cannot support, and the person reading it has no way to tell which rows are missing.\n */\n const response = {\n errors: [{ message: 'privileges field failed' }],\n data: { applications: { items: [{ id: 'a' }], total: 1 } }\n }\n\n expect(pageFromResponse(response, 'applications')).toBeUndefined()\n })\n\n it('refuses a partial response where the field is null but errors explain why', () => {\n /*\n * The quiet one. `data` exists, so a check that only asks \"is data there\" passes, and\n * the next line reads `.items` off null.\n */\n const response = { errors: [{ message: 'forbidden' }], data: { applications: null } }\n\n expect(pageFromResponse(response, 'applications')).toBeUndefined()\n })\n\n it('refuses a response with no errors but no field either', () => {\n expect(pageFromResponse({ data: {} }, 'applications')).toBeUndefined()\n expect(pageFromResponse({ data: { applications: null } }, 'applications')).toBeUndefined()\n })\n\n it('refuses a field that is not a page', () => {\n /* `items` missing means we cannot list it, whatever else came back. */\n expect(pageFromResponse({ data: { applications: { total: 3 } } }, 'applications')).toBeUndefined()\n })\n\n it('refuses nothing at all', () => {\n expect(pageFromResponse(undefined, 'applications')).toBeUndefined()\n expect(pageFromResponse(null, 'applications')).toBeUndefined()\n })\n})\n\ndescribe('telling a refused query from a withheld column', () => {\n /*\n * This house gates individual columns: `Appliance.accessToken` and\n * `AuthProvider.clientSecret` each carry their own @privilege. A viewer without it gets\n * the whole list plus one error pointing at that value.\n *\n * Reading the error's path is what separates the two. Getting it wrong is visible either\n * way: black out a list that arrived, or show a refused query as empty.\n */\n it('keeps the page when only a value inside it was refused', () => {\n const response = {\n errors: [{ message: 'security:query 권한이 필요합니다', path: ['authProviders', 'items', 2, 'clientSecret'] }],\n data: { authProviders: { items: [{ id: 'a', clientSecret: null }], total: 1 } }\n }\n\n expect(pageFromResponse(response, 'authProviders')).toEqual({\n items: [{ id: 'a', clientSecret: null }],\n total: 1\n })\n })\n\n it('refuses when the query itself was the thing rejected', () => {\n const response = {\n errors: [{ message: 'user:query 권한이 필요합니다', path: ['authProviders'] }],\n data: { authProviders: null }\n }\n\n expect(pageFromResponse(response, 'authProviders')).toBeUndefined()\n })\n\n it('refuses an error that does not say where it happened', () => {\n /* Unknown scope leans to the safe side: a network or parse failure stops the list. */\n const response = {\n errors: [{ message: 'Failed to fetch' }],\n data: { authProviders: { items: [{ id: 'a' }], total: 1 } }\n }\n\n expect(pageFromResponse(response, 'authProviders')).toBeUndefined()\n })\n\n it('refuses when any one error is fatal, even beside a harmless one', () => {\n const response = {\n errors: [\n { message: 'withheld', path: ['authProviders', 'items', 0, 'clientSecret'] },\n { message: 'refused', path: ['authProviders'] }\n ],\n data: { authProviders: { items: [], total: 0 } }\n }\n\n expect(pageFromResponse(response, 'authProviders')).toBeUndefined()\n })\n\n it('applies the same reading to a bare array', () => {\n const response = {\n errors: [{ message: 'withheld', path: ['domainOwners', 0, 'reason'] }],\n data: { domainOwners: [{ id: 'a', reason: null }] }\n }\n\n expect(arrayFromResponse(response, 'domainOwners')).toEqual({ items: [{ id: 'a', reason: null }], total: 1 })\n })\n})\n\ndescribe('a query that answers with a bare array', () => {\n it('reads the rows and counts them', () => {\n const response = { data: { domainOwners: [{ id: 'a' }, { id: 'b' }] } }\n\n expect(arrayFromResponse(response, 'domainOwners')).toEqual({ items: [{ id: 'a' }, { id: 'b' }], total: 2 })\n })\n\n it('keeps an answer of none as an answer', () => {\n expect(arrayFromResponse({ data: { domainOwners: [] } }, 'domainOwners')).toEqual({ items: [], total: 0 })\n })\n\n it('refuses a response carrying errors even when rows came back', () => {\n /*\n * ⚠ The load-bearing case, same as for pages. The screen this replaces wrote\n * `response.data?.domainOwners || []`, so a refused query read as \"this domain has no\n * owners\" — a sentence an administrator may act on by adding one.\n */\n const response = { errors: [{ message: 'forbidden' }], data: { domainOwners: [{ id: 'a' }] } }\n\n expect(arrayFromResponse(response, 'domainOwners')).toBeUndefined()\n })\n\n it('refuses a missing or non-array field', () => {\n expect(arrayFromResponse({ data: {} }, 'domainOwners')).toBeUndefined()\n expect(arrayFromResponse({ data: { domainOwners: null } }, 'domainOwners')).toBeUndefined()\n expect(arrayFromResponse({ data: { domainOwners: { items: [] } } }, 'domainOwners')).toBeUndefined()\n })\n})\n\ndescribe('what the screen may then say', () => {\n it('shows the rows when there are rows', () => {\n expect(listOutcome({ items: [{ id: 'a' }, { id: 'b' }], total: 2 })).toEqual({\n items: [{ id: 'a' }, { id: 'b' }],\n total: 2,\n failed: false\n })\n })\n\n it('an answer of none is not a failure', () => {\n /* The distinction this whole file exists for, from the other side. */\n expect(listOutcome({ items: [], total: 0 })).toEqual({ items: [], total: 0, failed: false })\n })\n\n it('no answer is a failure, and the rows are empty without claiming emptiness', () => {\n expect(listOutcome(undefined)).toEqual({ items: [], total: 0, failed: true })\n expect(listOutcome(null)).toEqual({ items: [], total: 0, failed: true })\n })\n\n it('counts the rows when the server sent no total', () => {\n expect(listOutcome({ items: [{ id: 'a' }] }).total).toBe(1)\n })\n\n it('keeps a total of zero rather than recounting it', () => {\n /* `?? ` and not `||` — a real zero must survive, or an empty list reports its length. */\n expect(listOutcome({ items: [], total: 0 }).total).toBe(0)\n })\n})\n"]}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The axis half of a privilege — the `privilege:` in `@privilege(category:, privilege:)`.
|
|
3
|
+
*
|
|
4
|
+
* ── This screen used to translate the axis, and the translation was a lie ─────
|
|
5
|
+
* Two copies of the same table lived here, one in `role-privilege-page.ts` and one in
|
|
6
|
+
* `role-template-picker.ts`:
|
|
7
|
+
*
|
|
8
|
+
* query → label.read mutation → label.write execute → label.execute
|
|
9
|
+
*
|
|
10
|
+
* It is gone. The axis does not name an operation; it names **which of a category's gates
|
|
11
|
+
* you have to hold**, and the value happens to be spelled with a transport word. Six
|
|
12
|
+
* declarations prove the difference, because in each of them the axis contradicts the actual
|
|
13
|
+
* GraphQL operation and is still correct:
|
|
14
|
+
*
|
|
15
|
+
* mutation on a @Query headless-twin ×2 · auth-base role-template-query ×2
|
|
16
|
+
* query on a @Mutation label-studio's two exports
|
|
17
|
+
*
|
|
18
|
+
* `roleTemplates` reads templates and asks for `mutation`, because choosing a template is
|
|
19
|
+
* creating a role. The screen drew "쓰기" over it. label-studio's exports change nothing and
|
|
20
|
+
* ask for `query`; the screen drew "읽기". The translation was wrong in exactly the places
|
|
21
|
+
* where the distinction mattered, and right everywhere it did not.
|
|
22
|
+
*
|
|
23
|
+
* The user asked how the screen could know (2026-09-11): 「바꿀 수 있고, 볼 수만 있는 것을
|
|
24
|
+
* 너가 어떻게 알아?」 It could not. It still cannot, so it no longer says.
|
|
25
|
+
*
|
|
26
|
+
* ── What the screen shows instead ────────────────────────────────────────────
|
|
27
|
+
* The word that is actually declared. `query` reads as `query`. A person who has to reason
|
|
28
|
+
* about which gate a role holds is better served by the value the gate was declared with
|
|
29
|
+
* than by a friendlier word that is sometimes false.
|
|
30
|
+
*
|
|
31
|
+
* ── This is temporary, and the end state is decided ──────────────────────────
|
|
32
|
+
* The architect settled the direction on 2026-09-15: rename the axes to `read`/`write`, so
|
|
33
|
+
* the value says what it means and no translation is needed. That moves 822 declarations
|
|
34
|
+
* across 215 files, so it waits until role templates have settled. When it lands, this file
|
|
35
|
+
* is where the change is — one table, not two.
|
|
36
|
+
*/
|
|
37
|
+
/**
|
|
38
|
+
* Known axes, in the order a screen should lay them out.
|
|
39
|
+
*
|
|
40
|
+
* Ordering is not a claim about meaning: it only keeps columns from jumping around between
|
|
41
|
+
* modules. An axis that is not listed here still renders — it sorts to the end under its own
|
|
42
|
+
* name rather than being dropped, because `@privilege(privilege:)` is an unconstrained string
|
|
43
|
+
* and a value first seen here is a value, not an error.
|
|
44
|
+
*/
|
|
45
|
+
export declare const KNOWN_AXES: string[];
|
|
46
|
+
/**
|
|
47
|
+
* What to print for an axis.
|
|
48
|
+
*
|
|
49
|
+
* The axis itself. Kept as a function rather than inlined at the two call sites so the
|
|
50
|
+
* rename has one place to land.
|
|
51
|
+
*/
|
|
52
|
+
export declare function axisLabel(axis: string): string;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The axis half of a privilege — the `privilege:` in `@privilege(category:, privilege:)`.
|
|
3
|
+
*
|
|
4
|
+
* ── This screen used to translate the axis, and the translation was a lie ─────
|
|
5
|
+
* Two copies of the same table lived here, one in `role-privilege-page.ts` and one in
|
|
6
|
+
* `role-template-picker.ts`:
|
|
7
|
+
*
|
|
8
|
+
* query → label.read mutation → label.write execute → label.execute
|
|
9
|
+
*
|
|
10
|
+
* It is gone. The axis does not name an operation; it names **which of a category's gates
|
|
11
|
+
* you have to hold**, and the value happens to be spelled with a transport word. Six
|
|
12
|
+
* declarations prove the difference, because in each of them the axis contradicts the actual
|
|
13
|
+
* GraphQL operation and is still correct:
|
|
14
|
+
*
|
|
15
|
+
* mutation on a @Query headless-twin ×2 · auth-base role-template-query ×2
|
|
16
|
+
* query on a @Mutation label-studio's two exports
|
|
17
|
+
*
|
|
18
|
+
* `roleTemplates` reads templates and asks for `mutation`, because choosing a template is
|
|
19
|
+
* creating a role. The screen drew "쓰기" over it. label-studio's exports change nothing and
|
|
20
|
+
* ask for `query`; the screen drew "읽기". The translation was wrong in exactly the places
|
|
21
|
+
* where the distinction mattered, and right everywhere it did not.
|
|
22
|
+
*
|
|
23
|
+
* The user asked how the screen could know (2026-09-11): 「바꿀 수 있고, 볼 수만 있는 것을
|
|
24
|
+
* 너가 어떻게 알아?」 It could not. It still cannot, so it no longer says.
|
|
25
|
+
*
|
|
26
|
+
* ── What the screen shows instead ────────────────────────────────────────────
|
|
27
|
+
* The word that is actually declared. `query` reads as `query`. A person who has to reason
|
|
28
|
+
* about which gate a role holds is better served by the value the gate was declared with
|
|
29
|
+
* than by a friendlier word that is sometimes false.
|
|
30
|
+
*
|
|
31
|
+
* ── This is temporary, and the end state is decided ──────────────────────────
|
|
32
|
+
* The architect settled the direction on 2026-09-15: rename the axes to `read`/`write`, so
|
|
33
|
+
* the value says what it means and no translation is needed. That moves 822 declarations
|
|
34
|
+
* across 215 files, so it waits until role templates have settled. When it lands, this file
|
|
35
|
+
* is where the change is — one table, not two.
|
|
36
|
+
*/
|
|
37
|
+
/**
|
|
38
|
+
* Known axes, in the order a screen should lay them out.
|
|
39
|
+
*
|
|
40
|
+
* Ordering is not a claim about meaning: it only keeps columns from jumping around between
|
|
41
|
+
* modules. An axis that is not listed here still renders — it sorts to the end under its own
|
|
42
|
+
* name rather than being dropped, because `@privilege(privilege:)` is an unconstrained string
|
|
43
|
+
* and a value first seen here is a value, not an error.
|
|
44
|
+
*/
|
|
45
|
+
export const KNOWN_AXES = ['query', 'mutation', 'execute'];
|
|
46
|
+
/**
|
|
47
|
+
* What to print for an axis.
|
|
48
|
+
*
|
|
49
|
+
* The axis itself. Kept as a function rather than inlined at the two call sites so the
|
|
50
|
+
* rename has one place to land.
|
|
51
|
+
*/
|
|
52
|
+
export function axisLabel(axis) {
|
|
53
|
+
return axis;
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=privilege-axis.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"privilege-axis.js","sourceRoot":"","sources":["../../client/utils/privilege-axis.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AAEH;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,UAAU,GAAG,CAAC,OAAO,EAAE,UAAU,EAAE,SAAS,CAAC,CAAA;AAE1D;;;;;GAKG;AACH,MAAM,UAAU,SAAS,CAAC,IAAY;IACpC,OAAO,IAAI,CAAA;AACb,CAAC","sourcesContent":["/**\n * The axis half of a privilege — the `privilege:` in `@privilege(category:, privilege:)`.\n *\n * ── This screen used to translate the axis, and the translation was a lie ─────\n * Two copies of the same table lived here, one in `role-privilege-page.ts` and one in\n * `role-template-picker.ts`:\n *\n * query → label.read mutation → label.write execute → label.execute\n *\n * It is gone. The axis does not name an operation; it names **which of a category's gates\n * you have to hold**, and the value happens to be spelled with a transport word. Six\n * declarations prove the difference, because in each of them the axis contradicts the actual\n * GraphQL operation and is still correct:\n *\n * mutation on a @Query headless-twin ×2 · auth-base role-template-query ×2\n * query on a @Mutation label-studio's two exports\n *\n * `roleTemplates` reads templates and asks for `mutation`, because choosing a template is\n * creating a role. The screen drew \"쓰기\" over it. label-studio's exports change nothing and\n * ask for `query`; the screen drew \"읽기\". The translation was wrong in exactly the places\n * where the distinction mattered, and right everywhere it did not.\n *\n * The user asked how the screen could know (2026-09-11): 「바꿀 수 있고, 볼 수만 있는 것을\n * 너가 어떻게 알아?」 It could not. It still cannot, so it no longer says.\n *\n * ── What the screen shows instead ────────────────────────────────────────────\n * The word that is actually declared. `query` reads as `query`. A person who has to reason\n * about which gate a role holds is better served by the value the gate was declared with\n * than by a friendlier word that is sometimes false.\n *\n * ── This is temporary, and the end state is decided ──────────────────────────\n * The architect settled the direction on 2026-09-15: rename the axes to `read`/`write`, so\n * the value says what it means and no translation is needed. That moves 822 declarations\n * across 215 files, so it waits until role templates have settled. When it lands, this file\n * is where the change is — one table, not two.\n */\n\n/**\n * Known axes, in the order a screen should lay them out.\n *\n * Ordering is not a claim about meaning: it only keeps columns from jumping around between\n * modules. An axis that is not listed here still renders — it sorts to the end under its own\n * name rather than being dropped, because `@privilege(privilege:)` is an unconstrained string\n * and a value first seen here is a value, not an error.\n */\nexport const KNOWN_AXES = ['query', 'mutation', 'execute']\n\n/**\n * What to print for an axis.\n *\n * The axis itself. Kept as a function rather than inlined at the two call sites so the\n * rename has one place to land.\n */\nexport function axisLabel(axis: string): string {\n return axis\n}\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { KNOWN_AXES, axisLabel } from './privilege-axis';
|
|
2
|
+
/**
|
|
3
|
+
* The axis is shown as declared, not translated.
|
|
4
|
+
*
|
|
5
|
+
* This is a guard against putting the translation back, which is easy to do because the
|
|
6
|
+
* friendlier words read better. They are also false in the six declarations where the axis
|
|
7
|
+
* and the GraphQL operation disagree on purpose — `roleTemplates` reads and asks for
|
|
8
|
+
* `mutation`, label-studio's exports change nothing and ask for `query`. A screen that prints
|
|
9
|
+
* 「읽기」/「쓰기」 is wrong exactly where a person needed it to be right.
|
|
10
|
+
*
|
|
11
|
+
* `privilege-axis.ts` carries the reasoning and the decided end state (rename the values to
|
|
12
|
+
* read/write, after role templates settle).
|
|
13
|
+
*/
|
|
14
|
+
describe('privilege axis', () => {
|
|
15
|
+
it('shows the axis that was declared', () => {
|
|
16
|
+
expect(axisLabel('query')).toBe('query');
|
|
17
|
+
expect(axisLabel('mutation')).toBe('mutation');
|
|
18
|
+
});
|
|
19
|
+
it('does not turn query and mutation into read and write words', () => {
|
|
20
|
+
/* The two that used to be translated, named here so the regression is obvious. */
|
|
21
|
+
expect(axisLabel('query')).not.toMatch(/읽기|read/);
|
|
22
|
+
expect(axisLabel('mutation')).not.toMatch(/쓰기|write/);
|
|
23
|
+
});
|
|
24
|
+
it('shows an axis it has never seen rather than dropping it', () => {
|
|
25
|
+
/*
|
|
26
|
+
* `@privilege(privilege:)` is an unconstrained string. dssp declares eight axes that are
|
|
27
|
+
* not query or mutation, `kpi:read` among them. A screen that only knows two must still
|
|
28
|
+
* render the third, or a role's privileges go missing on the page that exists to show
|
|
29
|
+
* them.
|
|
30
|
+
*/
|
|
31
|
+
expect(axisLabel('finalize')).toBe('finalize');
|
|
32
|
+
expect(axisLabel('')).toBe('');
|
|
33
|
+
});
|
|
34
|
+
it('keeps a stable order for the axes it knows', () => {
|
|
35
|
+
/* Ordering is layout, not meaning: it stops columns jumping between modules. */
|
|
36
|
+
expect(KNOWN_AXES).toEqual(['query', 'mutation', 'execute']);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
//# sourceMappingURL=privilege-axis.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"privilege-axis.test.js","sourceRoot":"","sources":["../../client/utils/privilege-axis.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAA;AAExD;;;;;;;;;;;GAWG;AACH,QAAQ,CAAC,gBAAgB,EAAE,GAAG,EAAE;IAC9B,EAAE,CAAC,kCAAkC,EAAE,GAAG,EAAE;QAC1C,MAAM,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAA;QACxC,MAAM,CAAC,SAAS,CAAC,UAAU,CAAC,CAAC,CAAC,IAAI,CAAC,UAAU,CAAC,CAAA;IAChD,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,4DAA4D,EAAE,GAAG,EAAE;QACpE,kFAAkF;QAClF,MAAM,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,SAAS,CAAC,CAAA;QACjD,MAAM,CAAC,SAAS,CAAC,UAAU,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,UAAU,CAAC,CAAA;IACvD,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,yDAAyD,EAAE,GAAG,EAAE;QACjE;;;;;WAKG;QACH,MAAM,CAAC,SAAS,CAAC,UAAU,CAAC,CAAC,CAAC,IAAI,CAAC,UAAU,CAAC,CAAA;QAC9C,MAAM,CAAC,SAAS,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;IAChC,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,4CAA4C,EAAE,GAAG,EAAE;QACpD,gFAAgF;QAChF,MAAM,CAAC,UAAU,CAAC,CAAC,OAAO,CAAC,CAAC,OAAO,EAAE,UAAU,EAAE,SAAS,CAAC,CAAC,CAAA;IAC9D,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA","sourcesContent":["import { KNOWN_AXES, axisLabel } from './privilege-axis'\n\n/**\n * The axis is shown as declared, not translated.\n *\n * This is a guard against putting the translation back, which is easy to do because the\n * friendlier words read better. They are also false in the six declarations where the axis\n * and the GraphQL operation disagree on purpose — `roleTemplates` reads and asks for\n * `mutation`, label-studio's exports change nothing and ask for `query`. A screen that prints\n * 「읽기」/「쓰기」 is wrong exactly where a person needed it to be right.\n *\n * `privilege-axis.ts` carries the reasoning and the decided end state (rename the values to\n * read/write, after role templates settle).\n */\ndescribe('privilege axis', () => {\n it('shows the axis that was declared', () => {\n expect(axisLabel('query')).toBe('query')\n expect(axisLabel('mutation')).toBe('mutation')\n })\n\n it('does not turn query and mutation into read and write words', () => {\n /* The two that used to be translated, named here so the regression is obvious. */\n expect(axisLabel('query')).not.toMatch(/읽기|read/)\n expect(axisLabel('mutation')).not.toMatch(/쓰기|write/)\n })\n\n it('shows an axis it has never seen rather than dropping it', () => {\n /*\n * `@privilege(privilege:)` is an unconstrained string. dssp declares eight axes that are\n * not query or mutation, `kpi:read` among them. A screen that only knows two must still\n * render the third, or a role's privileges go missing on the page that exists to show\n * them.\n */\n expect(axisLabel('finalize')).toBe('finalize')\n expect(axisLabel('')).toBe('')\n })\n\n it('keeps a stable order for the axes it knows', () => {\n /* Ordering is layout, not meaning: it stops columns jumping between modules. */\n expect(KNOWN_AXES).toEqual(['query', 'mutation', 'execute'])\n })\n})\n"]}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Showing a credential on screen.
|
|
3
|
+
*
|
|
4
|
+
* ── The screens this is for ──────────────────────────────────────────────────
|
|
5
|
+
* An application's refresh token, an appliance's access token, a provider's client secret.
|
|
6
|
+
* They have one thing in common that ordinary fields do not: the reader is meant to **copy**
|
|
7
|
+
* one, once, and never read it again. Every other moment the screen is open, the value on it
|
|
8
|
+
* is a liability — on a shared display, in a screenshot, over a shoulder.
|
|
9
|
+
*
|
|
10
|
+
* Today they are `<input readonly>` with the value in plain text, shown the moment the page
|
|
11
|
+
* loads.
|
|
12
|
+
*
|
|
13
|
+
* ── The rule that has teeth: the mask must not leak the length ───────────────
|
|
14
|
+
* The obvious mask is one dot per character, and it is wrong. A token's length says which
|
|
15
|
+
* kind of token it is, and for a short secret it narrows the guess badly — a six-character
|
|
16
|
+
* mask beside a twenty-character one tells you which is worth attacking. A mask is supposed
|
|
17
|
+
* to say "there is a value here", nothing else, so it is a fixed width.
|
|
18
|
+
*
|
|
19
|
+
* The one thing a mask must distinguish is **present** from **absent**. A field with no
|
|
20
|
+
* secret shows nothing at all: dots over an empty value would have the reader hunting for a
|
|
21
|
+
* token that was never issued.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* How wide a mask is, whatever it hides.
|
|
25
|
+
*
|
|
26
|
+
* Sixteen, to match `OxGristRendererSecret` in @operato/data-grist. The platform reached the
|
|
27
|
+
* same two rules on its own — a fixed width, and nothing at all for an absent value — and two
|
|
28
|
+
* different mask widths on adjacent screens would read as two different kinds of secret.
|
|
29
|
+
*/
|
|
30
|
+
export declare const MASK = "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022";
|
|
31
|
+
/**
|
|
32
|
+
* What to show when the value is hidden.
|
|
33
|
+
*
|
|
34
|
+
* Empty for an absent secret — see above; the distinction between "hidden" and "not issued"
|
|
35
|
+
* is the one thing the mask is allowed to carry.
|
|
36
|
+
*/
|
|
37
|
+
export declare function maskedSecret(value: string | null | undefined): string;
|
|
38
|
+
/**
|
|
39
|
+
* What to show, given whether the reader has asked to see it.
|
|
40
|
+
*
|
|
41
|
+
* Revealing is an action the reader takes, so it is never the initial state — see
|
|
42
|
+
* `secret-display.test.ts` for the case that pins it.
|
|
43
|
+
*/
|
|
44
|
+
export declare function shownSecret(value: string | null | undefined, revealed: boolean): string;
|
|
45
|
+
/**
|
|
46
|
+
* Is there anything to copy or reveal?
|
|
47
|
+
*
|
|
48
|
+
* Buttons that do nothing are worse than absent ones: the reader presses, nothing happens,
|
|
49
|
+
* and they cannot tell whether the screen is broken or the token is missing.
|
|
50
|
+
*/
|
|
51
|
+
export declare function hasSecret(value: string | null | undefined): boolean;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Showing a credential on screen.
|
|
3
|
+
*
|
|
4
|
+
* ── The screens this is for ──────────────────────────────────────────────────
|
|
5
|
+
* An application's refresh token, an appliance's access token, a provider's client secret.
|
|
6
|
+
* They have one thing in common that ordinary fields do not: the reader is meant to **copy**
|
|
7
|
+
* one, once, and never read it again. Every other moment the screen is open, the value on it
|
|
8
|
+
* is a liability — on a shared display, in a screenshot, over a shoulder.
|
|
9
|
+
*
|
|
10
|
+
* Today they are `<input readonly>` with the value in plain text, shown the moment the page
|
|
11
|
+
* loads.
|
|
12
|
+
*
|
|
13
|
+
* ── The rule that has teeth: the mask must not leak the length ───────────────
|
|
14
|
+
* The obvious mask is one dot per character, and it is wrong. A token's length says which
|
|
15
|
+
* kind of token it is, and for a short secret it narrows the guess badly — a six-character
|
|
16
|
+
* mask beside a twenty-character one tells you which is worth attacking. A mask is supposed
|
|
17
|
+
* to say "there is a value here", nothing else, so it is a fixed width.
|
|
18
|
+
*
|
|
19
|
+
* The one thing a mask must distinguish is **present** from **absent**. A field with no
|
|
20
|
+
* secret shows nothing at all: dots over an empty value would have the reader hunting for a
|
|
21
|
+
* token that was never issued.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* How wide a mask is, whatever it hides.
|
|
25
|
+
*
|
|
26
|
+
* Sixteen, to match `OxGristRendererSecret` in @operato/data-grist. The platform reached the
|
|
27
|
+
* same two rules on its own — a fixed width, and nothing at all for an absent value — and two
|
|
28
|
+
* different mask widths on adjacent screens would read as two different kinds of secret.
|
|
29
|
+
*/
|
|
30
|
+
export const MASK = '••••••••••••••••';
|
|
31
|
+
/**
|
|
32
|
+
* What to show when the value is hidden.
|
|
33
|
+
*
|
|
34
|
+
* Empty for an absent secret — see above; the distinction between "hidden" and "not issued"
|
|
35
|
+
* is the one thing the mask is allowed to carry.
|
|
36
|
+
*/
|
|
37
|
+
export function maskedSecret(value) {
|
|
38
|
+
return value ? MASK : '';
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* What to show, given whether the reader has asked to see it.
|
|
42
|
+
*
|
|
43
|
+
* Revealing is an action the reader takes, so it is never the initial state — see
|
|
44
|
+
* `secret-display.test.ts` for the case that pins it.
|
|
45
|
+
*/
|
|
46
|
+
export function shownSecret(value, revealed) {
|
|
47
|
+
if (!value) {
|
|
48
|
+
return '';
|
|
49
|
+
}
|
|
50
|
+
return revealed ? value : maskedSecret(value);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Is there anything to copy or reveal?
|
|
54
|
+
*
|
|
55
|
+
* Buttons that do nothing are worse than absent ones: the reader presses, nothing happens,
|
|
56
|
+
* and they cannot tell whether the screen is broken or the token is missing.
|
|
57
|
+
*/
|
|
58
|
+
export function hasSecret(value) {
|
|
59
|
+
return !!value;
|
|
60
|
+
}
|
|
61
|
+
//# sourceMappingURL=secret-display.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"secret-display.js","sourceRoot":"","sources":["../../client/utils/secret-display.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,IAAI,GAAG,kBAAkB,CAAA;AAEtC;;;;;GAKG;AACH,MAAM,UAAU,YAAY,CAAC,KAAgC;IAC3D,OAAO,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAA;AAC1B,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,WAAW,CAAC,KAAgC,EAAE,QAAiB;IAC7E,IAAI,CAAC,KAAK,EAAE,CAAC;QACX,OAAO,EAAE,CAAA;IACX,CAAC;IAED,OAAO,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,YAAY,CAAC,KAAK,CAAC,CAAA;AAC/C,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,SAAS,CAAC,KAAgC;IACxD,OAAO,CAAC,CAAC,KAAK,CAAA;AAChB,CAAC","sourcesContent":["/**\n * Showing a credential on screen.\n *\n * ── The screens this is for ──────────────────────────────────────────────────\n * An application's refresh token, an appliance's access token, a provider's client secret.\n * They have one thing in common that ordinary fields do not: the reader is meant to **copy**\n * one, once, and never read it again. Every other moment the screen is open, the value on it\n * is a liability — on a shared display, in a screenshot, over a shoulder.\n *\n * Today they are `<input readonly>` with the value in plain text, shown the moment the page\n * loads.\n *\n * ── The rule that has teeth: the mask must not leak the length ───────────────\n * The obvious mask is one dot per character, and it is wrong. A token's length says which\n * kind of token it is, and for a short secret it narrows the guess badly — a six-character\n * mask beside a twenty-character one tells you which is worth attacking. A mask is supposed\n * to say \"there is a value here\", nothing else, so it is a fixed width.\n *\n * The one thing a mask must distinguish is **present** from **absent**. A field with no\n * secret shows nothing at all: dots over an empty value would have the reader hunting for a\n * token that was never issued.\n */\n\n/**\n * How wide a mask is, whatever it hides.\n *\n * Sixteen, to match `OxGristRendererSecret` in @operato/data-grist. The platform reached the\n * same two rules on its own — a fixed width, and nothing at all for an absent value — and two\n * different mask widths on adjacent screens would read as two different kinds of secret.\n */\nexport const MASK = '••••••••••••••••'\n\n/**\n * What to show when the value is hidden.\n *\n * Empty for an absent secret — see above; the distinction between \"hidden\" and \"not issued\"\n * is the one thing the mask is allowed to carry.\n */\nexport function maskedSecret(value: string | null | undefined): string {\n return value ? MASK : ''\n}\n\n/**\n * What to show, given whether the reader has asked to see it.\n *\n * Revealing is an action the reader takes, so it is never the initial state — see\n * `secret-display.test.ts` for the case that pins it.\n */\nexport function shownSecret(value: string | null | undefined, revealed: boolean): string {\n if (!value) {\n return ''\n }\n\n return revealed ? value : maskedSecret(value)\n}\n\n/**\n * Is there anything to copy or reveal?\n *\n * Buttons that do nothing are worse than absent ones: the reader presses, nothing happens,\n * and they cannot tell whether the screen is broken or the token is missing.\n */\nexport function hasSecret(value: string | null | undefined): boolean {\n return !!value\n}\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { hasSecret, MASK, maskedSecret, shownSecret } from './secret-display';
|
|
2
|
+
/**
|
|
3
|
+
* The app binding and appliance detail screens print a refresh token and an access token
|
|
4
|
+
* into a readonly input, in plain text, from the moment the page opens. These are the rules
|
|
5
|
+
* that replace that.
|
|
6
|
+
*/
|
|
7
|
+
describe('the mask', () => {
|
|
8
|
+
it('is the same width whatever it hides', () => {
|
|
9
|
+
/*
|
|
10
|
+
* ⚠ The rule worth testing, because one-dot-per-character is the obvious mask and it
|
|
11
|
+
* leaks. A token's length says which kind of token it is; two masked fields of
|
|
12
|
+
* different widths tell a reader which is the short one.
|
|
13
|
+
*/
|
|
14
|
+
const short = maskedSecret('abc');
|
|
15
|
+
const long = maskedSecret('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.abcdefg');
|
|
16
|
+
expect(short).toBe(long);
|
|
17
|
+
expect(short.length).not.toBe(3);
|
|
18
|
+
});
|
|
19
|
+
it('says a secret is there', () => {
|
|
20
|
+
expect(maskedSecret('anything')).toBe(MASK);
|
|
21
|
+
});
|
|
22
|
+
it('says nothing when there is no secret', () => {
|
|
23
|
+
/*
|
|
24
|
+
* Dots over an empty value send the reader hunting for a token nobody issued. The mask
|
|
25
|
+
* is allowed to carry exactly one bit — present or absent — and this is it.
|
|
26
|
+
*/
|
|
27
|
+
expect(maskedSecret('')).toBe('');
|
|
28
|
+
expect(maskedSecret(null)).toBe('');
|
|
29
|
+
expect(maskedSecret(undefined)).toBe('');
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
describe('what the field shows', () => {
|
|
33
|
+
it('is masked until the reader asks', () => {
|
|
34
|
+
/* Revealing is an action. It is never where the screen starts. */
|
|
35
|
+
expect(shownSecret('a-real-token', false)).toBe(MASK);
|
|
36
|
+
});
|
|
37
|
+
it('is the value once they have asked', () => {
|
|
38
|
+
expect(shownSecret('a-real-token', true)).toBe('a-real-token');
|
|
39
|
+
});
|
|
40
|
+
it('stays empty when there is nothing, asked or not', () => {
|
|
41
|
+
expect(shownSecret('', true)).toBe('');
|
|
42
|
+
expect(shownSecret(undefined, true)).toBe('');
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
describe('whether the buttons do anything', () => {
|
|
46
|
+
it('knows when there is something to copy', () => {
|
|
47
|
+
expect(hasSecret('token')).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
it('knows when there is not', () => {
|
|
50
|
+
/*
|
|
51
|
+
* A copy button over an absent token leaves the reader unable to tell a broken screen
|
|
52
|
+
* from an unissued credential.
|
|
53
|
+
*/
|
|
54
|
+
expect(hasSecret('')).toBe(false);
|
|
55
|
+
expect(hasSecret(null)).toBe(false);
|
|
56
|
+
expect(hasSecret(undefined)).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
//# sourceMappingURL=secret-display.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"secret-display.test.js","sourceRoot":"","sources":["../../client/utils/secret-display.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,IAAI,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAA;AAE7E;;;;GAIG;AAEH,QAAQ,CAAC,UAAU,EAAE,GAAG,EAAE;IACxB,EAAE,CAAC,qCAAqC,EAAE,GAAG,EAAE;QAC7C;;;;WAIG;QACH,MAAM,KAAK,GAAG,YAAY,CAAC,KAAK,CAAC,CAAA;QACjC,MAAM,IAAI,GAAG,YAAY,CAAC,0EAA0E,CAAC,CAAA;QAErG,MAAM,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;QACxB,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;IAClC,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,wBAAwB,EAAE,GAAG,EAAE;QAChC,MAAM,CAAC,YAAY,CAAC,UAAU,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IAC7C,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,sCAAsC,EAAE,GAAG,EAAE;QAC9C;;;WAGG;QACH,MAAM,CAAC,YAAY,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;QACjC,MAAM,CAAC,YAAY,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;QACnC,MAAM,CAAC,YAAY,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;IAC1C,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA;AAEF,QAAQ,CAAC,sBAAsB,EAAE,GAAG,EAAE;IACpC,EAAE,CAAC,iCAAiC,EAAE,GAAG,EAAE;QACzC,kEAAkE;QAClE,MAAM,CAAC,WAAW,CAAC,cAAc,EAAE,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IACvD,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,mCAAmC,EAAE,GAAG,EAAE;QAC3C,MAAM,CAAC,WAAW,CAAC,cAAc,EAAE,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,cAAc,CAAC,CAAA;IAChE,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,iDAAiD,EAAE,GAAG,EAAE;QACzD,MAAM,CAAC,WAAW,CAAC,EAAE,EAAE,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;QACtC,MAAM,CAAC,WAAW,CAAC,SAAS,EAAE,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;IAC/C,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA;AAEF,QAAQ,CAAC,iCAAiC,EAAE,GAAG,EAAE;IAC/C,EAAE,CAAC,uCAAuC,EAAE,GAAG,EAAE;QAC/C,MAAM,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IACvC,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,yBAAyB,EAAE,GAAG,EAAE;QACjC;;;WAGG;QACH,MAAM,CAAC,SAAS,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;QACjC,MAAM,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;QACnC,MAAM,CAAC,SAAS,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;IAC1C,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA","sourcesContent":["import { hasSecret, MASK, maskedSecret, shownSecret } from './secret-display'\n\n/**\n * The app binding and appliance detail screens print a refresh token and an access token\n * into a readonly input, in plain text, from the moment the page opens. These are the rules\n * that replace that.\n */\n\ndescribe('the mask', () => {\n it('is the same width whatever it hides', () => {\n /*\n * ⚠ The rule worth testing, because one-dot-per-character is the obvious mask and it\n * leaks. A token's length says which kind of token it is; two masked fields of\n * different widths tell a reader which is the short one.\n */\n const short = maskedSecret('abc')\n const long = maskedSecret('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.abcdefg')\n\n expect(short).toBe(long)\n expect(short.length).not.toBe(3)\n })\n\n it('says a secret is there', () => {\n expect(maskedSecret('anything')).toBe(MASK)\n })\n\n it('says nothing when there is no secret', () => {\n /*\n * Dots over an empty value send the reader hunting for a token nobody issued. The mask\n * is allowed to carry exactly one bit — present or absent — and this is it.\n */\n expect(maskedSecret('')).toBe('')\n expect(maskedSecret(null)).toBe('')\n expect(maskedSecret(undefined)).toBe('')\n })\n})\n\ndescribe('what the field shows', () => {\n it('is masked until the reader asks', () => {\n /* Revealing is an action. It is never where the screen starts. */\n expect(shownSecret('a-real-token', false)).toBe(MASK)\n })\n\n it('is the value once they have asked', () => {\n expect(shownSecret('a-real-token', true)).toBe('a-real-token')\n })\n\n it('stays empty when there is nothing, asked or not', () => {\n expect(shownSecret('', true)).toBe('')\n expect(shownSecret(undefined, true)).toBe('')\n })\n})\n\ndescribe('whether the buttons do anything', () => {\n it('knows when there is something to copy', () => {\n expect(hasSecret('token')).toBe(true)\n })\n\n it('knows when there is not', () => {\n /*\n * A copy button over an absent token leaves the reader unable to tell a broken screen\n * from an unissued credential.\n */\n expect(hasSecret('')).toBe(false)\n expect(hasSecret(null)).toBe(false)\n expect(hasSecret(undefined)).toBe(false)\n })\n})\n"]}
|