@owlmeans/web-client 0.1.18-rc.32 → 0.1.18-rc.34

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 CHANGED
@@ -1,84 +1,285 @@
1
1
  # @owlmeans/web-client
2
2
 
3
- React web application bootstrap — context factory, entrypoint/routing utilities, and app renderer for OwlMeans web apps.
4
-
5
- ## Overview
6
-
7
- - `makeContext(cfg)` — creates the web application context (includes auth, router, and DB services)
8
- - `renderApp(context)` — mounts the React app to the DOM
9
- - Re-exports entrypoint/routing helpers: `bind`, `bindAll`, `bindScreen`, `route`, `frontend`, `handler`
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.23
14
+ bun add @owlmeans/web-client@^0.1.18-rc.34
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
- Bootstrap the app:
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
- ```typescript
26
- import { makeContext, renderApp } from '@owlmeans/web-client'
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(MANAGER, true)
31
- renderApp<Config, Context>(context)
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
- Define entrypoints:
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
- ```typescript
37
- import { bindScreen, bindAll } from '@owlmeans/client-entrypoint'
38
- import { handler } from '@owlmeans/client'
39
- import { appEntrypoints as protocols } from 'my-app-common'
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
- appEntrypoints.push(bindScreen(protocols.web.base, handler(PublicLayout)))
42
- appEntrypoints.push(bindScreen(protocols.web.home, handler(HomeScreen)))
43
- appEntrypoints.push(...bindAll(protocols.api))
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
- A screen entrypoint carries a renderer, so it is addressed by `url()` and never called over the
47
- wire. Call an API entrypoint from a component:
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
- ```typescript
50
- const result = await context.entrypoint(appEntrypoints.api.project)
51
- .call({ params: { id }, body: data })
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
- ### `makeContext<C, T>(cfg): T`
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
- Creates a web app context. Automatically registers web router, IndexedDB, and auth services.
215
+ ### Entrypoints and authentication screens
59
216
 
60
- ### `renderApp<C, T>(context): void`
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
- Renders the React application to the DOM using the provided context.
230
+ ### Components
63
231
 
64
- ### `useContext<C, T>(): T`
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
- React hook to access the current application context.
238
+ ### Re-exports
67
239
 
68
- ### `makeAuthWebService(alias?)`, `appendWebAuthService(ctx, alias?)`
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
- Auth service factory and appender — adds logout redirect handling for web environments.
250
+ Importing the package also registers the `auth` library i18n namespace in all seven supported
251
+ languages.
71
252
 
72
- ### `Dispatcher`
253
+ ## Common pitfalls
73
254
 
74
- Component that handles authentication routing (redirect to login, etc.).
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 Packages
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/web-router`](../web-router) — default OwlMeans browser routing plugin registered by `makeContext`
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/client-entrypoint`](../client-entrypoint) — protocol binding helpers
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
@@ -88,7 +289,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
88
289
  your project's skill store (`.agents/skills/`):
89
290
 
90
291
  ```sh
91
- npx @owlmeans/agent-skills@^0.1.18-rc.20
292
+ npx @owlmeans/agent-skills@^0.1.18-rc.21
92
293
  ```
93
294
 
94
295
  The embedded files are version-matched to this package release. Do not edit them
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@owlmeans/web-client",
4
- "version": "0.1.18-rc.32",
5
- "generatedAt": "2026-09-12T14:18:54.789Z",
4
+ "version": "0.1.18-rc.34",
5
+ "generatedAt": "2026-09-15T12:21:31.920Z",
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.30`
9
+ **Install:** `bun add @owlmeans/web-client@^0.1.18-rc.34`
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 { projectEntrypoints, webEntrypoints } from 'project-common'
16
+ import { projectProtocols, webProtocols } from 'project-common'
17
17
  import { ProjectScreen } from './screens/project.js'
18
18
 
