@ghub/identity-chrome 0.0.0-stage → 0.2.0
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/LICENSE +115 -0
- package/README.md +157 -2
- package/dist/catalogue.d.ts +39 -0
- package/dist/catalogue.js +79 -0
- package/dist/core.d.ts +69 -0
- package/dist/core.js +248 -0
- package/dist/elements/account.d.ts +96 -0
- package/dist/elements/account.js +1008 -0
- package/dist/elements/apps.d.ts +67 -0
- package/dist/elements/apps.js +689 -0
- package/dist/elements/contract.d.ts +29 -0
- package/dist/elements/contract.js +109 -0
- package/dist/elements/dom.d.ts +13 -0
- package/dist/elements/dom.js +45 -0
- package/dist/elements/index.d.ts +48 -0
- package/dist/elements/index.js +26 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +35 -0
- package/dist/react.d.ts +41 -0
- package/dist/react.js +73 -0
- package/dist/server.d.ts +65 -0
- package/dist/server.js +148 -0
- package/dist/tint.d.ts +22 -0
- package/dist/tint.js +41 -0
- package/dist/types.d.ts +83 -0
- package/dist/types.js +9 -0
- package/dist/validate.d.ts +22 -0
- package/dist/validate.js +148 -0
- package/package.json +61 -3
package/dist/core.js
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The browser half: what the header controls know, and what they keep.
|
|
4
|
+
*
|
|
5
|
+
* It owns no markup. The apps picker and the account popup draw from `state()`
|
|
6
|
+
* and ask it to `load()`, which is what keeps one fetch, one cache and one
|
|
7
|
+
* failure story behind both controls.
|
|
8
|
+
*
|
|
9
|
+
* **Off unless enabled, and off means silent.** Nothing is fetched, nothing is
|
|
10
|
+
* read from storage and nothing is written, so a self-hosted installation that
|
|
11
|
+
* never sets `enabled` makes no call to anybody.
|
|
12
|
+
*
|
|
13
|
+
* **A cached list paints at once.** The last answer is drawn while a fresh one is
|
|
14
|
+
* fetched, and kept if the fetch fails, marked `stale`. It is thirty days old at
|
|
15
|
+
* most and is checked as hard as a fresh answer, because storage is something
|
|
16
|
+
* anybody on the device can write.
|
|
17
|
+
*
|
|
18
|
+
* **The product's own route is the personal half.** The product's server holds the
|
|
19
|
+
* secret token and says who is signed in; this only ever calls that route, same
|
|
20
|
+
* origin, with the page's own cookie. The public key, if one is given, fetches
|
|
21
|
+
* the lower group straight from Identity so it can paint before the product has
|
|
22
|
+
* answered, and nothing more.
|
|
23
|
+
*/
|
|
24
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
25
|
+
exports.DEFAULT_TIMEOUT_MS = exports.CACHE_MAX_AGE_MS = exports.CACHE_KEY = void 0;
|
|
26
|
+
exports.createChromeCore = createChromeCore;
|
|
27
|
+
const validate_js_1 = require("./validate.js");
|
|
28
|
+
exports.CACHE_KEY = "gpid.chrome.v1";
|
|
29
|
+
/** A list older than this is not drawn, however the fetch fares. */
|
|
30
|
+
exports.CACHE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
31
|
+
exports.DEFAULT_TIMEOUT_MS = 5000;
|
|
32
|
+
const NO_PERSON = { hasIdentity: false, avatarUrl: null, tint: null };
|
|
33
|
+
function isRecord(value) {
|
|
34
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
35
|
+
}
|
|
36
|
+
function createChromeCore(options = {}) {
|
|
37
|
+
const enabled = options.enabled === true;
|
|
38
|
+
const now = options.now ?? Date.now;
|
|
39
|
+
const timeoutMs = options.timeoutMs ?? exports.DEFAULT_TIMEOUT_MS;
|
|
40
|
+
const storage = options.storage ?? null;
|
|
41
|
+
const cacheKey = options.cacheKey ?? exports.CACHE_KEY;
|
|
42
|
+
let state = {
|
|
43
|
+
status: enabled ? "idle" : "off",
|
|
44
|
+
view: null,
|
|
45
|
+
updatedAt: null,
|
|
46
|
+
error: null,
|
|
47
|
+
};
|
|
48
|
+
const listeners = new Set();
|
|
49
|
+
let inFlight = null;
|
|
50
|
+
let controller = null;
|
|
51
|
+
let disposed = false;
|
|
52
|
+
function set(next) {
|
|
53
|
+
if (disposed)
|
|
54
|
+
return;
|
|
55
|
+
state = { ...state, ...next };
|
|
56
|
+
for (const listener of [...listeners]) {
|
|
57
|
+
try {
|
|
58
|
+
listener(state);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// One listener's fault is not another's.
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function readCache() {
|
|
66
|
+
if (storage === null)
|
|
67
|
+
return null;
|
|
68
|
+
try {
|
|
69
|
+
const raw = storage.getItem(cacheKey);
|
|
70
|
+
if (raw === null)
|
|
71
|
+
return null;
|
|
72
|
+
const parsed = JSON.parse(raw);
|
|
73
|
+
if (!isRecord(parsed) || typeof parsed["savedAt"] !== "number")
|
|
74
|
+
return null;
|
|
75
|
+
const savedAt = parsed["savedAt"];
|
|
76
|
+
// Too old, or dated in the future, which only somebody editing it would do.
|
|
77
|
+
if (now() - savedAt > exports.CACHE_MAX_AGE_MS || savedAt > now() + 60_000)
|
|
78
|
+
return null;
|
|
79
|
+
const view = (0, validate_js_1.parseView)(parsed["view"]);
|
|
80
|
+
return view === null ? null : { view, savedAt };
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function writeCache(view, savedAt) {
|
|
87
|
+
if (storage === null)
|
|
88
|
+
return;
|
|
89
|
+
try {
|
|
90
|
+
storage.setItem(cacheKey, JSON.stringify({ v: 1, savedAt, view }));
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// Full, blocked or private: the controls work without it.
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async function getJson(url, init, signal) {
|
|
97
|
+
const send = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
98
|
+
const response = await send(url, { ...init, signal });
|
|
99
|
+
if (!response.ok)
|
|
100
|
+
throw new Error(`status ${String(response.status)}`);
|
|
101
|
+
try {
|
|
102
|
+
return await response.json();
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
throw new Error("invalid answer");
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
async function fetchPersonal(endpoint, signal) {
|
|
109
|
+
const body = await getJson(endpoint, { method: "GET", headers: { accept: "application/json" }, credentials: "same-origin" }, signal);
|
|
110
|
+
const view = (0, validate_js_1.parseView)(body);
|
|
111
|
+
if (view === null)
|
|
112
|
+
throw new Error("invalid answer");
|
|
113
|
+
return view;
|
|
114
|
+
}
|
|
115
|
+
async function fetchPublic(baseUrl, key, signal) {
|
|
116
|
+
const url = `${baseUrl.replace(/\/+$/u, "")}/api/v1/chrome/public`;
|
|
117
|
+
const body = await getJson(url, {
|
|
118
|
+
method: "GET",
|
|
119
|
+
headers: { accept: "application/json", "x-gpid-key": key },
|
|
120
|
+
credentials: "omit",
|
|
121
|
+
}, signal);
|
|
122
|
+
const view = (0, validate_js_1.parsePublicView)(body);
|
|
123
|
+
if (view === null)
|
|
124
|
+
throw new Error("invalid answer");
|
|
125
|
+
return view;
|
|
126
|
+
}
|
|
127
|
+
function reasonOf(error, timedOut) {
|
|
128
|
+
if (timedOut)
|
|
129
|
+
return "timeout";
|
|
130
|
+
if (error instanceof Error && /^(status \d+|invalid answer)$/u.test(error.message))
|
|
131
|
+
return error.message;
|
|
132
|
+
return "network";
|
|
133
|
+
}
|
|
134
|
+
async function run() {
|
|
135
|
+
const cached = readCache();
|
|
136
|
+
set({
|
|
137
|
+
status: "loading",
|
|
138
|
+
view: cached?.view ?? state.view,
|
|
139
|
+
updatedAt: cached?.savedAt ?? state.updatedAt,
|
|
140
|
+
error: null,
|
|
141
|
+
});
|
|
142
|
+
const own = new AbortController();
|
|
143
|
+
controller = own;
|
|
144
|
+
let timedOut = false;
|
|
145
|
+
const timer = setTimeout(() => {
|
|
146
|
+
timedOut = true;
|
|
147
|
+
own.abort();
|
|
148
|
+
}, timeoutMs);
|
|
149
|
+
try {
|
|
150
|
+
const personalCall = options.endpoint === undefined
|
|
151
|
+
? Promise.resolve(null)
|
|
152
|
+
: fetchPersonal(options.endpoint, own.signal);
|
|
153
|
+
const publicCall = options.baseUrl === undefined || options.publicKey === undefined
|
|
154
|
+
? Promise.resolve(null)
|
|
155
|
+
: fetchPublic(options.baseUrl, options.publicKey, own.signal);
|
|
156
|
+
// The lower group paints as soon as it is back, when nothing else is on screen yet.
|
|
157
|
+
void publicCall
|
|
158
|
+
.then((pub) => {
|
|
159
|
+
if (pub !== null && state.status === "loading" && state.view === null) {
|
|
160
|
+
set({
|
|
161
|
+
view: {
|
|
162
|
+
person: NO_PERSON,
|
|
163
|
+
yours: [],
|
|
164
|
+
identity: null,
|
|
165
|
+
more: pub.more,
|
|
166
|
+
legal: pub.legal,
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
.catch(() => undefined);
|
|
172
|
+
const [personal, pub] = await Promise.allSettled([personalCall, publicCall]);
|
|
173
|
+
const publicHalf = pub.status === "fulfilled" ? pub.value : null;
|
|
174
|
+
if (personal.status === "fulfilled" && personal.value !== null) {
|
|
175
|
+
const at = now();
|
|
176
|
+
writeCache(personal.value, at);
|
|
177
|
+
set({ status: "ready", view: personal.value, updatedAt: at, error: null });
|
|
178
|
+
return state;
|
|
179
|
+
}
|
|
180
|
+
if (personal.status === "fulfilled" && publicHalf !== null) {
|
|
181
|
+
// No personal route configured: the public group is the whole answer.
|
|
182
|
+
set({
|
|
183
|
+
status: "ready",
|
|
184
|
+
view: { person: NO_PERSON, yours: [], identity: null, ...publicHalf },
|
|
185
|
+
updatedAt: now(),
|
|
186
|
+
error: null,
|
|
187
|
+
});
|
|
188
|
+
return state;
|
|
189
|
+
}
|
|
190
|
+
const error = personal.status === "rejected" ? reasonOf(personal.reason, timedOut) : "not configured";
|
|
191
|
+
if (cached !== null) {
|
|
192
|
+
set({ status: "stale", view: cached.view, updatedAt: cached.savedAt, error });
|
|
193
|
+
}
|
|
194
|
+
else if (publicHalf !== null) {
|
|
195
|
+
set({
|
|
196
|
+
status: "error",
|
|
197
|
+
view: { person: NO_PERSON, yours: [], identity: null, ...publicHalf },
|
|
198
|
+
updatedAt: null,
|
|
199
|
+
error,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
set({ status: "error", view: null, updatedAt: null, error });
|
|
204
|
+
}
|
|
205
|
+
return state;
|
|
206
|
+
}
|
|
207
|
+
finally {
|
|
208
|
+
clearTimeout(timer);
|
|
209
|
+
if (controller === own)
|
|
210
|
+
controller = null;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
state: () => state,
|
|
215
|
+
subscribe(listener) {
|
|
216
|
+
listeners.add(listener);
|
|
217
|
+
return () => {
|
|
218
|
+
listeners.delete(listener);
|
|
219
|
+
};
|
|
220
|
+
},
|
|
221
|
+
load() {
|
|
222
|
+
if (!enabled || disposed)
|
|
223
|
+
return Promise.resolve(state);
|
|
224
|
+
inFlight ??= run().finally(() => {
|
|
225
|
+
inFlight = null;
|
|
226
|
+
});
|
|
227
|
+
return inFlight;
|
|
228
|
+
},
|
|
229
|
+
clear() {
|
|
230
|
+
if (storage !== null) {
|
|
231
|
+
try {
|
|
232
|
+
storage.removeItem(cacheKey);
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
// Nothing to forget that could be read.
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (enabled)
|
|
239
|
+
set({ status: "idle", view: null, updatedAt: null, error: null });
|
|
240
|
+
},
|
|
241
|
+
dispose() {
|
|
242
|
+
disposed = true;
|
|
243
|
+
controller?.abort();
|
|
244
|
+
listeners.clear();
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
//# sourceMappingURL=core.js.map
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<gp-account>`, the account popup (GPlatform Identity, sheet 32, option A).
|
|
3
|
+
*
|
|
4
|
+
* An avatar at the right end of a product's top bar and, under it, a card on a
|
|
5
|
+
* tinted ground: who is signed in, where their Identity account is, which
|
|
6
|
+
* organisation they are acting in, and how to sign out of this product and
|
|
7
|
+
* only this product (D21).
|
|
8
|
+
*
|
|
9
|
+
* **Most of what it shows is the product's.** The name, the address, the
|
|
10
|
+
* organisations and both actions are properties the product sets from its own
|
|
11
|
+
* session. Identity adds the picture, the tint, its own two links and the
|
|
12
|
+
* footer, through the same core as `<gp-apps>`, so the popup still signs
|
|
13
|
+
* somebody out while Identity cannot be reached.
|
|
14
|
+
*
|
|
15
|
+
* **What it is to assistive technology:**
|
|
16
|
+
*
|
|
17
|
+
* - The trigger is a `button` with `aria-haspopup="dialog"`, named "Account:
|
|
18
|
+
* Mira Kessler", with the same words as a tooltip on hover and on focus after
|
|
19
|
+
* half a second, which Escape dismisses without closing anything else (1.4.13).
|
|
20
|
+
* - The popup is a `dialog` labelled by the person's name and described by their
|
|
21
|
+
* address, not modal. Below 600 wide it is a modal sheet with a scrim and a
|
|
22
|
+
* contained focus loop, as the picker is.
|
|
23
|
+
* - Focus opens on the pill (Manage your Identity account, or Set up your
|
|
24
|
+
* Identity), else the active organisation, else Sign out. The close button is
|
|
25
|
+
* a stop and never the first one.
|
|
26
|
+
* - The organisations are a `radiogroup`, one tab stop, Up and Down moving
|
|
27
|
+
* between them. Choosing is Space or Enter and never an arrow, because
|
|
28
|
+
* choosing switches the product: it closes the popup and announces
|
|
29
|
+
* "Switched to ...". Five show; "Show all" opens the rest in place, scrolling
|
|
30
|
+
* inside, and focus lands on the row that took its place.
|
|
31
|
+
* - Signing out goes busy in place. A failure keeps the popup open, says in one
|
|
32
|
+
* sentence that the person is still signed in, and turns the row into Try
|
|
33
|
+
* again. It never claims to have signed out.
|
|
34
|
+
*
|
|
35
|
+
* **Hidden for now (D20):** the other accounts and "Add another account" are
|
|
36
|
+
* not drawn until Identity holds several sessions in one browser and products
|
|
37
|
+
* sign in through it, and no product can switch them on.
|
|
38
|
+
*
|
|
39
|
+
* The words are English by default and every one can be replaced (`labels`).
|
|
40
|
+
*/
|
|
41
|
+
export type OrganisationRole = "owner" | "admin" | "member";
|
|
42
|
+
/** Who is signed in, from the product's own session. */
|
|
43
|
+
export interface AccountPerson {
|
|
44
|
+
name: string;
|
|
45
|
+
email: string;
|
|
46
|
+
/** The product's own stable id for this person: it picks the tint where they have no Identity. Never shown. */
|
|
47
|
+
id?: string;
|
|
48
|
+
/** The product's own picture, drawn where Identity has none. */
|
|
49
|
+
avatarUrl?: string | null;
|
|
50
|
+
}
|
|
51
|
+
/** An organisation the person can act in, as the product or Identity passes it (D24, D33). */
|
|
52
|
+
export interface AccountOrganisation {
|
|
53
|
+
id: string;
|
|
54
|
+
name: string;
|
|
55
|
+
role: OrganisationRole;
|
|
56
|
+
active: boolean;
|
|
57
|
+
}
|
|
58
|
+
/** Signs the person out of this product. Rejecting means they are still signed in. */
|
|
59
|
+
export type SignOutHandler = () => unknown;
|
|
60
|
+
/** Switches the product to this organisation. Rejecting means it did not. */
|
|
61
|
+
export type SwitchHandler = (organisationId: string) => unknown;
|
|
62
|
+
export interface AccountLabels {
|
|
63
|
+
/** The trigger's name and its tooltip. */
|
|
64
|
+
trigger: (name: string) => string;
|
|
65
|
+
signedInTo: (product: string) => string;
|
|
66
|
+
close: string;
|
|
67
|
+
changePicture: string;
|
|
68
|
+
manage: string;
|
|
69
|
+
setUp: string;
|
|
70
|
+
organisations: string;
|
|
71
|
+
roles: Record<OrganisationRole, string>;
|
|
72
|
+
active: string;
|
|
73
|
+
showAll: (count: number) => string;
|
|
74
|
+
switched: (organisation: string) => string;
|
|
75
|
+
switchFailed: (organisation: string) => string;
|
|
76
|
+
signOut: (product: string) => string;
|
|
77
|
+
/** In a product that signs in through Identity, where signing out ends both. */
|
|
78
|
+
signOutBoth: (product: string) => string;
|
|
79
|
+
signingOut: (product: string) => string;
|
|
80
|
+
signOutFailed: (product: string) => string;
|
|
81
|
+
retry: string;
|
|
82
|
+
/** Stands in for the product's name where it was not given. */
|
|
83
|
+
thisProduct: string;
|
|
84
|
+
terms: string;
|
|
85
|
+
privacy: string;
|
|
86
|
+
}
|
|
87
|
+
export declare const ACCOUNT_LABELS: AccountLabels;
|
|
88
|
+
export declare const ACCOUNT_CSS: string;
|
|
89
|
+
/**
|
|
90
|
+
* Defines `<gp-account>` once, where custom elements exist. Safe to import on a
|
|
91
|
+
* server: nothing touches the DOM until this is called in a browser.
|
|
92
|
+
*/
|
|
93
|
+
export declare function defineAccountElement(name?: string): void;
|
|
94
|
+
/** The class itself, for a caller that registers it under its own name or in a scoped registry. */
|
|
95
|
+
export declare function createAccountElementClass(): CustomElementConstructor;
|
|
96
|
+
//# sourceMappingURL=account.d.ts.map
|