@owlmeans/web-client 0.1.18-rc.32 → 0.1.18-rc.33
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/README.md +244 -43
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/web-client/SKILL.md +6 -6
- package/build/entrypoints.d.ts +2 -2
- package/build/login/surrogate.d.ts +16 -0
- package/build/login/surrogate.d.ts.map +1 -1
- package/build/login/surrogate.js +34 -5
- package/build/login/surrogate.js.map +1 -1
- package/package.json +2 -2
- package/src/login/surrogate.ts +41 -5
- package/tests/surrogate.spec.d.ts +2 -0
- package/tests/surrogate.spec.d.ts.map +1 -0
- package/tests/surrogate.spec.js +43 -0
- package/tests/surrogate.spec.js.map +1 -0
- package/tests/surrogate.spec.ts +52 -0
- package/tsconfig.json +1 -1
package/README.md
CHANGED
|
@@ -1,84 +1,285 @@
|
|
|
1
1
|
# @owlmeans/web-client
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
-
|
|
9
|
-
|
|
10
|
-
- Re-exports route constants: `BASE`, `HOME`, `ROOT`, `GUEST`
|
|
11
|
-
- `entrypoints` — the base client entrypoints (dispatcher, login surrogate) every web app spreads
|
|
12
|
-
- `ClientProtocolEntrypoint<Protocol>` — the context-bound entrypoint interface inferred from a
|
|
13
|
-
shared protocol declaration; it exposes `call()` / `invoke()` / `url()`
|
|
3
|
+
Browser application bootstrap for OwlMeans: the web context factory (auth service, IndexedDB, the
|
|
4
|
+
default OwlMeans router and the login host already appended), the React mount helpers, the
|
|
5
|
+
framework entrypoint list (dispatcher, login surrogate) and the browser login plugins. An app uses
|
|
6
|
+
it directly when it renders its own UI and wants only the framework plumbing. An app that wants the
|
|
7
|
+
shadcn panel shell, forms, the styled sign-in screen and the socket reload prompt builds on
|
|
8
|
+
[`@owlmeans/web-panel`](../web-panel), whose `makeContext` composes this one. `@owlmeans/mui-panel`
|
|
9
|
+
is the legacy MUI layer — start nothing new there. React Native apps use the `native` monorepo.
|
|
14
10
|
|
|
15
11
|
## Installation
|
|
16
12
|
|
|
17
13
|
```bash
|
|
18
|
-
bun add @owlmeans/web-client@^0.1.18-rc.
|
|
14
|
+
bun add @owlmeans/web-client@^0.1.18-rc.32
|
|
19
15
|
```
|
|
20
16
|
|
|
17
|
+
## Concepts
|
|
18
|
+
|
|
19
|
+
- **Web context** — `makeContext(cfg)` builds the page's one client context: `makeClientContext`,
|
|
20
|
+
`extractPrimaryHost` (host and port from `window.location`), the web auth service
|
|
21
|
+
(`context.auth()`), the IndexedDB service, the `AUTH_RESOURCE` client resource, the default router
|
|
22
|
+
plugin from `@owlmeans/web-router`, and the login host (`context.login()`) with the redirect and
|
|
23
|
+
surrogate plugins. An app factory calls it and appends its own idempotent `append*` mixins.
|
|
24
|
+
- **Framework entrypoints** — `entrypoints` is `@owlmeans/client-auth`'s list plus the
|
|
25
|
+
`authProtocols.dispatcher` screen (`Dispatcher`) and the `authProtocols.surrogate` screen
|
|
26
|
+
(`SurrogateScreen`). Every app spreads it into the one list it registers.
|
|
27
|
+
- **Dispatcher** — the return leg of authentication. It adopts a token carried in `AUTH_QUERY`,
|
|
28
|
+
lets the login plugin `resume` an existing session, or renders the registered sign-in screen
|
|
29
|
+
(`context.login().screen()`, falling back to `FallbackLoginScreen`). It never starts a flow itself.
|
|
30
|
+
- **Login plugins** — `makeRedirectLoginPlugin` (every ordinary tab, priority 0) and
|
|
31
|
+
`makeSurrogateLoginPlugin` (a framed app or the surrogate window itself,
|
|
32
|
+
`SURROGATE_LOGIN_PRIORITY`) decide *where* the sign-in round trip runs.
|
|
33
|
+
- **Session loss** — `appendWebAuthService` wraps every guarded backend entrypoint: an `AuthUnknown`
|
|
34
|
+
ending in `:invalid` clears the token, and clearing the token sends the document to the
|
|
35
|
+
dispatcher URL.
|
|
36
|
+
- **Global overlay slot** — `children` of `renderApp` / `WebApp` render inside the context provider
|
|
37
|
+
and before the router, so a dialog or toast surface mounted there survives navigation.
|
|
38
|
+
|
|
21
39
|
## Usage
|
|
22
40
|
|
|
23
|
-
|
|
41
|
+
### 1. Config, types and the context factory
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
// src/types.ts
|
|
45
|
+
import type { AppConfig, AppContext } from '@owlmeans/web-client'
|
|
46
|
+
|
|
47
|
+
export interface Config extends AppConfig {}
|
|
48
|
+
export interface Context<C extends Config = Config> extends AppContext<C> {}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
// src/config.ts
|
|
53
|
+
import { config } from '@owlmeans/web-client'
|
|
54
|
+
import { commonConfig, MY_APP_WEB } from 'my-app-common'
|
|
55
|
+
import type { Config } from './types.js'
|
|
56
|
+
|
|
57
|
+
export default config<Config>(MY_APP_WEB, commonConfig as Partial<Config>)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
// src/context.ts
|
|
62
|
+
import { makeContext as makeWebContext, useContext as useWebContext } from '@owlmeans/web-client'
|
|
63
|
+
import { appendStateResource } from '@owlmeans/state'
|
|
64
|
+
import { PROJECT_STATE } from './consts.js'
|
|
65
|
+
import type { Config, Context } from './types.js'
|
|
66
|
+
|
|
67
|
+
export const useContext = (): Context => useWebContext<Config, Context>()
|
|
68
|
+
|
|
69
|
+
export const makeContext = <C extends Config, T extends Context<C>>(cfg: C): T => {
|
|
70
|
+
// Router, auth service, IndexedDB and the login host are already on the context.
|
|
71
|
+
const context = makeWebContext<C, T>(cfg)
|
|
72
|
+
appendStateResource<C, T>(context, PROJECT_STATE)
|
|
73
|
+
|
|
74
|
+
return context
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### 2. Shared screen declarations and the entrypoint list
|
|
79
|
+
|
|
80
|
+
Screens are declared once in the shared package, next to the API protocols:
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
// my-app-common/src/protocols/web.ts
|
|
84
|
+
import { DEFAULT_GUARD } from '@owlmeans/auth-common'
|
|
85
|
+
import { BASE, HOME } from '@owlmeans/context'
|
|
86
|
+
import { openProtocol } from '@owlmeans/entrypoint'
|
|
87
|
+
import { frontend, route } from '@owlmeans/route'
|
|
24
88
|
|
|
25
|
-
|
|
26
|
-
|
|
89
|
+
const aliases = { projects: 'my-app-web:projects', project: 'my-app-web:project' } as const
|
|
90
|
+
|
|
91
|
+
const base = openProtocol(route(BASE, '/', frontend()))
|
|
92
|
+
const projects = openProtocol(route(aliases.projects, '/projects', frontend({ parent: base })), {
|
|
93
|
+
guards: DEFAULT_GUARD,
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
export const webProtocols = {
|
|
97
|
+
base,
|
|
98
|
+
// A parent frontend route needs a `default: true` child, or it renders blank at its own path.
|
|
99
|
+
home: openProtocol(route(HOME, '/', frontend({ parent: base, default: true }))),
|
|
100
|
+
projects,
|
|
101
|
+
project: openProtocol(route(aliases.project, '/:id', frontend({ parent: projects }))),
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The browser binds the whole API tree and attaches a component to each screen:
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
// src/entrypoints.ts
|
|
109
|
+
import { bindAll, bindScreen, entrypoints as frameworkEntrypoints, handler } from '@owlmeans/web-client'
|
|
110
|
+
import { apiProtocols, webProtocols } from 'my-app-common'
|
|
111
|
+
import { PublicLayout } from './layout/public.js'
|
|
112
|
+
import { HomeScreen } from './screens/home.js'
|
|
113
|
+
import { ProjectsScreen } from './screens/projects.js'
|
|
114
|
+
import { ProjectScreen } from './screens/project.js'
|
|
115
|
+
|
|
116
|
+
export const appEntrypoints = [
|
|
117
|
+
...frameworkEntrypoints,
|
|
118
|
+
...bindAll(apiProtocols),
|
|
119
|
+
bindScreen(webProtocols.base, handler(PublicLayout)),
|
|
120
|
+
bindScreen(webProtocols.home, handler(HomeScreen)),
|
|
121
|
+
bindScreen(webProtocols.projects, handler(ProjectsScreen)),
|
|
122
|
+
bindScreen(webProtocols.project, handler(ProjectScreen)),
|
|
123
|
+
]
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### 3. Boot
|
|
127
|
+
|
|
128
|
+
```tsx
|
|
129
|
+
// src/index.tsx
|
|
130
|
+
import './index.css'
|
|
131
|
+
import { renderApp } from '@owlmeans/web-client'
|
|
132
|
+
import { MY_APP_API, MY_APP_WEB } from 'my-app-common'
|
|
133
|
+
import config from './config.js'
|
|
134
|
+
import { makeContext } from './context.js'
|
|
135
|
+
import { appEntrypoints } from './entrypoints.js'
|
|
136
|
+
import { AppOverlays } from './components/overlays.js'
|
|
27
137
|
|
|
28
138
|
const context = makeContext(config)
|
|
139
|
+
|
|
140
|
+
// Before the first render: the router builds its route table once, from what is registered.
|
|
29
141
|
context.registerEntrypoints(appEntrypoints)
|
|
30
|
-
context.serviceRoute(
|
|
31
|
-
|
|
142
|
+
context.serviceRoute(MY_APP_WEB, true)
|
|
143
|
+
context.serviceRoute(MY_APP_API, true)
|
|
144
|
+
|
|
145
|
+
// The third argument is the global overlay slot — rendered above the router, never torn down.
|
|
146
|
+
renderApp(context, undefined, <AppOverlays />)
|
|
32
147
|
```
|
|
33
148
|
|
|
34
|
-
|
|
149
|
+
`renderApp(context, opts?, children?)` mounts `WebApp` (i18n provider, context provider, router)
|
|
150
|
+
into `#root` (`DEFAULT_ROOT`) once `DOMContentLoaded` fires. `RenderOptions` changes that:
|
|
151
|
+
`domId`, `onReady: false` (mount immediately), `hydrate: true` (`hydrateRoot` instead of
|
|
152
|
+
`createRoot`) and `debug`.
|
|
153
|
+
|
|
154
|
+
### 4. Calling protocols from a view model
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
// src/screens/project.vm.ts
|
|
158
|
+
import { EntrypointOutcome } from '@owlmeans/entrypoint'
|
|
159
|
+
import { apiProtocols, webProtocols } from 'my-app-common'
|
|
160
|
+
import type { Context } from '../types.js'
|
|
161
|
+
|
|
162
|
+
export const loadProject = async (ctx: Context, id: string) => {
|
|
163
|
+
// Value only — an error in the reply is thrown.
|
|
164
|
+
const project = await ctx.entrypoint(apiProtocols.project.get).call({ params: { id } })
|
|
35
165
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
166
|
+
// Value plus outcome, when the outcome decides what happens next.
|
|
167
|
+
const { value: slot, outcome } = await ctx.entrypoint(apiProtocols.project.slot)
|
|
168
|
+
.invoke({ params: { id } })
|
|
169
|
+
if (outcome !== EntrypointOutcome.Ok) {
|
|
170
|
+
return { project, slot: null }
|
|
171
|
+
}
|
|
40
172
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
173
|
+
// A screen is addressed, never called.
|
|
174
|
+
const shareUrl = await ctx.entrypoint(webProtocols.project).url({ params: { id } }, { absolute: true })
|
|
175
|
+
|
|
176
|
+
return { project, slot, shareUrl }
|
|
177
|
+
}
|
|
44
178
|
```
|
|
45
179
|
|
|
46
|
-
|
|
47
|
-
|
|
180
|
+
### 5. File drop target
|
|
181
|
+
|
|
182
|
+
`Uploader` wraps `react-dropzone`; its props are `DropzoneOptions` plus an optional `Root`
|
|
183
|
+
component and `rootProps`. `ImageUploader` is the same control with `maxSize` defaulted to 5 MB.
|
|
48
184
|
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
|
|
185
|
+
```tsx
|
|
186
|
+
import type { FC } from 'react'
|
|
187
|
+
import { ImageUploader } from '@owlmeans/web-client'
|
|
188
|
+
|
|
189
|
+
export const LogoDrop: FC<{ onFile: (file: File) => void }> = ({ onFile }) =>
|
|
190
|
+
<ImageUploader maxFiles={1} accept={{ 'image/*': [] }} onDrop={files => onFile(files[0])}>
|
|
191
|
+
<span>Drop a logo here or click to choose one</span>
|
|
192
|
+
</ImageUploader>
|
|
52
193
|
```
|
|
53
194
|
|
|
54
195
|
## API
|
|
55
196
|
|
|
56
|
-
###
|
|
197
|
+
### Context, rendering and services
|
|
198
|
+
|
|
199
|
+
| Symbol | Kind | Purpose |
|
|
200
|
+
|---|---|---|
|
|
201
|
+
| `makeContext<C, T>(cfg)` | function | Web context factory — see *Concepts* |
|
|
202
|
+
| `useContext<C, T>()` | hook | The current context, from React |
|
|
203
|
+
| `renderApp<C, T>(context, opts?, children?)` | function | Mount `WebApp` for a context; `children` are the global overlay slot |
|
|
204
|
+
| `render(node, opts?)` | function | Mount any React node with the same readiness, root and hydration handling |
|
|
205
|
+
| `WebApp` | component | `I18nContext` + `@owlmeans/client`'s `App` (context provider and router) |
|
|
206
|
+
| `RenderOptions` | type | `domId?`, `onReady?`, `hydrate?`, `debug?` |
|
|
207
|
+
| `AppConfig`, `AppContext` | type | Web config and context; the context carries `auth()` and `login()` |
|
|
208
|
+
| `DEFAULT_ROOT` | const | `'root'` — the default mount element id |
|
|
209
|
+
| `extractPrimaryHost(context)` | function | Copy `window.location` host and port into `cfg.primaryHost` / `cfg.primaryPort` |
|
|
210
|
+
| `makeAuthWebService(alias?)` | function | `@owlmeans/client-auth`'s service whose `update(undefined)` redirects to the dispatcher |
|
|
211
|
+
| `appendWebAuthService(ctx, alias?)` | function | Register that service, `authMiddleware` and the session-loss middleware; expose `context.auth()` |
|
|
212
|
+
| `provide` | const | Deprecated, always `undefined`; routing resolves its compiler from the active router plugin |
|
|
213
|
+
| `UploaderError`, `FileUploadingError` | class | Uploader errors registered with `ResilientError` |
|
|
57
214
|
|
|
58
|
-
|
|
215
|
+
### Entrypoints and authentication screens
|
|
59
216
|
|
|
60
|
-
|
|
217
|
+
| Symbol | Kind | Purpose |
|
|
218
|
+
|---|---|---|
|
|
219
|
+
| `entrypoints` | const | Framework bindings: client-auth's list, the dispatcher screen, the surrogate screen |
|
|
220
|
+
| `Dispatcher` | component | The dispatcher screen, built on `DispatcherHOC` |
|
|
221
|
+
| `parametriseDispatcher(defaults, Component?)` | function | A dispatcher with default props (`payload`, `alias`, ...) merged under the routed ones |
|
|
222
|
+
| `ParametrisedProps` | type | Routed entrypoint params plus `DispatcherProps` |
|
|
223
|
+
| `SurrogateScreen` | component | The screen the surrogate login window renders |
|
|
224
|
+
| `LoginSurrogateView`, `SurrogateStage`, `SurrogateViewProps` | component, enum, type | The surrogate window's status panel |
|
|
225
|
+
| `appendWebLogin(ctx)` | function | `appendLogin` plus the redirect and surrogate plugins |
|
|
226
|
+
| `makeRedirectLoginPlugin()`, `makeSurrogateLoginPlugin()` | function | The two shipped `LoginPlugin`s |
|
|
227
|
+
| `awaitSurrogate(...)` | function | The single waiter for a surrogate window's hand-back |
|
|
228
|
+
| `REDIRECT_LOGIN`, `SURROGATE_LOGIN`, `SURROGATE_LOGIN_PRIORITY` | const | Plugin aliases and the surrogate plugin's priority |
|
|
61
229
|
|
|
62
|
-
|
|
230
|
+
### Components
|
|
63
231
|
|
|
64
|
-
|
|
232
|
+
| Symbol | Kind | Purpose |
|
|
233
|
+
|---|---|---|
|
|
234
|
+
| `Uploader` | component | `react-dropzone` root with a hidden input |
|
|
235
|
+
| `ImageUploader` | component | `Uploader` with a 5 MB `maxSize` default |
|
|
236
|
+
| `UploaderProps`, `UploaderRootProps` | type | Uploader props |
|
|
65
237
|
|
|
66
|
-
|
|
238
|
+
### Re-exports
|
|
67
239
|
|
|
68
|
-
|
|
240
|
+
| Symbol | From |
|
|
241
|
+
|---|---|
|
|
242
|
+
| `handler` | `@owlmeans/client` |
|
|
243
|
+
| `config` | `@owlmeans/client-context` |
|
|
244
|
+
| `service` | `@owlmeans/config` |
|
|
245
|
+
| `AppType`, `HOME`, `ROOT`, `BASE`, `GUEST` | `@owlmeans/context` |
|
|
246
|
+
| `bind`, `bindAll`, `bindScreen`, `provideRequest`, `stab`; types `ClientEntrypoint`, `Module` | `@owlmeans/client-entrypoint` |
|
|
247
|
+
| `route`, `frontend` | `@owlmeans/route` |
|
|
248
|
+
| type `Route` (`ClientRoute`) | `@owlmeans/client-route` |
|
|
69
249
|
|
|
70
|
-
|
|
250
|
+
Importing the package also registers the `auth` library i18n namespace in all seven supported
|
|
251
|
+
languages.
|
|
71
252
|
|
|
72
|
-
|
|
253
|
+
## Common pitfalls
|
|
73
254
|
|
|
74
|
-
|
|
255
|
+
- **Register entrypoints before rendering.** The router builds its route table once, from the
|
|
256
|
+
entrypoints the context knows about at mount.
|
|
257
|
+
- **Spread the framework `entrypoints`.** Without them there is no dispatcher and no surrogate
|
|
258
|
+
screen, and `useLogin` throws `Entrypoint dispatcher not found`.
|
|
259
|
+
- **Bind every API declaration the browser calls, including route parents.** An unbound protocol
|
|
260
|
+
throws "entrypoint not found" before any network call — `bindAll` over the whole tree avoids it.
|
|
261
|
+
- **A parent frontend route needs a `default: true` child**, or it renders blank at its own path.
|
|
262
|
+
- **Do not pass `provide`.** Routing comes from the router plugin (`@owlmeans/web-router` by
|
|
263
|
+
default); react-router is only available through the opt-in `@owlmeans/web-router-react-router`.
|
|
264
|
+
- **Mount dialogs and toast surfaces in the overlay slot**, not inside a route — a dialog inside a
|
|
265
|
+
route closes itself on the first navigation.
|
|
266
|
+
- **Use `renderApp`, not a hand-rolled `createRoot` in `StrictMode`.** The readiness handling is
|
|
267
|
+
lost, and `StrictMode` double-invokes effects the router is not written for.
|
|
268
|
+
- **`onReady` waits for `DOMContentLoaded`.** A script that runs after the event has fired must pass
|
|
269
|
+
`{ onReady: false }`, or nothing mounts.
|
|
270
|
+
- **One context per page, from one factory.** Build it with `makeContext` plus `append*` mixins;
|
|
271
|
+
never create a second context for a component tree.
|
|
75
272
|
|
|
76
|
-
## Related
|
|
273
|
+
## Related packages
|
|
77
274
|
|
|
275
|
+
- [`@owlmeans/web-panel`](../web-panel) — shadcn/Tailwind panel layer built on this context
|
|
78
276
|
- [`@owlmeans/client-context`](../client-context) — `ClientContext` base extended by `makeContext`
|
|
79
|
-
- [`@owlmeans/
|
|
277
|
+
- [`@owlmeans/client-entrypoint`](../client-entrypoint) — protocol binding and `call` / `invoke` / `url`
|
|
278
|
+
- [`@owlmeans/client-auth`](../client-auth) — auth service, dispatcher HOC and login host
|
|
279
|
+
- [`@owlmeans/web-router`](../web-router) — default OwlMeans browser routing plugin
|
|
280
|
+
- [`@owlmeans/web-router-react-router`](../web-router-react-router) — opt-in react-router plugin
|
|
80
281
|
- [`@owlmeans/web-db`](../web-db) — IndexedDB service registered by `makeContext`
|
|
81
|
-
- [`@owlmeans/
|
|
282
|
+
- [`@owlmeans/web-oidc-rp`](../web-oidc-rp) — OIDC relying party with its own dispatcher
|
|
82
283
|
|
|
83
284
|
<!-- owlmeans:agent-guidance:start -->
|
|
84
285
|
## Agent guidance
|
package/agent-meta/manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"package": "@owlmeans/web-client",
|
|
4
|
-
"version": "0.1.18-rc.
|
|
5
|
-
"generatedAt": "2026-09-
|
|
4
|
+
"version": "0.1.18-rc.33",
|
|
5
|
+
"generatedAt": "2026-09-14T19:17:35.193Z",
|
|
6
6
|
"canonicalRepo": "https://github.com/owlmeans/common",
|
|
7
7
|
"entries": [
|
|
8
8
|
{
|
|
@@ -6,27 +6,27 @@ description: Bind OwlMeans shared entrypoint protocols in a browser application.
|
|
|
6
6
|
|
|
7
7
|
# Browser protocol entrypoints
|
|
8
8
|
|
|
9
|
-
**Install:** `bun add @owlmeans/web-client@^0.1.18-rc.
|
|
9
|
+
**Install:** `bun add @owlmeans/web-client@^0.1.18-rc.32`
|
|
10
10
|
|
|
11
11
|
Shared protocol declarations are immutable. Bind a complete protocol tree for callable API routes,
|
|
12
12
|
then bind frontend declarations to screens.
|
|
13
13
|
|
|
14
14
|
```ts
|
|
15
15
|
import { bindAll, bindScreen, entrypoints as frameworkEntrypoints, handler } from '@owlmeans/web-panel'
|
|
16
|
-
import {
|
|
16
|
+
import { projectProtocols, webProtocols } from 'project-common'
|
|
17
17
|
import { ProjectScreen } from './screens/project.js'
|
|
18
18
|
|
|
19
|
-
export const
|
|
19
|
+
export const clientBindings = [
|
|
20
20
|
...frameworkEntrypoints,
|
|
21
|
-
...bindAll(
|
|
22
|
-
bindScreen(
|
|
21
|
+
...bindAll(projectProtocols),
|
|
22
|
+
bindScreen(webProtocols.project, handler(ProjectScreen)),
|
|
23
23
|
]
|
|
24
24
|
```
|
|
25
25
|
|
|
26
26
|
Call a protocol directly. Its request and response types come from its shared contract.
|
|
27
27
|
|
|
28
28
|
```ts
|
|
29
|
-
const project = await context.entrypoint(
|
|
29
|
+
const project = await context.entrypoint(projectProtocols.get).call({
|
|
30
30
|
params: { id: projectId },
|
|
31
31
|
})
|
|
32
32
|
```
|
package/build/entrypoints.d.ts
CHANGED
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
*/
|
|
9
9
|
export declare const entrypoints: (import("@owlmeans/client-entrypoint").ClientProtocolEntrypoint<import("@owlmeans/entrypoint").EntrypointProtocol<{
|
|
10
10
|
query: import("@owlmeans/auth").AuthToken;
|
|
11
|
-
},
|
|
11
|
+
}, undefined>> | import("@owlmeans/client-entrypoint").ClientProtocolEntrypoint<import("@owlmeans/entrypoint").EntrypointProtocol<{
|
|
12
12
|
query: import("@owlmeans/auth-common").SurrogateQuery;
|
|
13
|
-
},
|
|
13
|
+
}, undefined>> | import("@owlmeans/client-entrypoint").ClientProtocolEntrypoint<import("@owlmeans/entrypoint").EntrypointProtocol<{
|
|
14
14
|
body: import("@owlmeans/auth").AuthToken;
|
|
15
15
|
}, import("@owlmeans/auth").AuthToken>>)[];
|
|
16
16
|
//# sourceMappingURL=entrypoints.d.ts.map
|
|
@@ -1,4 +1,20 @@
|
|
|
1
1
|
import type { LoginPlugin } from '@owlmeans/client-auth/login';
|
|
2
|
+
/**
|
|
3
|
+
* `window.open` features that size and CENTER the surrogate on the screen the browser window
|
|
4
|
+
* currently occupies.
|
|
5
|
+
*
|
|
6
|
+
* Computed fresh per call, never a constant: centering depends on where the browser window sits
|
|
7
|
+
* NOW, which can change between one login attempt and the next. `screenLeft`/`screenTop` and
|
|
8
|
+
* `outerWidth`/`outerHeight` describe the browser's own chrome rather than this document's
|
|
9
|
+
* viewport — and, unlike `innerWidth` or anything reached through `window.top`, they stay readable
|
|
10
|
+
* from a cross-origin iframe. That matters here specifically: a generated app's preview runs
|
|
11
|
+
* framed on a different origin than the manager that embeds it, so `env.embedded` is exactly the
|
|
12
|
+
* case where `window.top.innerWidth` would throw, and reading this document's own `innerWidth`
|
|
13
|
+
* instead would center the popup on the IFRAME's rectangle rather than on the actual window a
|
|
14
|
+
* person sees. Falling back to `window.screen` covers a window that reports no outer geometry at
|
|
15
|
+
* all (a headless test runner).
|
|
16
|
+
*/
|
|
17
|
+
export declare const centeredPopupFeatures: (width: number, height: number) => string;
|
|
2
18
|
/**
|
|
3
19
|
* Login for a document that cannot complete the flow where it is.
|
|
4
20
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"surrogate.d.ts","sourceRoot":"","sources":["../../src/login/surrogate.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"surrogate.d.ts","sourceRoot":"","sources":["../../src/login/surrogate.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAA0B,WAAW,EAAE,MAAM,6BAA6B,CAAA;AAItF;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,qBAAqB,UAAW,MAAM,UAAU,MAAM,KAAG,MAUrE,CAAA;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,wBAAwB,QAAO,WA4J3C,CAAA"}
|
package/build/login/surrogate.js
CHANGED
|
@@ -1,6 +1,30 @@
|
|
|
1
|
-
import { adoptToken, clearSurrogate, markSurrogate, revokeToken, surrogatePath, LoginIntent, LoginOutcome, LOGIN_LOGOUT_MESSAGE,
|
|
1
|
+
import { adoptToken, clearSurrogate, markSurrogate, revokeToken, surrogatePath, LoginIntent, LoginOutcome, LOGIN_LOGOUT_MESSAGE, LOGIN_SURROGATE_HEIGHT, LOGIN_SURROGATE_NAME, LOGIN_SURROGATE_WIDTH, LOGIN_TOKEN_MESSAGE, } from '@owlmeans/client-auth/login';
|
|
2
2
|
import { SURROGATE_LOGIN, SURROGATE_LOGIN_PRIORITY } from './consts.js';
|
|
3
3
|
import { awaitSurrogate } from './pump.js';
|
|
4
|
+
/**
|
|
5
|
+
* `window.open` features that size and CENTER the surrogate on the screen the browser window
|
|
6
|
+
* currently occupies.
|
|
7
|
+
*
|
|
8
|
+
* Computed fresh per call, never a constant: centering depends on where the browser window sits
|
|
9
|
+
* NOW, which can change between one login attempt and the next. `screenLeft`/`screenTop` and
|
|
10
|
+
* `outerWidth`/`outerHeight` describe the browser's own chrome rather than this document's
|
|
11
|
+
* viewport — and, unlike `innerWidth` or anything reached through `window.top`, they stay readable
|
|
12
|
+
* from a cross-origin iframe. That matters here specifically: a generated app's preview runs
|
|
13
|
+
* framed on a different origin than the manager that embeds it, so `env.embedded` is exactly the
|
|
14
|
+
* case where `window.top.innerWidth` would throw, and reading this document's own `innerWidth`
|
|
15
|
+
* instead would center the popup on the IFRAME's rectangle rather than on the actual window a
|
|
16
|
+
* person sees. Falling back to `window.screen` covers a window that reports no outer geometry at
|
|
17
|
+
* all (a headless test runner).
|
|
18
|
+
*/
|
|
19
|
+
export const centeredPopupFeatures = (width, height) => {
|
|
20
|
+
const screenLeft = window.screenLeft ?? window.screenX ?? 0;
|
|
21
|
+
const screenTop = window.screenTop ?? window.screenY ?? 0;
|
|
22
|
+
const outerWidth = window.outerWidth || window.screen.width;
|
|
23
|
+
const outerHeight = window.outerHeight || window.screen.height;
|
|
24
|
+
const left = Math.round(screenLeft + Math.max(0, (outerWidth - width) / 2));
|
|
25
|
+
const top = Math.round(screenTop + Math.max(0, (outerHeight - height) / 2));
|
|
26
|
+
return `popup=yes,width=${width},height=${height},left=${left},top=${top}`;
|
|
27
|
+
};
|
|
4
28
|
/**
|
|
5
29
|
* Login for a document that cannot complete the flow where it is.
|
|
6
30
|
*
|
|
@@ -66,9 +90,10 @@ export const makeSurrogateLoginPlugin = () => {
|
|
|
66
90
|
const path = surrogatePath(ctx, {
|
|
67
91
|
intent: LoginIntent.Login, next: request.url,
|
|
68
92
|
});
|
|
69
|
-
const surrogate = window.open(path ?? request.url, LOGIN_SURROGATE_NAME,
|
|
93
|
+
const surrogate = window.open(path ?? request.url, LOGIN_SURROGATE_NAME, centeredPopupFeatures(LOGIN_SURROGATE_WIDTH, LOGIN_SURROGATE_HEIGHT));
|
|
70
94
|
if (surrogate == null) {
|
|
71
|
-
|
|
95
|
+
// Distinct from `Failed` below: nothing ever opened, so there is nothing left to wait on.
|
|
96
|
+
return Promise.resolve(LoginOutcome.Blocked);
|
|
72
97
|
}
|
|
73
98
|
return awaitSurrogate(surrogate, LOGIN_TOKEN_MESSAGE, async (data) => {
|
|
74
99
|
if (data.token == null || data.token === '') {
|
|
@@ -109,16 +134,20 @@ export const makeSurrogateLoginPlugin = () => {
|
|
|
109
134
|
const path = surrogatePath(ctx, { intent: LoginIntent.Logout });
|
|
110
135
|
// Opened first and synchronously, for the same reason `begin` does.
|
|
111
136
|
const surrogate = path != null
|
|
112
|
-
? window.open(path, LOGIN_SURROGATE_NAME,
|
|
137
|
+
? window.open(path, LOGIN_SURROGATE_NAME, centeredPopupFeatures(LOGIN_SURROGATE_WIDTH, LOGIN_SURROGATE_HEIGHT))
|
|
113
138
|
: null;
|
|
114
139
|
// The local session goes next, and UNCONDITIONALLY. A blocked window, a severed opener or a
|
|
115
140
|
// user who closes the popup must never leave THIS document signed in: a logout that only
|
|
116
141
|
// half happened is bad, and one that did not happen at all is worse.
|
|
117
142
|
const local = revokeToken(ctx);
|
|
118
143
|
if (surrogate == null) {
|
|
144
|
+
// `path == null` is an app whose entrypoint list predates the surrogate route — nothing a
|
|
145
|
+
// retry would do differently. A `window.open` the browser refused is the one case worth
|
|
146
|
+
// telling the user about, so only that one reports `Blocked`.
|
|
147
|
+
const outcome = path != null ? LoginOutcome.Blocked : LoginOutcome.Failed;
|
|
119
148
|
return local.then(async () => {
|
|
120
149
|
await request.navigate?.();
|
|
121
|
-
return
|
|
150
|
+
return outcome;
|
|
122
151
|
});
|
|
123
152
|
}
|
|
124
153
|
return local.then(async () => await awaitSurrogate(surrogate, LOGIN_LOGOUT_MESSAGE, async () => {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"surrogate.js","sourceRoot":"","sources":["../../src/login/surrogate.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,UAAU,EAAE,cAAc,EAAE,aAAa,EAAE,WAAW,EAAE,aAAa,EAAE,WAAW,EAAE,YAAY,EAChG,oBAAoB,EAAE,
|
|
1
|
+
{"version":3,"file":"surrogate.js","sourceRoot":"","sources":["../../src/login/surrogate.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@owlmeans/web-client",
|
|
3
|
-
"version": "0.1.18-rc.
|
|
3
|
+
"version": "0.1.18-rc.33",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"@owlmeans/auth": "^0.1.18-rc.17",
|
|
25
25
|
"@owlmeans/auth-common": "^0.1.18-rc.21",
|
|
26
26
|
"@owlmeans/client": "^0.1.18-rc.24",
|
|
27
|
-
"@owlmeans/client-auth": "^0.1.18-rc.
|
|
27
|
+
"@owlmeans/client-auth": "^0.1.18-rc.29",
|
|
28
28
|
"@owlmeans/client-context": "^0.1.18-rc.21",
|
|
29
29
|
"@owlmeans/client-i18n": "^0.1.18-rc.23",
|
|
30
30
|
"@owlmeans/client-entrypoint": "^0.1.18-rc.21",
|
package/src/login/surrogate.ts
CHANGED
|
@@ -1,11 +1,39 @@
|
|
|
1
1
|
import {
|
|
2
2
|
adoptToken, clearSurrogate, markSurrogate, revokeToken, surrogatePath, LoginIntent, LoginOutcome,
|
|
3
|
-
LOGIN_LOGOUT_MESSAGE,
|
|
3
|
+
LOGIN_LOGOUT_MESSAGE, LOGIN_SURROGATE_HEIGHT, LOGIN_SURROGATE_NAME, LOGIN_SURROGATE_WIDTH,
|
|
4
|
+
LOGIN_TOKEN_MESSAGE,
|
|
4
5
|
} from '@owlmeans/client-auth/login'
|
|
5
6
|
import type { LoginContext, LoginEnv, LoginPlugin } from '@owlmeans/client-auth/login'
|
|
6
7
|
import { SURROGATE_LOGIN, SURROGATE_LOGIN_PRIORITY } from './consts.js'
|
|
7
8
|
import { awaitSurrogate } from './pump.js'
|
|
8
9
|
|
|
10
|
+
/**
|
|
11
|
+
* `window.open` features that size and CENTER the surrogate on the screen the browser window
|
|
12
|
+
* currently occupies.
|
|
13
|
+
*
|
|
14
|
+
* Computed fresh per call, never a constant: centering depends on where the browser window sits
|
|
15
|
+
* NOW, which can change between one login attempt and the next. `screenLeft`/`screenTop` and
|
|
16
|
+
* `outerWidth`/`outerHeight` describe the browser's own chrome rather than this document's
|
|
17
|
+
* viewport — and, unlike `innerWidth` or anything reached through `window.top`, they stay readable
|
|
18
|
+
* from a cross-origin iframe. That matters here specifically: a generated app's preview runs
|
|
19
|
+
* framed on a different origin than the manager that embeds it, so `env.embedded` is exactly the
|
|
20
|
+
* case where `window.top.innerWidth` would throw, and reading this document's own `innerWidth`
|
|
21
|
+
* instead would center the popup on the IFRAME's rectangle rather than on the actual window a
|
|
22
|
+
* person sees. Falling back to `window.screen` covers a window that reports no outer geometry at
|
|
23
|
+
* all (a headless test runner).
|
|
24
|
+
*/
|
|
25
|
+
export const centeredPopupFeatures = (width: number, height: number): string => {
|
|
26
|
+
const screenLeft = window.screenLeft ?? window.screenX ?? 0
|
|
27
|
+
const screenTop = window.screenTop ?? window.screenY ?? 0
|
|
28
|
+
const outerWidth = window.outerWidth || window.screen.width
|
|
29
|
+
const outerHeight = window.outerHeight || window.screen.height
|
|
30
|
+
|
|
31
|
+
const left = Math.round(screenLeft + Math.max(0, (outerWidth - width) / 2))
|
|
32
|
+
const top = Math.round(screenTop + Math.max(0, (outerHeight - height) / 2))
|
|
33
|
+
|
|
34
|
+
return `popup=yes,width=${width},height=${height},left=${left},top=${top}`
|
|
35
|
+
}
|
|
36
|
+
|
|
9
37
|
/**
|
|
10
38
|
* Login for a document that cannot complete the flow where it is.
|
|
11
39
|
*
|
|
@@ -79,10 +107,12 @@ export const makeSurrogateLoginPlugin = (): LoginPlugin => {
|
|
|
79
107
|
intent: LoginIntent.Login, next: request.url,
|
|
80
108
|
})
|
|
81
109
|
const surrogate = window.open(
|
|
82
|
-
path ?? request.url, LOGIN_SURROGATE_NAME,
|
|
110
|
+
path ?? request.url, LOGIN_SURROGATE_NAME,
|
|
111
|
+
centeredPopupFeatures(LOGIN_SURROGATE_WIDTH, LOGIN_SURROGATE_HEIGHT)
|
|
83
112
|
)
|
|
84
113
|
if (surrogate == null) {
|
|
85
|
-
|
|
114
|
+
// Distinct from `Failed` below: nothing ever opened, so there is nothing left to wait on.
|
|
115
|
+
return Promise.resolve(LoginOutcome.Blocked)
|
|
86
116
|
}
|
|
87
117
|
|
|
88
118
|
return awaitSurrogate(surrogate, LOGIN_TOKEN_MESSAGE, async data => {
|
|
@@ -132,7 +162,8 @@ export const makeSurrogateLoginPlugin = (): LoginPlugin => {
|
|
|
132
162
|
const path = surrogatePath(ctx as LoginContext, { intent: LoginIntent.Logout })
|
|
133
163
|
// Opened first and synchronously, for the same reason `begin` does.
|
|
134
164
|
const surrogate = path != null
|
|
135
|
-
? window.open(path, LOGIN_SURROGATE_NAME,
|
|
165
|
+
? window.open(path, LOGIN_SURROGATE_NAME,
|
|
166
|
+
centeredPopupFeatures(LOGIN_SURROGATE_WIDTH, LOGIN_SURROGATE_HEIGHT))
|
|
136
167
|
: null
|
|
137
168
|
|
|
138
169
|
// The local session goes next, and UNCONDITIONALLY. A blocked window, a severed opener or a
|
|
@@ -140,10 +171,15 @@ export const makeSurrogateLoginPlugin = (): LoginPlugin => {
|
|
|
140
171
|
// half happened is bad, and one that did not happen at all is worse.
|
|
141
172
|
const local = revokeToken(ctx as LoginContext)
|
|
142
173
|
if (surrogate == null) {
|
|
174
|
+
// `path == null` is an app whose entrypoint list predates the surrogate route — nothing a
|
|
175
|
+
// retry would do differently. A `window.open` the browser refused is the one case worth
|
|
176
|
+
// telling the user about, so only that one reports `Blocked`.
|
|
177
|
+
const outcome = path != null ? LoginOutcome.Blocked : LoginOutcome.Failed
|
|
178
|
+
|
|
143
179
|
return local.then(async () => {
|
|
144
180
|
await request.navigate?.()
|
|
145
181
|
|
|
146
|
-
return
|
|
182
|
+
return outcome
|
|
147
183
|
})
|
|
148
184
|
}
|
|
149
185
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"surrogate.spec.d.ts","sourceRoot":"","sources":["surrogate.spec.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, test, expect, afterEach } from 'bun:test';
|
|
2
|
+
import { centeredPopupFeatures } from '../src/login/surrogate.js';
|
|
3
|
+
/**
|
|
4
|
+
* A hand-written `window`, not jsdom: the function under test reads exactly four geometry
|
|
5
|
+
* properties, and a real DOM would prove a fixture behaves rather than that the math does.
|
|
6
|
+
*/
|
|
7
|
+
const stubWindow = (over) => {
|
|
8
|
+
globalThis.window = {
|
|
9
|
+
screenLeft: 0, screenTop: 0, screenX: 0, screenY: 0,
|
|
10
|
+
outerWidth: 1920, outerHeight: 1080,
|
|
11
|
+
screen: { width: 1920, height: 1080 },
|
|
12
|
+
...over,
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
delete globalThis.window;
|
|
17
|
+
});
|
|
18
|
+
describe('centeredPopupFeatures', () => {
|
|
19
|
+
test('centers on the browser window, at its current screen position', () => {
|
|
20
|
+
stubWindow({ screenLeft: 100, screenTop: 50, outerWidth: 1200, outerHeight: 800 });
|
|
21
|
+
expect(centeredPopupFeatures(520, 760)).toBe('popup=yes,width=520,height=760,left=440,top=70');
|
|
22
|
+
});
|
|
23
|
+
// A cross-origin iframe: `window.top.innerWidth` would throw, which is exactly why this reads
|
|
24
|
+
// `outerWidth`/`outerHeight` instead — they describe the browser's own chrome, not this
|
|
25
|
+
// document's viewport, and stay readable across the origin boundary.
|
|
26
|
+
test('falls back to screenX/screenY when the legacy aliases are unavailable', () => {
|
|
27
|
+
stubWindow({
|
|
28
|
+
screenLeft: undefined, screenTop: undefined, screenX: 20, screenY: 10,
|
|
29
|
+
outerWidth: 1200, outerHeight: 800,
|
|
30
|
+
});
|
|
31
|
+
expect(centeredPopupFeatures(520, 760)).toBe('popup=yes,width=520,height=760,left=360,top=30');
|
|
32
|
+
});
|
|
33
|
+
test('falls back to window.screen when outerWidth/outerHeight report zero', () => {
|
|
34
|
+
// A headless runner: no chrome geometry to report at all.
|
|
35
|
+
stubWindow({ outerWidth: 0, outerHeight: 0, screen: { width: 1600, height: 900 } });
|
|
36
|
+
expect(centeredPopupFeatures(520, 760)).toBe('popup=yes,width=520,height=760,left=540,top=70');
|
|
37
|
+
});
|
|
38
|
+
test('never places the popup off-screen when it is larger than the window', () => {
|
|
39
|
+
stubWindow({ outerWidth: 400, outerHeight: 400 });
|
|
40
|
+
expect(centeredPopupFeatures(520, 760)).toBe('popup=yes,width=520,height=760,left=0,top=0');
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
//# sourceMappingURL=surrogate.spec.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"surrogate.spec.js","sourceRoot":"","sources":["surrogate.spec.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,UAAU,CAAA;AAC5D,OAAO,EAAE,qBAAqB,EAAE,MAAM,2BAA2B,CAAA;AAEjE;;;GAGG;AACH,MAAM,UAAU,GAAG,CAAC,IAA6B,EAAQ,EAAE;IACxD,UAA6C,CAAC,MAAM,GAAG;QACtD,UAAU,EAAE,CAAC,EAAE,SAAS,EAAE,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE,OAAO,EAAE,CAAC;QACnD,UAAU,EAAE,IAAI,EAAE,WAAW,EAAE,IAAI;QACnC,MAAM,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE;QACrC,GAAG,IAAI;KACR,CAAA;AACH,CAAC,CAAA;AAED,SAAS,CAAC,GAAG,EAAE;IACb,OAAQ,UAAmC,CAAC,MAAM,CAAA;AACpD,CAAC,CAAC,CAAA;AAEF,QAAQ,CAAC,uBAAuB,EAAE,GAAG,EAAE;IACrC,IAAI,CAAC,+DAA+D,EAAE,GAAG,EAAE;QACzE,UAAU,CAAC,EAAE,UAAU,EAAE,GAAG,EAAE,SAAS,EAAE,EAAE,EAAE,UAAU,EAAE,IAAI,EAAE,WAAW,EAAE,GAAG,EAAE,CAAC,CAAA;QAElF,MAAM,CAAC,qBAAqB,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,gDAAgD,CAAC,CAAA;IAChG,CAAC,CAAC,CAAA;IAEF,8FAA8F;IAC9F,wFAAwF;IACxF,qEAAqE;IACrE,IAAI,CAAC,uEAAuE,EAAE,GAAG,EAAE;QACjF,UAAU,CAAC;YACT,UAAU,EAAE,SAAS,EAAE,SAAS,EAAE,SAAS,EAAE,OAAO,EAAE,EAAE,EAAE,OAAO,EAAE,EAAE;YACrE,UAAU,EAAE,IAAI,EAAE,WAAW,EAAE,GAAG;SACnC,CAAC,CAAA;QAEF,MAAM,CAAC,qBAAqB,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,gDAAgD,CAAC,CAAA;IAChG,CAAC,CAAC,CAAA;IAEF,IAAI,CAAC,qEAAqE,EAAE,GAAG,EAAE;QAC/E,0DAA0D;QAC1D,UAAU,CAAC,EAAE,UAAU,EAAE,CAAC,EAAE,WAAW,EAAE,CAAC,EAAE,MAAM,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,EAAE,CAAC,CAAA;QAEnF,MAAM,CAAC,qBAAqB,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,gDAAgD,CAAC,CAAA;IAChG,CAAC,CAAC,CAAA;IAEF,IAAI,CAAC,qEAAqE,EAAE,GAAG,EAAE;QAC/E,UAAU,CAAC,EAAE,UAAU,EAAE,GAAG,EAAE,WAAW,EAAE,GAAG,EAAE,CAAC,CAAA;QAEjD,MAAM,CAAC,qBAAqB,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,6CAA6C,CAAC,CAAA;IAC7F,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, test, expect, afterEach } from 'bun:test'
|
|
2
|
+
import { centeredPopupFeatures } from '../src/login/surrogate.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A hand-written `window`, not jsdom: the function under test reads exactly four geometry
|
|
6
|
+
* properties, and a real DOM would prove a fixture behaves rather than that the math does.
|
|
7
|
+
*/
|
|
8
|
+
const stubWindow = (over: Record<string, unknown>): void => {
|
|
9
|
+
(globalThis as unknown as { window: unknown }).window = {
|
|
10
|
+
screenLeft: 0, screenTop: 0, screenX: 0, screenY: 0,
|
|
11
|
+
outerWidth: 1920, outerHeight: 1080,
|
|
12
|
+
screen: { width: 1920, height: 1080 },
|
|
13
|
+
...over,
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
delete (globalThis as { window?: unknown }).window
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
describe('centeredPopupFeatures', () => {
|
|
22
|
+
test('centers on the browser window, at its current screen position', () => {
|
|
23
|
+
stubWindow({ screenLeft: 100, screenTop: 50, outerWidth: 1200, outerHeight: 800 })
|
|
24
|
+
|
|
25
|
+
expect(centeredPopupFeatures(520, 760)).toBe('popup=yes,width=520,height=760,left=440,top=70')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
// A cross-origin iframe: `window.top.innerWidth` would throw, which is exactly why this reads
|
|
29
|
+
// `outerWidth`/`outerHeight` instead — they describe the browser's own chrome, not this
|
|
30
|
+
// document's viewport, and stay readable across the origin boundary.
|
|
31
|
+
test('falls back to screenX/screenY when the legacy aliases are unavailable', () => {
|
|
32
|
+
stubWindow({
|
|
33
|
+
screenLeft: undefined, screenTop: undefined, screenX: 20, screenY: 10,
|
|
34
|
+
outerWidth: 1200, outerHeight: 800,
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
expect(centeredPopupFeatures(520, 760)).toBe('popup=yes,width=520,height=760,left=360,top=30')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('falls back to window.screen when outerWidth/outerHeight report zero', () => {
|
|
41
|
+
// A headless runner: no chrome geometry to report at all.
|
|
42
|
+
stubWindow({ outerWidth: 0, outerHeight: 0, screen: { width: 1600, height: 900 } })
|
|
43
|
+
|
|
44
|
+
expect(centeredPopupFeatures(520, 760)).toBe('popup=yes,width=520,height=760,left=540,top=70')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('never places the popup off-screen when it is larger than the window', () => {
|
|
48
|
+
stubWindow({ outerWidth: 400, outerHeight: 400 })
|
|
49
|
+
|
|
50
|
+
expect(centeredPopupFeatures(520, 760)).toBe('popup=yes,width=520,height=760,left=0,top=0')
|
|
51
|
+
})
|
|
52
|
+
})
|
package/tsconfig.json
CHANGED