@spooky-sync/core 0.0.1-canary.263 → 0.0.1-canary.265
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/AGENTS.md +1 -1
- package/dist/index.d.ts +11 -87
- package/dist/index.js +239 -121
- package/dist/types.d.ts +147 -1
- package/package.json +3 -3
- package/src/client/sp00ky-client.ts +25 -4
- package/src/modules/auth/impersonation-banner.ts +310 -140
- package/src/modules/auth/impersonation.test.ts +73 -0
- package/src/types.ts +12 -0
|
@@ -1,31 +1,128 @@
|
|
|
1
1
|
import type { ImpersonationInfo } from './impersonation';
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
*
|
|
5
|
-
* when hidden), so
|
|
6
|
-
|
|
3
|
+
/** Default height of the warning bar above the framed page. The effective
|
|
4
|
+
* height is published as the CSS variable
|
|
5
|
+
* `--sp00ky-impersonation-banner-height` on `<html>` (`0px` when hidden), so
|
|
6
|
+
* an app can offset its own fixed header. */
|
|
7
|
+
export const BANNER_HEIGHT_PX = 40;
|
|
7
8
|
|
|
8
|
-
/**
|
|
9
|
-
const
|
|
9
|
+
/** Default gutter left, right and below the framed page. */
|
|
10
|
+
export const FRAME_INSET_PX = 10;
|
|
11
|
+
|
|
12
|
+
/** Default radius of the framed page's corners. */
|
|
13
|
+
export const PAGE_RADIUS_PX = 14;
|
|
14
|
+
|
|
15
|
+
/** The default backdrop: a warm amber-to-orange wash with a faint diagonal
|
|
16
|
+
* hazard rake over it. Layered background-image, topmost layer first. */
|
|
17
|
+
export const BANNER_BACKGROUND =
|
|
18
|
+
'repeating-linear-gradient(45deg, rgba(69, 26, 3, .07) 0 9px, rgba(69, 26, 3, 0) 9px 18px),' +
|
|
19
|
+
' linear-gradient(135deg, #fbbf24 0%, #fb923c 52%, #f59e0b 100%)';
|
|
10
20
|
|
|
11
21
|
const HOST_TAG = 'sp00ky-impersonation-banner';
|
|
12
22
|
|
|
13
|
-
/**
|
|
14
|
-
*
|
|
15
|
-
|
|
23
|
+
/** How long a `custom` banner has to acknowledge itself before the built-in
|
|
24
|
+
* one is shown anyway. Long enough for a first paint, short enough that
|
|
25
|
+
* nobody browses unwarned. */
|
|
26
|
+
export const CUSTOM_BANNER_GRACE_MS = 2500;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Restyling of the built-in banner. Every field is optional; anything unset
|
|
30
|
+
* keeps the default. Colours are plain CSS values, so a project can pass its
|
|
31
|
+
* own tokens (`var(--brand-warning)`) as long as they resolve at the document
|
|
32
|
+
* level.
|
|
33
|
+
*/
|
|
34
|
+
export interface ImpersonationBannerTheme {
|
|
35
|
+
/** Height of the warning bar above the page. */
|
|
36
|
+
heightPx?: number;
|
|
37
|
+
/** Gutter left, right and below the page. `0` frames only from the top. */
|
|
38
|
+
insetPx?: number;
|
|
39
|
+
/** Radius of the framed page's corners. `0` squares them off. */
|
|
40
|
+
radiusPx?: number;
|
|
41
|
+
/** The backdrop behind the page. Any CSS `background` value. */
|
|
42
|
+
background?: string;
|
|
43
|
+
/** Colour of the warning text on the backdrop. */
|
|
44
|
+
text?: string;
|
|
45
|
+
/** Background of the Stop button. */
|
|
46
|
+
accent?: string;
|
|
47
|
+
/** Text colour of the Stop button. */
|
|
48
|
+
accentText?: string;
|
|
49
|
+
/** Label for the Stop button. Defaults to `Stop`. */
|
|
50
|
+
stopLabel?: string;
|
|
51
|
+
/** The warning sentence. Defaults to `Impersonating <target> as <admin>`.
|
|
52
|
+
* Wrap a run in `**` to emphasise it; the result is text, never markup. */
|
|
53
|
+
label?: (info: ImpersonationInfo) => string;
|
|
54
|
+
/** Keep the bar but leave the page's own layout completely alone. The CSS
|
|
55
|
+
* variable is still published, so the app can do its own offset. */
|
|
56
|
+
noPageShift?: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ImpersonationBannerConfig {
|
|
60
|
+
/**
|
|
61
|
+
* - `'default'` renders the built-in banner.
|
|
62
|
+
* - `'custom'` leaves the page alone so the app can render its own from
|
|
63
|
+
* `useImpersonation()` / `auth.subscribeImpersonation`. Acknowledge it
|
|
64
|
+
* with `client.acknowledgeImpersonationBanner()` while it is on screen;
|
|
65
|
+
* if nothing acknowledges within {@link CUSTOM_BANNER_GRACE_MS}, the
|
|
66
|
+
* built-in banner appears, on the assumption that the custom one failed
|
|
67
|
+
* to render rather than that nobody should be warned.
|
|
68
|
+
* - `'none'` renders nothing, ever. The app takes over responsibility for
|
|
69
|
+
* telling whoever is looking at the page that it is not their session.
|
|
70
|
+
*/
|
|
71
|
+
mode?: 'default' | 'custom' | 'none';
|
|
72
|
+
/** Restyle the built-in banner (also used if `custom` falls back). */
|
|
73
|
+
theme?: ImpersonationBannerTheme;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface ResolvedTheme
|
|
77
|
+
extends Required<Omit<ImpersonationBannerTheme, 'label' | 'stopLabel'>> {
|
|
78
|
+
stopLabel: string;
|
|
79
|
+
label: (info: ImpersonationInfo) => string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function resolveTheme(theme: ImpersonationBannerTheme = {}): ResolvedTheme {
|
|
83
|
+
return {
|
|
84
|
+
heightPx: theme.heightPx ?? BANNER_HEIGHT_PX,
|
|
85
|
+
insetPx: theme.insetPx ?? FRAME_INSET_PX,
|
|
86
|
+
radiusPx: theme.radiusPx ?? PAGE_RADIUS_PX,
|
|
87
|
+
background: theme.background ?? BANNER_BACKGROUND,
|
|
88
|
+
text: theme.text ?? '#45230a',
|
|
89
|
+
accent: theme.accent ?? '#45230a',
|
|
90
|
+
accentText: theme.accentText ?? '#fff7ed',
|
|
91
|
+
stopLabel: theme.stopLabel ?? 'Stop',
|
|
92
|
+
noPageShift: theme.noPageShift ?? false,
|
|
93
|
+
label: theme.label ?? ((info) => `Impersonating **${info.target}** as ${info.admin}`),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
16
96
|
|
|
17
|
-
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
|
|
21
|
-
|
|
97
|
+
/**
|
|
98
|
+
* How much the page has to shrink to fit inside the frame, and how wide its
|
|
99
|
+
* layout box must be to fill the frame at that scale.
|
|
100
|
+
*
|
|
101
|
+
* Pure so the arithmetic is unit tested: the frame takes `heightPx` off the
|
|
102
|
+
* top and `insetPx` off the other three sides, and the page is scaled
|
|
103
|
+
* uniformly (one factor for both axes, so nothing is distorted) by the
|
|
104
|
+
* vertical ratio. `width` is the CSS width the page's box needs, since a
|
|
105
|
+
* zoomed box occupies `width * scale` of its parent.
|
|
106
|
+
*/
|
|
107
|
+
export function frameMetrics(
|
|
108
|
+
viewport: { width: number; height: number },
|
|
109
|
+
theme: { heightPx: number; insetPx: number }
|
|
110
|
+
): { scale: number; width: number; contentHeight: number } {
|
|
111
|
+
const { width: w, height: h } = viewport;
|
|
112
|
+
const contentHeight = Math.max(1, h - theme.heightPx - theme.insetPx);
|
|
113
|
+
const contentWidth = Math.max(1, w - theme.insetPx * 2);
|
|
114
|
+
// Uniform: the vertical fit decides, so the aspect ratio is untouched and
|
|
115
|
+
// the page keeps every pixel of its height reachable.
|
|
116
|
+
const scale = h > 0 ? contentHeight / h : 1;
|
|
117
|
+
return { scale, width: contentWidth / scale, contentHeight };
|
|
22
118
|
}
|
|
23
119
|
|
|
24
120
|
// The shadow root keeps app CSS away from the bar, but the host element itself
|
|
25
121
|
// is in the page and matches the page's selectors (`div { display: none }`).
|
|
26
122
|
// An `!important` rule on `:host` wins over the page's own `!important`
|
|
27
123
|
// (inner tree context), which keeps the host rendered whatever the app says.
|
|
28
|
-
|
|
124
|
+
export function bannerCss(t: ResolvedTheme): string {
|
|
125
|
+
return `
|
|
29
126
|
:host {
|
|
30
127
|
all: initial !important;
|
|
31
128
|
display: block !important;
|
|
@@ -35,107 +132,155 @@ const STYLE = `
|
|
|
35
132
|
transform: none !important;
|
|
36
133
|
clip-path: none !important;
|
|
37
134
|
}
|
|
38
|
-
.
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
/* The page's rounded top corners, drawn over whatever the app puts there.
|
|
43
|
-
The body element's own border-radius rounds its background, but a fixed app
|
|
44
|
-
header is not clipped by it, so each corner is also painted here: a striped
|
|
45
|
-
square masked down to the nub OUTSIDE the corner's curve, so the app's own
|
|
46
|
-
colour still shows inside it. */
|
|
47
|
-
.corners {
|
|
48
|
-
position: fixed; left: 0; right: 0; top: ${BANNER_HEIGHT_PX}px;
|
|
49
|
-
height: ${PAGE_RADIUS_PX}px; z-index: 2147483646; pointer-events: none;
|
|
50
|
-
}
|
|
51
|
-
.corner {
|
|
52
|
-
position: absolute; top: 0; width: ${PAGE_RADIUS_PX}px; height: ${PAGE_RADIUS_PX}px;
|
|
53
|
-
background: ${STRIPES};
|
|
54
|
-
}
|
|
55
|
-
.corner.left {
|
|
56
|
-
left: 0;
|
|
57
|
-
-webkit-mask: ${cornerMask('100% 100%')};
|
|
58
|
-
mask: ${cornerMask('100% 100%')};
|
|
59
|
-
}
|
|
60
|
-
.corner.right {
|
|
61
|
-
right: 0;
|
|
62
|
-
-webkit-mask: ${cornerMask('0% 100%')};
|
|
63
|
-
mask: ${cornerMask('0% 100%')};
|
|
64
|
-
}
|
|
135
|
+
/* The bar. The backdrop itself is painted on the root element, which is
|
|
136
|
+
BEHIND the page's sheet; an overlay here would cover the page. The bar
|
|
137
|
+
repeats that background with a fixed attachment, so its gradient lines up
|
|
138
|
+
with the root's to the pixel. */
|
|
65
139
|
.bar {
|
|
66
|
-
position: fixed; inset: 0 0 auto 0; height: ${
|
|
140
|
+
position: fixed; inset: 0 0 auto 0; height: ${t.heightPx}px; z-index: 2147483647;
|
|
67
141
|
display: flex; align-items: center; justify-content: center; gap: 10px;
|
|
68
|
-
padding: 0
|
|
69
|
-
|
|
142
|
+
padding: 0 ${Math.max(t.insetPx, 10)}px; box-sizing: border-box;
|
|
143
|
+
background-image: ${t.background};
|
|
144
|
+
background-attachment: fixed;
|
|
145
|
+
color: ${t.text};
|
|
146
|
+
font: 600 12.5px/1 system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
147
|
+
letter-spacing: .01em;
|
|
70
148
|
}
|
|
71
|
-
.
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
background:
|
|
75
|
-
|
|
149
|
+
.sign {
|
|
150
|
+
flex: none; display: grid; place-items: center;
|
|
151
|
+
width: 17px; height: 17px; border-radius: 50%;
|
|
152
|
+
background: ${t.text}; color: #fff7ed;
|
|
153
|
+
font-size: 11px; font-weight: 800;
|
|
76
154
|
}
|
|
77
|
-
.sign { flex: none; font-size: 14px; line-height: 1; }
|
|
78
155
|
.text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
|
79
|
-
.text b {
|
|
156
|
+
.text b { font-weight: 800; }
|
|
80
157
|
button {
|
|
81
158
|
flex: none; cursor: pointer; border: 0; border-radius: 999px;
|
|
82
|
-
background:
|
|
83
|
-
font:
|
|
84
|
-
box-shadow: 0 1px
|
|
159
|
+
background: ${t.accent}; color: ${t.accentText};
|
|
160
|
+
font: 700 11.5px/1 system-ui, sans-serif; padding: 6px 11px;
|
|
161
|
+
box-shadow: 0 1px 2px rgba(69, 26, 3, .35);
|
|
85
162
|
}
|
|
86
|
-
button:hover {
|
|
163
|
+
button:hover { filter: brightness(1.15); }
|
|
87
164
|
button:disabled { opacity: .6; cursor: progress; }
|
|
88
165
|
@media (max-width: 520px) {
|
|
89
|
-
.bar { gap:
|
|
90
|
-
.
|
|
166
|
+
.bar { gap: 7px; padding: 0 8px; font-size: 11.5px; }
|
|
167
|
+
.sign { display: none; }
|
|
91
168
|
}
|
|
92
169
|
`;
|
|
170
|
+
}
|
|
93
171
|
|
|
94
172
|
/**
|
|
95
173
|
* The "you are impersonating" warning.
|
|
96
174
|
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
175
|
+
* The page becomes a sheet on a warm striped backdrop: a bar across the top
|
|
176
|
+
* names the session, and the page itself is scaled down to sit inside the
|
|
177
|
+
* remaining frame with rounded corners. Scaled, not pushed: growing the
|
|
178
|
+
* document moved the bottom of an app-shell layout off-screen, while a
|
|
179
|
+
* uniform scale keeps the aspect ratio and every pixel reachable, and
|
|
180
|
+
* `100vh`/`100dvh` layouts still resolve to exactly the frame.
|
|
181
|
+
*
|
|
182
|
+
* The bar lives in a closed shadow root, so app styles cannot hide or
|
|
183
|
+
* restyle it; the framing is written as `!important` inline styles on
|
|
184
|
+
* `<html>` / `<body>` (nothing in a stylesheet outranks that) and the
|
|
185
|
+
* previous inline styles are restored on unmount.
|
|
104
186
|
*
|
|
105
187
|
* Every piece of text goes through `textContent`.
|
|
106
188
|
*/
|
|
107
189
|
export class ImpersonationBanner {
|
|
108
190
|
private host: HTMLElement | null = null;
|
|
109
191
|
private text: HTMLElement | null = null;
|
|
110
|
-
/** `style` attributes as the page had them before the
|
|
192
|
+
/** `style` attributes as the page had them before the frame. */
|
|
111
193
|
private saved: { html: string | null; body: string | null } | null = null;
|
|
194
|
+
private theme: ResolvedTheme;
|
|
195
|
+
private mode: 'default' | 'custom' | 'none';
|
|
196
|
+
/** Grace timer for `custom` mode. */
|
|
197
|
+
private fallbackTimer: ReturnType<typeof setTimeout> | null = null;
|
|
198
|
+
/** The app said its own banner is on screen for this impersonation. */
|
|
199
|
+
private acknowledged = false;
|
|
200
|
+
private warnedAboutNone = false;
|
|
201
|
+
private onResize: (() => void) | null = null;
|
|
112
202
|
|
|
113
|
-
constructor(
|
|
203
|
+
constructor(
|
|
204
|
+
private onStop: () => Promise<void>,
|
|
205
|
+
config: ImpersonationBannerConfig = {}
|
|
206
|
+
) {
|
|
207
|
+
this.mode = config.mode ?? 'default';
|
|
208
|
+
this.theme = resolveTheme(config.theme);
|
|
209
|
+
}
|
|
114
210
|
|
|
115
211
|
static supported(): boolean {
|
|
116
212
|
return typeof document !== 'undefined' && typeof document.createElement === 'function';
|
|
117
213
|
}
|
|
118
214
|
|
|
215
|
+
/**
|
|
216
|
+
* The app's own banner is rendered. Cancels the `custom`-mode fallback, and
|
|
217
|
+
* removes the built-in banner if the fallback already fired (a slow first
|
|
218
|
+
* paint, say). No-op in the other modes.
|
|
219
|
+
*/
|
|
220
|
+
acknowledge(): void {
|
|
221
|
+
this.acknowledged = true;
|
|
222
|
+
this.clearFallback();
|
|
223
|
+
if (this.mode === 'custom') this.teardown();
|
|
224
|
+
}
|
|
225
|
+
|
|
119
226
|
update(info: ImpersonationInfo | null): void {
|
|
120
227
|
if (!ImpersonationBanner.supported()) return;
|
|
121
228
|
if (!info) {
|
|
229
|
+
this.acknowledged = false;
|
|
122
230
|
this.unmount();
|
|
123
231
|
return;
|
|
124
232
|
}
|
|
233
|
+
if (this.mode === 'none') {
|
|
234
|
+
// The app owns the warning entirely. Said once, loudly, because the
|
|
235
|
+
// page now looks like an ordinary session of someone else's account.
|
|
236
|
+
if (!this.warnedAboutNone) {
|
|
237
|
+
this.warnedAboutNone = true;
|
|
238
|
+
// oxlint-disable-next-line no-console
|
|
239
|
+
console.warn(
|
|
240
|
+
'[sp00ky] impersonation is active and impersonationBanner.mode is "none": ' +
|
|
241
|
+
"nothing marks this page as another user's session."
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (this.mode === 'custom' && !this.acknowledged) {
|
|
247
|
+
// Give the app its grace period, then warn anyway.
|
|
248
|
+
if (!this.fallbackTimer && !this.host) {
|
|
249
|
+
this.fallbackTimer = setTimeout(() => {
|
|
250
|
+
this.fallbackTimer = null;
|
|
251
|
+
if (!this.acknowledged) this.render(info);
|
|
252
|
+
}, CUSTOM_BANNER_GRACE_MS);
|
|
253
|
+
}
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (this.mode === 'custom') return;
|
|
257
|
+
this.render(info);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
unmount(): void {
|
|
261
|
+
this.clearFallback();
|
|
262
|
+
this.teardown();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Mount (if needed) and write the current identities into the bar. */
|
|
266
|
+
private render(info: ImpersonationInfo): void {
|
|
125
267
|
this.mount();
|
|
268
|
+
const label = this.theme.label(info);
|
|
126
269
|
const text = this.text!;
|
|
127
|
-
text.
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
document.createTextNode(` as ${info.admin}`)
|
|
131
|
-
);
|
|
132
|
-
text.title = `Impersonating ${info.target} as ${info.admin}. Every write is audited.`;
|
|
270
|
+
text.textContent = '';
|
|
271
|
+
text.append(...labelNodes(label));
|
|
272
|
+
text.title = `${label.replace(/\*\*/g, '')}. Every write is audited.`;
|
|
133
273
|
}
|
|
134
274
|
|
|
135
|
-
|
|
275
|
+
/** Remove the bar and put the page back exactly as it was. */
|
|
276
|
+
private teardown(): void {
|
|
136
277
|
this.host?.remove();
|
|
137
278
|
this.host = this.text = null;
|
|
138
279
|
if (typeof document === 'undefined') return;
|
|
280
|
+
if (this.onResize) {
|
|
281
|
+
window.removeEventListener('resize', this.onResize);
|
|
282
|
+
this.onResize = null;
|
|
283
|
+
}
|
|
139
284
|
if (this.saved) {
|
|
140
285
|
restoreStyle(document.documentElement, this.saved.html);
|
|
141
286
|
restoreStyle(document.body, this.saved.body);
|
|
@@ -144,111 +289,117 @@ export class ImpersonationBanner {
|
|
|
144
289
|
document.documentElement?.style.setProperty('--sp00ky-impersonation-banner-height', '0px');
|
|
145
290
|
}
|
|
146
291
|
|
|
292
|
+
private clearFallback(): void {
|
|
293
|
+
if (this.fallbackTimer) clearTimeout(this.fallbackTimer);
|
|
294
|
+
this.fallbackTimer = null;
|
|
295
|
+
}
|
|
296
|
+
|
|
147
297
|
private mount(): void {
|
|
148
298
|
if (this.host?.isConnected) return;
|
|
149
299
|
const host = document.createElement(HOST_TAG);
|
|
150
300
|
const root = host.attachShadow({ mode: 'closed' });
|
|
151
301
|
const style = document.createElement('style');
|
|
152
|
-
style.textContent =
|
|
153
|
-
|
|
154
|
-
// The striped ground the bar's text sits on: fixed, so it stays put while
|
|
155
|
-
// the page scrolls underneath. Never takes a click.
|
|
156
|
-
const layer = document.createElement('div');
|
|
157
|
-
layer.className = 'layer';
|
|
158
|
-
layer.setAttribute('aria-hidden', 'true');
|
|
159
|
-
const corners = document.createElement('div');
|
|
160
|
-
corners.className = 'corners';
|
|
161
|
-
corners.setAttribute('aria-hidden', 'true');
|
|
162
|
-
for (const side of ['left', 'right']) {
|
|
163
|
-
const corner = document.createElement('div');
|
|
164
|
-
corner.className = `corner ${side}`;
|
|
165
|
-
corners.append(corner);
|
|
166
|
-
}
|
|
302
|
+
style.textContent = bannerCss(this.theme);
|
|
167
303
|
|
|
168
304
|
const bar = document.createElement('div');
|
|
169
305
|
bar.className = 'bar';
|
|
170
306
|
bar.setAttribute('role', 'alert');
|
|
171
|
-
const pill = document.createElement('div');
|
|
172
|
-
pill.className = 'pill';
|
|
173
307
|
const sign = document.createElement('span');
|
|
174
308
|
sign.className = 'sign';
|
|
175
|
-
sign.textContent = '
|
|
309
|
+
sign.textContent = '!';
|
|
310
|
+
sign.setAttribute('aria-hidden', 'true');
|
|
176
311
|
const text = document.createElement('span');
|
|
177
312
|
text.className = 'text';
|
|
178
313
|
const button = document.createElement('button');
|
|
179
314
|
button.type = 'button';
|
|
180
|
-
button.textContent =
|
|
315
|
+
button.textContent = this.theme.stopLabel;
|
|
181
316
|
button.addEventListener('click', () => {
|
|
182
317
|
button.disabled = true;
|
|
183
318
|
this.onStop().finally(() => {
|
|
184
319
|
button.disabled = false;
|
|
185
320
|
});
|
|
186
321
|
});
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
//
|
|
191
|
-
// rounded top, and a bar inside it would be clipped away with everything
|
|
192
|
-
// else.
|
|
322
|
+
bar.append(sign, text, button);
|
|
323
|
+
root.append(style, bar);
|
|
324
|
+
// Hosted on `<html>`, not `<body>`: the body is the framed sheet, and a
|
|
325
|
+
// bar inside it would be scaled and clipped along with the page.
|
|
193
326
|
document.documentElement.appendChild(host);
|
|
194
327
|
|
|
195
|
-
this.
|
|
328
|
+
this.frame();
|
|
196
329
|
this.host = host;
|
|
197
330
|
this.text = text;
|
|
198
331
|
}
|
|
199
332
|
|
|
200
333
|
/**
|
|
201
|
-
*
|
|
334
|
+
* Turn `<body>` into the framed sheet.
|
|
202
335
|
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
* the
|
|
207
|
-
*
|
|
336
|
+
* `zoom`, not `transform`: it scales at layout level, so `100vh` children
|
|
337
|
+
* resolve to the frame instead of overflowing it, scrollbars stay correct,
|
|
338
|
+
* and `position: fixed` keeps meaning the viewport (a transform would make
|
|
339
|
+
* the body a containing block and every fixed header would scroll away).
|
|
340
|
+
* The body's CSS width is widened by `1 / scale` so the scaled box still
|
|
341
|
+
* fills the frame.
|
|
208
342
|
*/
|
|
209
|
-
private
|
|
343
|
+
private frame(): void {
|
|
210
344
|
const root = document.documentElement;
|
|
211
345
|
const body = document.body;
|
|
212
346
|
if (!root || !body) return;
|
|
213
|
-
|
|
347
|
+
const { heightPx, insetPx, radiusPx, noPageShift } = this.theme;
|
|
348
|
+
// Published even when the framing is off, so an app doing its own offset
|
|
349
|
+
// has one number to read either way.
|
|
350
|
+
root.style.setProperty('--sp00ky-impersonation-banner-height', `${heightPx}px`);
|
|
351
|
+
if (noPageShift) return;
|
|
214
352
|
|
|
353
|
+
this.saved = { html: root.getAttribute('style'), body: body.getAttribute('style') };
|
|
215
354
|
const surface = resolveSurface(root, body);
|
|
216
|
-
root
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
root.style.setProperty('
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
'
|
|
231
|
-
|
|
232
|
-
'important'
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
355
|
+
// The root paints the backdrop: it is the one box that is guaranteed to
|
|
356
|
+
// be behind the page's sheet. `overflow: hidden` keeps the document
|
|
357
|
+
// itself from scrolling — the sheet is fixed and scrolls internally.
|
|
358
|
+
root.style.setProperty('background', this.theme.background, 'important');
|
|
359
|
+
root.style.setProperty('overflow', 'hidden', 'important');
|
|
360
|
+
|
|
361
|
+
const apply = () => {
|
|
362
|
+
const { scale, width } = frameMetrics(
|
|
363
|
+
{ width: window.innerWidth, height: window.innerHeight },
|
|
364
|
+
{ heightPx, insetPx }
|
|
365
|
+
);
|
|
366
|
+
body.style.setProperty('position', 'fixed', 'important');
|
|
367
|
+
body.style.setProperty('top', `${heightPx}px`, 'important');
|
|
368
|
+
body.style.setProperty('left', `${insetPx}px`, 'important');
|
|
369
|
+
body.style.setProperty('margin', '0', 'important');
|
|
370
|
+
body.style.setProperty('zoom', String(scale), 'important');
|
|
371
|
+
body.style.setProperty('width', `${width}px`, 'important');
|
|
372
|
+
body.style.setProperty('height', '100vh', 'important');
|
|
373
|
+
body.style.setProperty('overflow', 'auto', 'important');
|
|
374
|
+
body.style.setProperty('background-color', surface, 'important');
|
|
375
|
+
body.style.setProperty('box-shadow', '0 6px 24px rgba(69, 26, 3, .28)', 'important');
|
|
376
|
+
if (radiusPx > 0) {
|
|
377
|
+
const radius = `${radiusPx / scale}px`;
|
|
378
|
+
body.style.setProperty('border-radius', radius, 'important');
|
|
379
|
+
// Clips the page's own fixed layers (a loading overlay, a modal
|
|
380
|
+
// backdrop) to the sheet, so nothing paints over the frame.
|
|
381
|
+
body.style.setProperty('clip-path', `inset(0 round ${radius})`, 'important');
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
apply();
|
|
385
|
+
this.onResize = apply;
|
|
386
|
+
window.addEventListener('resize', apply);
|
|
238
387
|
}
|
|
239
388
|
}
|
|
240
389
|
|
|
241
|
-
/** Put an element's inline styles back exactly as they were.
|
|
390
|
+
/** Put an element's inline styles back exactly as they were. An element that
|
|
391
|
+
* had no `style` attribute (or an empty one) ends up without one again. */
|
|
242
392
|
function restoreStyle(el: HTMLElement | null, saved: string | null): void {
|
|
243
393
|
if (!el) return;
|
|
244
|
-
if (saved
|
|
245
|
-
else el.
|
|
394
|
+
if (saved) el.setAttribute('style', saved);
|
|
395
|
+
else el.removeAttribute('style');
|
|
246
396
|
}
|
|
247
397
|
|
|
248
398
|
/**
|
|
249
|
-
* The colour to paint
|
|
250
|
-
*
|
|
251
|
-
* colour
|
|
399
|
+
* The colour to paint the sheet with: whatever the page already paints (body
|
|
400
|
+
* first, then html), and failing that the canvas colour for the viewer's
|
|
401
|
+
* colour scheme. A transparent sheet would show the backdrop through the
|
|
402
|
+
* whole page.
|
|
252
403
|
*/
|
|
253
404
|
function resolveSurface(root: HTMLElement, body: HTMLElement): string {
|
|
254
405
|
for (const el of [body, root]) {
|
|
@@ -266,8 +417,27 @@ function isTransparent(color: string): boolean {
|
|
|
266
417
|
return !!parts && parts.length === 4 && Number(parts[3]) === 0;
|
|
267
418
|
}
|
|
268
419
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
420
|
+
/**
|
|
421
|
+
* The label as DOM nodes, emphasising the runs between `**` markers. The
|
|
422
|
+
* label is a plain string (an app's own function can return anything), so it
|
|
423
|
+
* never reaches the DOM as markup.
|
|
424
|
+
*/
|
|
425
|
+
function labelNodes(label: string): Node[] {
|
|
426
|
+
return labelSegments(label).map(({ text, bold }) => {
|
|
427
|
+
if (!bold) return document.createTextNode(text);
|
|
428
|
+
const b = document.createElement('b');
|
|
429
|
+
b.textContent = text;
|
|
430
|
+
return b;
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** The label split into plain and emphasised runs. Pure, so it is unit tested. */
|
|
435
|
+
function labelSegments(label: string): Array<{ text: string; bold: boolean }> {
|
|
436
|
+
return label
|
|
437
|
+
.split(/\*\*(.+?)\*\*/g)
|
|
438
|
+
.map((text, i) => ({ text, bold: i % 2 === 1 }))
|
|
439
|
+
.filter((part) => part.text !== '');
|
|
273
440
|
}
|
|
441
|
+
|
|
442
|
+
/** Exported for unit tests only. */
|
|
443
|
+
export const __test = { labelSegments, resolveTheme };
|
|
@@ -280,3 +280,76 @@ describe('timers', () => {
|
|
|
280
280
|
timers.clear();
|
|
281
281
|
});
|
|
282
282
|
});
|
|
283
|
+
|
|
284
|
+
describe('banner configuration', () => {
|
|
285
|
+
it('builds CSS from the theme, defaults included', async () => {
|
|
286
|
+
const { bannerCss, BANNER_BACKGROUND } = await import('./impersonation-banner');
|
|
287
|
+
// `bannerCss` takes a resolved theme; the client resolves it, so this
|
|
288
|
+
// pins the rendered output for a fully specified one.
|
|
289
|
+
const css = bannerCss({
|
|
290
|
+
heightPx: 60,
|
|
291
|
+
insetPx: 16,
|
|
292
|
+
radiusPx: 0,
|
|
293
|
+
background: 'var(--brand-warning)',
|
|
294
|
+
text: '#222',
|
|
295
|
+
accent: '#f00',
|
|
296
|
+
accentText: '#fff',
|
|
297
|
+
stopLabel: 'Leave',
|
|
298
|
+
noPageShift: true,
|
|
299
|
+
label: () => 'x',
|
|
300
|
+
});
|
|
301
|
+
expect(css).toContain('height: 60px');
|
|
302
|
+
expect(css).toContain('background-image: var(--brand-warning)');
|
|
303
|
+
expect(css).toContain('background: #f00; color: #fff');
|
|
304
|
+
expect(css).not.toContain(BANNER_BACKGROUND);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('scales the page to the frame without distorting it', async () => {
|
|
308
|
+
const { frameMetrics } = await import('./impersonation-banner');
|
|
309
|
+
const theme = { heightPx: 40, insetPx: 10 };
|
|
310
|
+
|
|
311
|
+
const desktop = frameMetrics({ width: 1440, height: 900 }, theme);
|
|
312
|
+
// 900 - 40 - 10 = 850 of 900: one factor for both axes, so the aspect
|
|
313
|
+
// ratio is untouched and nothing is cropped.
|
|
314
|
+
expect(desktop.scale).toBeCloseTo(850 / 900);
|
|
315
|
+
expect(desktop.contentHeight).toBe(850);
|
|
316
|
+
// A zoomed box occupies width * scale, so the layout box is widened to
|
|
317
|
+
// still fill the frame exactly.
|
|
318
|
+
expect(desktop.width * desktop.scale).toBeCloseTo(1440 - 20);
|
|
319
|
+
|
|
320
|
+
const phone = frameMetrics({ width: 390, height: 844 }, theme);
|
|
321
|
+
expect(phone.contentHeight).toBe(844 - 50);
|
|
322
|
+
expect(phone.width * phone.scale).toBeCloseTo(370);
|
|
323
|
+
|
|
324
|
+
// Degenerate viewports (a hidden tab reporting 0) must not divide by zero
|
|
325
|
+
// or hand back a negative box.
|
|
326
|
+
const zero = frameMetrics({ width: 0, height: 0 }, theme);
|
|
327
|
+
expect(zero.scale).toBe(1);
|
|
328
|
+
expect(zero.contentHeight).toBeGreaterThan(0);
|
|
329
|
+
expect(zero.width).toBeGreaterThan(0);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('emphasises the label between ** markers instead of parsing markup', async () => {
|
|
333
|
+
const { __test } = await import('./impersonation-banner');
|
|
334
|
+
// A label is a plain string an app supplies, so markup in it must stay text.
|
|
335
|
+
expect(__test.labelSegments('Impersonating **user:bob** as user:alice')).toEqual([
|
|
336
|
+
{ text: 'Impersonating ', bold: false },
|
|
337
|
+
{ text: 'user:bob', bold: true },
|
|
338
|
+
{ text: ' as user:alice', bold: false },
|
|
339
|
+
]);
|
|
340
|
+
expect(__test.labelSegments('<img src=x onerror=alert(1)>')).toEqual([
|
|
341
|
+
{ text: '<img src=x onerror=alert(1)>', bold: false },
|
|
342
|
+
]);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('resolves a partial theme over the defaults', async () => {
|
|
346
|
+
const { __test, BANNER_HEIGHT_PX } = await import('./impersonation-banner');
|
|
347
|
+
const theme = __test.resolveTheme({ insetPx: 0, stopLabel: 'Exit' });
|
|
348
|
+
expect(theme.insetPx).toBe(0);
|
|
349
|
+
expect(theme.stopLabel).toBe('Exit');
|
|
350
|
+
expect(theme.heightPx).toBe(BANNER_HEIGHT_PX);
|
|
351
|
+
expect(theme.label({ target: 'user:b', admin: 'user:a', session: 's', tokenExpiresAt: null })).toBe(
|
|
352
|
+
'Impersonating **user:b** as user:a'
|
|
353
|
+
);
|
|
354
|
+
});
|
|
355
|
+
});
|