19
- export const appEntrypoints = [
19
+ export const clientBindings = [
20
20
  ...frameworkEntrypoints,
21
- ...bindAll(projectEntrypoints),
22
- bindScreen(webEntrypoints.project, handler(ProjectScreen)),
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(projectEntrypoints.get).call({
29
+ const project = await context.entrypoint(projectProtocols.get).call({
30
30
  params: { id: projectId },
31
31
  })
32
32
  ```
@@ -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
- }, import("@owlmeans/entrypoint").OpenValue>> | import("@owlmeans/client-entrypoint").ClientProtocolEntrypoint<import("@owlmeans/entrypoint").EntrypointProtocol<{
11
+ }, undefined>> | import("@owlmeans/client-entrypoint").ClientProtocolEntrypoint<import("@owlmeans/entrypoint").EntrypointProtocol<{
12
12
  query: import("@owlmeans/auth-common").SurrogateQuery;
13
- }, import("@owlmeans/entrypoint").OpenValue>> | import("@owlmeans/client-entrypoint").ClientProtocolEntrypoint<import("@owlmeans/entrypoint").EntrypointProtocol<{
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":"AAIA,OAAO,KAAK,EAA0B,WAAW,EAAE,MAAM,6BAA6B,CAAA;AAItF;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,wBAAwB,QAAO,WAoJ3C,CAAA"}
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"}
@@ -1,6 +1,30 @@
1
- import { adoptToken, clearSurrogate, markSurrogate, revokeToken, surrogatePath, LoginIntent, LoginOutcome, LOGIN_LOGOUT_MESSAGE, LOGIN_SURROGATE_FEATURES, LOGIN_SURROGATE_NAME, LOGIN_TOKEN_MESSAGE, } from '@owlmeans/client-auth/login';
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, LOGIN_SURROGATE_FEATURES);
93
+ const surrogate = window.open(path ?? request.url, LOGIN_SURROGATE_NAME, centeredPopupFeatures(LOGIN_SURROGATE_WIDTH, LOGIN_SURROGATE_HEIGHT));
70
94
  if (surrogate == null) {
71
- return Promise.resolve(LoginOutcome.Failed);
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, LOGIN_SURROGATE_FEATURES)
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 LoginOutcome.Failed;
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":"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"}
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,sBAAsB,EAAE,oBAAoB,EAAE,qBAAqB,EACzF,mBAAmB,GACpB,MAAM,6BAA6B,CAAA;AAEpC,OAAO,EAAE,eAAe,EAAE,wBAAwB,EAAE,MAAM,aAAa,CAAA;AACvE,OAAO,EAAE,cAAc,EAAE,MAAM,WAAW,CAAA;AAE1C;;;;;;;;;;;;;;GAcG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG,CAAC,KAAa,EAAE,MAAc,EAAU,EAAE;IAC7E,MAAM,UAAU,GAAG,MAAM,CAAC,UAAU,IAAI,MAAM,CAAC,OAAO,IAAI,CAAC,CAAA;IAC3D,MAAM,SAAS,GAAG,MAAM,CAAC,SAAS,IAAI,MAAM,CAAC,OAAO,IAAI,CAAC,CAAA;IACzD,MAAM,UAAU,GAAG,MAAM,CAAC,UAAU,IAAI,MAAM,CAAC,MAAM,CAAC,KAAK,CAAA;IAC3D,MAAM,WAAW,GAAG,MAAM,CAAC,WAAW,IAAI,MAAM,CAAC,MAAM,CAAC,MAAM,CAAA;IAE9D,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,UAAU,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,UAAU,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAA;IAC3E,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,SAAS,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,WAAW,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAA;IAE3E,OAAO,mBAAmB,KAAK,WAAW,MAAM,SAAS,IAAI,QAAQ,GAAG,EAAE,CAAA;AAC5E,CAAC,CAAA;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,CAAC,MAAM,wBAAwB,GAAG,GAAgB,EAAE;IACxD;;;;;OAKG;IACH,MAAM,QAAQ,GAAG,KAAK,EAAE,KAAa,EAAE,GAAa,EAAyB,EAAE;QAC7E,IAAI,CAAC,GAAG,CAAC,SAAS,EAAE,CAAC;YACnB,0FAA0F;YAC1F,OAAO,YAAY,CAAC,MAAM,CAAA;QAC5B,CAAC;QACD,IAAI,CAAC,GAAG,CAAC,SAAS,EAAE,CAAC;YACnB,4FAA4F;YAC5F,yFAAyF;YACzF,OAAO,YAAY,CAAC,QAAQ,CAAA;QAC9B,CAAC;QAED,cAAc,EAAE,CAAA;QAChB,6FAA6F;QAC7F,uFAAuF;QACvF,MAAM,CAAC,MAAM,CAAC,WAAW,CAAC,EAAE,IAAI,EAAE,mBAAmB,EAAE,KAAK,EAAE,EAAE,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAA;QACvF,MAAM,CAAC,KAAK,EAAE,CAAA;QAEd,OAAO,YAAY,CAAC,OAAO,CAAA;IAC7B,CAAC,CAAA;IAED,MAAM,MAAM,GAAgB;QAC1B,KAAK,EAAE,eAAe;QACtB,QAAQ,EAAE,wBAAwB;QAClC,IAAI,EAAE,WAAW;QACjB,KAAK,EAAE,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,SAAS,IAAI,CAAC,GAAG,CAAC,QAAQ,IAAI,GAAG,CAAC,SAAS,CAAC;QAE9D,KAAK,EAAE,GAAG,EAAE,GAAG,aAAa,EAAE,CAAA,CAAC,CAAC;QAEhC,KAAK,EAAE,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,EAAE;YAC3B,8DAA8D;YAC9D,IAAI,GAAG,CAAC,SAAS,EAAE,CAAC;gBAClB,MAAM,CAAC,QAAQ,CAAC,IAAI,GAAG,OAAO,CAAC,GAAG,CAAA;gBAElC,OAAO,OAAO,CAAC,OAAO,CAAC,YAAY,CAAC,UAAU,CAAC,CAAA;YACjD,CAAC;YAED,yFAAyF;YACzF,2FAA2F;YAC3F,oCAAoC;YACpC,EAAE;YACF,2FAA2F;YAC3F,6FAA6F;YAC7F,8FAA8F;YAC9F,0EAA0E;YAC1E,MAAM,IAAI,GAAG,aAAa,CAAC,GAAmB,EAAE;gBAC9C,MAAM,EAAE,WAAW,CAAC,KAAK,EAAE,IAAI,EAAE,OAAO,CAAC,GAAG;aAC7C,CAAC,CAAA;YACF,MAAM,SAAS,GAAG,MAAM,CAAC,IAAI,CAC3B,IAAI,IAAI,OAAO,CAAC,GAAG,EAAE,oBAAoB,EACzC,qBAAqB,CAAC,qBAAqB,EAAE,sBAAsB,CAAC,CACrE,CAAA;YACD,IAAI,SAAS,IAAI,IAAI,EAAE,CAAC;gBACtB,0FAA0F;gBAC1F,OAAO,OAAO,CAAC,OAAO,CAAC,YAAY,CAAC,OAAO,CAAC,CAAA;YAC9C,CAAC;YAED,OAAO,cAAc,CAAC,SAAS,EAAE,mBAAmB,EAAE,KAAK,EAAC,IAAI,EAAC,EAAE;gBACjE,IAAI,IAAI,CAAC,KAAK,IAAI,IAAI,IAAI,IAAI,CAAC,KAAK,KAAK,EAAE,EAAE,CAAC;oBAC5C,OAAO,YAAY,CAAC,MAAM,CAAA;gBAC5B,CAAC;gBACD,MAAM,UAAU,CAAC,GAAmB,EAAE,IAAI,CAAC,KAAK,CAAC,CAAA;gBACjD,wFAAwF;gBACxF,wFAAwF;gBACxF,yFAAyF;gBACzF,yFAAyF;gBACzF,sEAAsE;gBACtE,MAAM,OAAO,CAAC,QAAQ,EAAE,EAAE,CAAA;gBAE1B,OAAO,YAAY,CAAC,OAAO,CAAA;YAC7B,CAAC,CAAC,CAAA;QACJ,CAAC;QAED,SAAS,EAAE,KAAK,EAAE,IAAI,EAAE,GAAG,EAAE,GAAG,EAAE,EAAE;YAClC,qEAAqE;YACrE,IAAI,GAAG,CAAC,SAAS,EAAE,CAAC;gBAClB,MAAM,CAAC,QAAQ,CAAC,IAAI,GAAG,GAAG,CAAA;gBAE1B,OAAO,YAAY,CAAC,UAAU,CAAA;YAChC,CAAC;YAED,yFAAyF;YACzF,6FAA6F;YAC7F,8DAA8D;YAC9D,OAAO,YAAY,CAAC,OAAO,CAAA;QAC7B,CAAC;QAED,QAAQ,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,EAAE,GAAG,EAAE,EAAE,CAAC,MAAM,QAAQ,CAAC,KAAK,EAAE,GAAG,CAAC;QAEhE,8FAA8F;QAC9F,0FAA0F;QAC1F,uFAAuF;QACvF,MAAM,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,EAAE,GAAG,EAAE,EAAE,CAAC,MAAM,QAAQ,CAAC,KAAK,EAAE,GAAG,CAAC;QAE9D,MAAM,EAAE,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,EAAE;YAC5B,oEAAoE;YACpE,IAAI,GAAG,CAAC,SAAS,EAAE,CAAC;gBAClB,OAAO,WAAW,CAAC,GAAmB,CAAC;qBACpC,IAAI,CAAC,KAAK,IAAI,EAAE,CAAC,MAAM,MAAM,CAAC,cAAe,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC,CAAA;YAC7D,CAAC;YAED,MAAM,IAAI,GAAG,aAAa,CAAC,GAAmB,EAAE,EAAE,MAAM,EAAE,WAAW,CAAC,MAAM,EAAE,CAAC,CAAA;YAC/E,oEAAoE;YACpE,MAAM,SAAS,GAAG,IAAI,IAAI,IAAI;gBAC5B,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,EAAE,oBAAoB,EACtC,qBAAqB,CAAC,qBAAqB,EAAE,sBAAsB,CAAC,CAAC;gBACvE,CAAC,CAAC,IAAI,CAAA;YAER,4FAA4F;YAC5F,yFAAyF;YACzF,qEAAqE;YACrE,MAAM,KAAK,GAAG,WAAW,CAAC,GAAmB,CAAC,CAAA;YAC9C,IAAI,SAAS,IAAI,IAAI,EAAE,CAAC;gBACtB,0FAA0F;gBAC1F,wFAAwF;gBACxF,8DAA8D;gBAC9D,MAAM,OAAO,GAAG,IAAI,IAAI,IAAI,CAAC,CAAC,CAAC,YAAY,CAAC,OAAO,CAAC,CAAC,CAAC,YAAY,CAAC,MAAM,CAAA;gBAEzE,OAAO,KAAK,CAAC,IAAI,CAAC,KAAK,IAAI,EAAE;oBAC3B,MAAM,OAAO,CAAC,QAAQ,EAAE,EAAE,CAAA;oBAE1B,OAAO,OAAO,CAAA;gBAChB,CAAC,CAAC,CAAA;YACJ,CAAC;YAED,OAAO,KAAK,CAAC,IAAI,CAAC,KAAK,IAAI,EAAE,CAAC,MAAM,cAAc,CAChD,SAAS,EAAE,oBAAoB,EAAE,KAAK,IAAI,EAAE;gBAC1C,MAAM,OAAO,CAAC,QAAQ,EAAE,EAAE,CAAA;gBAE1B,OAAO,YAAY,CAAC,OAAO,CAAA;YAC7B,CAAC,CACF,CAAC,CAAA;QACJ,CAAC;QAED,cAAc,EAAE,KAAK,EAAE,IAAI,EAAE,GAAG,EAAE,EAAE;YAClC,IAAI,CAAC,GAAG,CAAC,SAAS,EAAE,CAAC;gBACnB,OAAO,YAAY,CAAC,MAAM,CAAA;YAC5B,CAAC;YACD,IAAI,CAAC,GAAG,CAAC,SAAS,EAAE,CAAC;gBACnB,OAAO,YAAY,CAAC,QAAQ,CAAA;YAC9B,CAAC;YACD,cAAc,EAAE,CAAA;YAChB,MAAM,CAAC,MAAM,CAAC,WAAW,CAAC,EAAE,IAAI,EAAE,oBAAoB,EAAE,EAAE,EAAE,IAAI,EAAE,EAAE,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAA;YAC3F,MAAM,CAAC,KAAK,EAAE,CAAA;YAEd,OAAO,YAAY,CAAC,OAAO,CAAA;QAC7B,CAAC;KACF,CAAA;IAED,OAAO,MAAM,CAAA;AACf,CAAC,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owlmeans/web-client",
3
- "version": "0.1.18-rc.32",
3
+ "version": "0.1.18-rc.34",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -21,22 +21,22 @@
21
21
  }
22
22
  },
23
23
  "dependencies": {
24
- "@owlmeans/auth": "^0.1.18-rc.17",
25
- "@owlmeans/auth-common": "^0.1.18-rc.21",
26
- "@owlmeans/client": "^0.1.18-rc.24",
27
- "@owlmeans/client-auth": "^0.1.18-rc.28",
28
- "@owlmeans/client-context": "^0.1.18-rc.21",
29
- "@owlmeans/client-i18n": "^0.1.18-rc.23",
30
- "@owlmeans/client-entrypoint": "^0.1.18-rc.21",
31
- "@owlmeans/client-resource": "^0.1.18-rc.21",
32
- "@owlmeans/client-route": "^0.1.18-rc.21",
33
- "@owlmeans/config": "^0.1.18-rc.20",
34
- "@owlmeans/context": "^0.1.18-rc.16",
35
- "@owlmeans/error": "^0.1.18-rc.16",
36
- "@owlmeans/i18n": "^0.1.18-rc.16",
37
- "@owlmeans/route": "^0.1.18-rc.17",
38
- "@owlmeans/web-db": "^0.1.18-rc.22",
39
- "@owlmeans/web-router": "^0.1.18-rc.22",
24
+ "@owlmeans/auth": "^0.1.18-rc.18",
25
+ "@owlmeans/auth-common": "^0.1.18-rc.22",
26
+ "@owlmeans/client": "^0.1.18-rc.25",
27
+ "@owlmeans/client-auth": "^0.1.18-rc.30",
28
+ "@owlmeans/client-context": "^0.1.18-rc.22",
29
+ "@owlmeans/client-i18n": "^0.1.18-rc.24",
30
+ "@owlmeans/client-entrypoint": "^0.1.18-rc.22",
31
+ "@owlmeans/client-resource": "^0.1.18-rc.22",
32
+ "@owlmeans/client-route": "^0.1.18-rc.22",
33
+ "@owlmeans/config": "^0.1.18-rc.21",
34
+ "@owlmeans/context": "^0.1.18-rc.17",
35
+ "@owlmeans/error": "^0.1.18-rc.17",
36
+ "@owlmeans/i18n": "^0.1.18-rc.17",
37
+ "@owlmeans/route": "^0.1.18-rc.18",
38
+ "@owlmeans/web-db": "^0.1.18-rc.23",
39
+ "@owlmeans/web-router": "^0.1.18-rc.23",
40
40
  "react-dropzone": "^14.3.5"
41
41
  },
42
42
  "peerDependencies": {
@@ -1,11 +1,39 @@
1
1
  import {
2
2
  adoptToken, clearSurrogate, markSurrogate, revokeToken, surrogatePath, LoginIntent, LoginOutcome,
3
- LOGIN_LOGOUT_MESSAGE, LOGIN_SURROGATE_FEATURES, LOGIN_SURROGATE_NAME, LOGIN_TOKEN_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, LOGIN_SURROGATE_FEATURES
110
+ path ?? request.url, LOGIN_SURROGATE_NAME,
111
+ centeredPopupFeatures(LOGIN_SURROGATE_WIDTH, LOGIN_SURROGATE_HEIGHT)
83
112
  )
84
113
  if (surrogate == null) {
85
- return Promise.resolve(LoginOutcome.Failed)
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, LOGIN_SURROGATE_FEATURES)
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 LoginOutcome.Failed
182
+ return outcome
147
183
  })
148
184
  }
149
185
 
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=surrogate.spec.d.ts.map
@@ -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
@@ -7,5 +7,5 @@
7
7
  "rootDir": "./src/",
8
8
  "outDir": "./build/"
9
9
  },
10
- "exclude": ["./dist/**/*", "./build/**/*", "./*.ts"]
10
+ "exclude": ["./dist/**/*", "./build/**/*", "./tests/**/*", "./*.ts"]
11
11
  }