@owlmeans/web-client 0.1.18-rc.4 → 0.1.18-rc.41

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.
Files changed (110) hide show
  1. package/README.md +247 -43
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/web-client/SKILL.md +30 -48
  4. package/build/components/dispatcher/component.d.ts.map +1 -1
  5. package/build/components/dispatcher/component.js +54 -9
  6. package/build/components/dispatcher/component.js.map +1 -1
  7. package/build/components/dispatcher/types.d.ts +2 -2
  8. package/build/components/dispatcher/types.d.ts.map +1 -1
  9. package/build/context.d.ts.map +1 -1
  10. package/build/context.js +2 -1
  11. package/build/context.js.map +1 -1
  12. package/build/entrypoints.d.ts +16 -0
  13. package/build/entrypoints.d.ts.map +1 -0
  14. package/build/entrypoints.js +20 -0
  15. package/build/entrypoints.js.map +1 -0
  16. package/build/exports.d.ts +2 -1
  17. package/build/exports.d.ts.map +1 -1
  18. package/build/exports.js +1 -1
  19. package/build/exports.js.map +1 -1
  20. package/build/i18n/auth-be.json +32 -0
  21. package/build/i18n/auth-de.json +32 -0
  22. package/build/i18n/auth-en.json +32 -0
  23. package/build/i18n/auth-es.json +32 -0
  24. package/build/i18n/auth-pl.json +32 -0
  25. package/build/i18n/auth-ru.json +32 -0
  26. package/build/i18n/auth-uk.json +32 -0
  27. package/build/index.d.ts +2 -1
  28. package/build/index.d.ts.map +1 -1
  29. package/build/index.js +2 -1
  30. package/build/index.js.map +1 -1
  31. package/build/login/consts.d.ts +11 -0
  32. package/build/login/consts.d.ts.map +1 -0
  33. package/build/login/consts.js +11 -0
  34. package/build/login/consts.js.map +1 -0
  35. package/build/login/index.d.ts +8 -0
  36. package/build/login/index.d.ts.map +1 -0
  37. package/build/login/index.js +8 -0
  38. package/build/login/index.js.map +1 -0
  39. package/build/login/pump.d.ts +20 -0
  40. package/build/login/pump.d.ts.map +1 -0
  41. package/build/login/pump.js +46 -0
  42. package/build/login/pump.js.map +1 -0
  43. package/build/login/redirect.d.ts +9 -0
  44. package/build/login/redirect.d.ts.map +1 -0
  45. package/build/login/redirect.js +47 -0
  46. package/build/login/redirect.js.map +1 -0
  47. package/build/login/screen.d.ts +15 -0
  48. package/build/login/screen.d.ts.map +1 -0
  49. package/build/login/screen.js +97 -0
  50. package/build/login/screen.js.map +1 -0
  51. package/build/login/service.d.ts +10 -0
  52. package/build/login/service.d.ts.map +1 -0
  53. package/build/login/service.js +17 -0
  54. package/build/login/service.js.map +1 -0
  55. package/build/login/surrogate.d.ts +37 -0
  56. package/build/login/surrogate.d.ts.map +1 -0
  57. package/build/login/surrogate.js +173 -0
  58. package/build/login/surrogate.js.map +1 -0
  59. package/build/login/view.d.ts +26 -0
  60. package/build/login/view.d.ts.map +1 -0
  61. package/build/login/view.js +73 -0
  62. package/build/login/view.js.map +1 -0
  63. package/build/main.d.ts +9 -1
  64. package/build/main.d.ts.map +1 -1
  65. package/build/main.js +9 -1
  66. package/build/main.js.map +1 -1
  67. package/build/middleware.d.ts.map +1 -1
  68. package/build/middleware.js +7 -6
  69. package/build/middleware.js.map +1 -1
  70. package/build/service.js +1 -1
  71. package/build/service.js.map +1 -1
  72. package/build/types.d.ts +2 -1
  73. package/build/types.d.ts.map +1 -1
  74. package/package.json +17 -17
  75. package/src/components/dispatcher/component.tsx +72 -8
  76. package/src/components/dispatcher/types.ts +2 -2
  77. package/src/context.ts +2 -2
  78. package/src/entrypoints.ts +20 -0
  79. package/src/exports.ts +2 -1
  80. package/src/i18n/auth-be.json +32 -0
  81. package/src/i18n/auth-de.json +32 -0
  82. package/src/i18n/auth-en.json +32 -0
  83. package/src/i18n/auth-es.json +32 -0
  84. package/src/i18n/auth-pl.json +32 -0
  85. package/src/i18n/auth-ru.json +32 -0
  86. package/src/i18n/auth-uk.json +32 -0
  87. package/src/index.ts +2 -1
  88. package/src/login/consts.ts +12 -0
  89. package/src/login/index.ts +7 -0
  90. package/src/login/pump.ts +53 -0
  91. package/src/login/redirect.ts +59 -0
  92. package/src/login/screen.tsx +119 -0
  93. package/src/login/service.ts +19 -0
  94. package/src/login/surrogate.ts +211 -0
  95. package/src/login/view.tsx +94 -0
  96. package/src/main.tsx +11 -2
  97. package/src/middleware.ts +7 -6
  98. package/src/service.ts +1 -1
  99. package/src/types.ts +2 -1
  100. package/tests/surrogate.spec.d.ts +2 -0
  101. package/tests/surrogate.spec.d.ts.map +1 -0
  102. package/tests/surrogate.spec.js +43 -0
  103. package/tests/surrogate.spec.js.map +1 -0
  104. package/tests/surrogate.spec.ts +52 -0
  105. package/tsconfig.json +1 -1
  106. package/build/modules.d.ts +0 -2
  107. package/build/modules.d.ts.map +0 -1
  108. package/build/modules.js +0 -8
  109. package/build/modules.js.map +0 -1
  110. package/src/modules.ts +0 -9
@@ -0,0 +1,119 @@
1
+ import { useCallback, useEffect, useState } from 'react'
2
+ import type { FC } from 'react'
3
+ import { useContext } from '@owlmeans/client'
4
+ import { useI18nLib } from '@owlmeans/client-i18n'
5
+ import {
6
+ LoginIntent, LoginOutcome, LOGIN_INTENT_QUERY, LOGIN_METHOD_QUERY, LOGIN_NEXT_QUERY,
7
+ } from '@owlmeans/client-auth/login'
8
+ import type { LoginService } from '@owlmeans/client-auth/login'
9
+ import type { AppContext } from '../types.js'
10
+ import { LoginSurrogateView, SurrogateStage } from './view.js'
11
+
12
+ /**
13
+ * The login window an embedded application opens one level up.
14
+ *
15
+ * It is NOT wrapped in `DispatcherHOC`, and that is the point: the HOC's continuation navigates to
16
+ * `HOME` when it has nothing else to do, which is how a popup ended up rendering the whole
17
+ * application, with its navigation, inside itself. This screen has no continuation at all — it
18
+ * either hands something back and closes, or it says what it is waiting for.
19
+ *
20
+ * It also never runs the authorization machine. When there is already a session here, that is the
21
+ * answer, and it goes back to the opener immediately — no provider round trip, no PKCE exchange.
22
+ * When there is not, it forwards to the dispatcher (`next`), which owns that flow.
23
+ */
24
+ export const SurrogateScreen: FC = () => {
25
+ const context = useContext() as unknown as AppContext
26
+ const [query] = context.router().useSearchParams()
27
+ const t = useI18nLib('auth')
28
+
29
+ const intent = query.get(LOGIN_INTENT_QUERY) === LoginIntent.Logout
30
+ ? LoginIntent.Logout
31
+ : LoginIntent.Login
32
+
33
+ const [stage, setStage] = useState<SurrogateStage>(SurrogateStage.Working)
34
+ const [error, setError] = useState<string | undefined>()
35
+
36
+ const onAction = useCallback(() => {
37
+ if (stage === SurrogateStage.Standalone) {
38
+ window.location.href = '/'
39
+ return
40
+ }
41
+ if (stage === SurrogateStage.Gesture) {
42
+ const next = query.get(LOGIN_NEXT_QUERY)
43
+ void context.login().begin({ url: next ?? window.location.href })
44
+ return
45
+ }
46
+ window.close()
47
+ }, [context, stage, query])
48
+
49
+ useEffect(() => {
50
+ const login = context.service<LoginService>('login-service')
51
+ // First statement, unconditionally: `markSurrogate` reads `window.name`, and the browser
52
+ // clears that the moment this window's top-level context goes cross-origin. Recording it
53
+ // later means recording nothing.
54
+ login.enter()
55
+
56
+ const env = login.env()
57
+ if (!env.surrogate) {
58
+ // Somebody opened this address directly, or the window lost its marker. Never navigate on
59
+ // its behalf — offer a link and stop.
60
+ setStage(env.embedded ? SurrogateStage.Gesture : SurrogateStage.Standalone)
61
+ return
62
+ }
63
+
64
+ const run = async (): Promise<void> => {
65
+ const token = await context.auth().authenticated()
66
+
67
+ if (intent === LoginIntent.Logout) {
68
+ // Revoked FIRST, before anything can be awaited on a channel that may never answer: a
69
+ // window the user closes half a second later must still have ended the session it opened
70
+ // to end.
71
+ await login.revoke()
72
+ const outcome = await login.logoutComplete()
73
+ setStage(
74
+ outcome === LoginOutcome.Handled ? SurrogateStage.Handing
75
+ : outcome === LoginOutcome.Orphaned ? SurrogateStage.Orphaned
76
+ : SurrogateStage.Failed
77
+ )
78
+ return
79
+ }
80
+
81
+ if (token != null && token !== '') {
82
+ // Already signed in here. The framed application is the one that asked, so the session
83
+ // goes to it rather than being displayed to this window.
84
+ const outcome = await login.resume(token)
85
+ setStage(
86
+ outcome === LoginOutcome.Handled ? SurrogateStage.Handing
87
+ : outcome === LoginOutcome.Orphaned ? SurrogateStage.Orphaned
88
+ : SurrogateStage.Failed
89
+ )
90
+ return
91
+ }
92
+
93
+ // Nothing here yet — the dispatcher owns the authorization round trip, and the provider's
94
+ // callback lands there rather than on this route. The method the user already chose travels
95
+ // with it, so the dispatcher does not ask a second time in a window with no one to ask.
96
+ const next = query.get(LOGIN_NEXT_QUERY)
97
+ if (next == null || next === '') {
98
+ setStage(SurrogateStage.Gesture)
99
+ return
100
+ }
101
+ const method = query.get(LOGIN_METHOD_QUERY)
102
+ const target = method != null && method !== ''
103
+ ? `${next}${next.includes('?') ? '&' : '?'}${LOGIN_METHOD_QUERY}=${encodeURIComponent(method)}`
104
+ : next
105
+ window.location.href = target
106
+ }
107
+
108
+ void run().catch((e: Error) => {
109
+ console.error(e)
110
+ setError(e.message)
111
+ setStage(SurrogateStage.Failed)
112
+ })
113
+ }, [context, intent])
114
+
115
+ return <LoginSurrogateView
116
+ stage={stage} intent={intent} onAction={onAction} error={error}
117
+ translate={(key, defaultValue) => t(key, { defaultValue })}
118
+ />
119
+ }
@@ -0,0 +1,19 @@
1
+ import { appendLogin } from '@owlmeans/client-auth/login'
2
+ import type { BasicConfig, BasicContext } from '@owlmeans/context'
3
+ import { makeRedirectLoginPlugin } from './redirect.js'
4
+ import { makeSurrogateLoginPlugin } from './surrogate.js'
5
+
6
+ /**
7
+ * Register the login host and the two browser flows on a context.
8
+ *
9
+ * Called by `makeContext`, so every web app has working login — including a framed one — without
10
+ * registering anything. An app that needs a different mechanic registers its own plugin at a
11
+ * higher priority; it does not replace this.
12
+ */
13
+ export const appendWebLogin = <C extends BasicConfig, T extends BasicContext<C>>(ctx: T): T => {
14
+ const service = appendLogin<C, T>(ctx).login()
15
+ service.registerPlugin(makeRedirectLoginPlugin())
16
+ service.registerPlugin(makeSurrogateLoginPlugin())
17
+
18
+ return ctx
19
+ }
@@ -0,0 +1,211 @@
1
+ import {
2
+ adoptToken, clearSurrogate, markSurrogate, revokeToken, surrogatePath, LoginIntent, LoginOutcome,
3
+ LOGIN_LOGOUT_MESSAGE, LOGIN_SURROGATE_HEIGHT, LOGIN_SURROGATE_NAME, LOGIN_SURROGATE_WIDTH,
4
+ LOGIN_TOKEN_MESSAGE,
5
+ } from '@owlmeans/client-auth/login'
6
+ import type { LoginContext, LoginEnv, LoginPlugin } from '@owlmeans/client-auth/login'
7
+ import { SURROGATE_LOGIN, SURROGATE_LOGIN_PRIORITY } from './consts.js'
8
+ import { awaitSurrogate } from './pump.js'
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
+
37
+ /**
38
+ * Login for a document that cannot complete the flow where it is.
39
+ *
40
+ * An app embedded in a frame cannot redirect to an identity provider: the provider refuses to be
41
+ * embedded (`frame-ancestors`), and its session cookies are third-party there. So the flow runs
42
+ * one window up, in a surrogate window that is top-level and first-party on this application's own
43
+ * origin — where the very same flow completes normally — and the token is passed back to the
44
+ * document that asked for it.
45
+ *
46
+ * This plugin is deliberately not OIDC-specific. The mechanic is "run the login route one window
47
+ * up and hand the token back", and the adoption is the plain auth service, so any relying party
48
+ * that renders a login route gets it by registering nothing.
49
+ *
50
+ * `match` covers BOTH halves of the lifecycle — the framed opener and the surrogate itself, where
51
+ * `embedded` is false. Inside the surrogate the redirect plugin must not win: that window IS
52
+ * top-level, so it authorizes by an ordinary redirect and differs only in what it does with the
53
+ * token at the end.
54
+ */
55
+ export const makeSurrogateLoginPlugin = (): LoginPlugin => {
56
+ /**
57
+ * Give the token to whoever is entitled to it, and get out of the way.
58
+ *
59
+ * Shared by `complete` (a token was just issued here) and `resume` (one was already here),
60
+ * because what happens to it is the same in both cases and only the question differs.
61
+ */
62
+ const handBack = async (token: string, env: LoginEnv): Promise<LoginOutcome> => {
63
+ if (!env.surrogate) {
64
+ // This document started the flow and finished it in place — it already has what it needs.
65
+ return LoginOutcome.Passed
66
+ }
67
+ if (!env.hasOpener) {
68
+ // Authenticated with nowhere to send it. `Cross-Origin-Opener-Policy: same-origin` from the
69
+ // provider severs `window.opener` permanently, so this is not a timing problem to retry.
70
+ return LoginOutcome.Orphaned
71
+ }
72
+
73
+ clearSurrogate()
74
+ // Both windows are the same origin (the surrogate is this application's own login route), so
75
+ // the origin is pinned rather than passed as `*` — the message carries a bearer token.
76
+ window.opener.postMessage({ type: LOGIN_TOKEN_MESSAGE, token }, window.location.origin)
77
+ window.close()
78
+
79
+ return LoginOutcome.Handled
80
+ }
81
+
82
+ const plugin: LoginPlugin = {
83
+ alias: SURROGATE_LOGIN,
84
+ priority: SURROGATE_LOGIN_PRIORITY,
85
+ mode: 'surrogate',
86
+ match: env => env.hasWindow && (env.embedded || env.surrogate),
87
+
88
+ enter: () => { markSurrogate() },
89
+
90
+ begin: (ctx, request, env) => {
91
+ // Already one window up — nothing to open, just run the flow.
92
+ if (env.surrogate) {
93
+ window.location.href = request.url
94
+
95
+ return Promise.resolve(LoginOutcome.Redirected)
96
+ }
97
+
98
+ // Opened FIRST and synchronously: `window.open` escapes the popup blocker only while the
99
+ // user gesture is still being handled, so nothing may be awaited before this line. That is
100
+ // why this function is not `async`.
101
+ //
102
+ // The window opens on the surrogate route rather than on the dispatcher, so its very first
103
+ // paint is a bare "signing you in" panel instead of the application with its navigation. The
104
+ // dispatcher address travels as `next`, because the provider's callback still lands there and
105
+ // whatever flow parameters the caller's URL carried must survive the hop.
106
+ const path = surrogatePath(ctx as LoginContext, {
107
+ intent: LoginIntent.Login, next: request.url,
108
+ })
109
+ const surrogate = window.open(
110
+ path ?? request.url, LOGIN_SURROGATE_NAME,
111
+ centeredPopupFeatures(LOGIN_SURROGATE_WIDTH, LOGIN_SURROGATE_HEIGHT)
112
+ )
113
+ if (surrogate == null) {
114
+ // Distinct from `Failed` below: nothing ever opened, so there is nothing left to wait on.
115
+ return Promise.resolve(LoginOutcome.Blocked)
116
+ }
117
+
118
+ return awaitSurrogate(surrogate, LOGIN_TOKEN_MESSAGE, async data => {
119
+ if (data.token == null || data.token === '') {
120
+ return LoginOutcome.Failed
121
+ }
122
+ await adoptToken(ctx as LoginContext, data.token)
123
+ // Adopting the token is not the end of the flow — running the caller's continuation is,
124
+ // exactly as the redirect plugin does before it reports `Handled`. Without it the token
125
+ // lands in the auth service and nothing else happens: the framed app keeps rendering its
126
+ // signed-out tree, and the user watches the login window close over a preview that never
127
+ // changes. `Handled` promises the caller there is nothing left to do.
128
+ await request.navigate?.()
129
+
130
+ return LoginOutcome.Handled
131
+ })
132
+ },
133
+
134
+ authorize: async (_ctx, url, env) => {
135
+ // The surrogate is top-level, so it redirects like any ordinary tab.
136
+ if (env.surrogate) {
137
+ window.location.href = url
138
+
139
+ return LoginOutcome.Redirected
140
+ }
141
+
142
+ // Framed, and not yet one window up. A redirect from here would land in the frame and be
143
+ // refused, and the window that would fix it cannot be opened without a user gesture — so the
144
+ // caller is told to render a control rather than to navigate.
145
+ return LoginOutcome.Gesture
146
+ },
147
+
148
+ complete: async (_ctx, token, env) => await handBack(token, env),
149
+
150
+ // A session that was already here is worth exactly as much as one just issued — to the window
151
+ // that asked for it. Without this the surrogate would render the signed-in application to
152
+ // itself and the framed app would stay signed out, which is the whole reported defect.
153
+ resume: async (_ctx, token, env) => await handBack(token, env),
154
+
155
+ logout: (ctx, request, env) => {
156
+ // Already one window up — end the session here and tell the opener.
157
+ if (env.surrogate) {
158
+ return revokeToken(ctx as LoginContext)
159
+ .then(async () => await plugin.logoutComplete!(ctx, env))
160
+ }
161
+
162
+ const path = surrogatePath(ctx as LoginContext, { intent: LoginIntent.Logout })
163
+ // Opened first and synchronously, for the same reason `begin` does.
164
+ const surrogate = path != null
165
+ ? window.open(path, LOGIN_SURROGATE_NAME,
166
+ centeredPopupFeatures(LOGIN_SURROGATE_WIDTH, LOGIN_SURROGATE_HEIGHT))
167
+ : null
168
+
169
+ // The local session goes next, and UNCONDITIONALLY. A blocked window, a severed opener or a
170
+ // user who closes the popup must never leave THIS document signed in: a logout that only
171
+ // half happened is bad, and one that did not happen at all is worse.
172
+ const local = revokeToken(ctx as LoginContext)
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
+
179
+ return local.then(async () => {
180
+ await request.navigate?.()
181
+
182
+ return outcome
183
+ })
184
+ }
185
+
186
+ return local.then(async () => await awaitSurrogate(
187
+ surrogate, LOGIN_LOGOUT_MESSAGE, async () => {
188
+ await request.navigate?.()
189
+
190
+ return LoginOutcome.Handled
191
+ }
192
+ ))
193
+ },
194
+
195
+ logoutComplete: async (_ctx, env) => {
196
+ if (!env.surrogate) {
197
+ return LoginOutcome.Passed
198
+ }
199
+ if (!env.hasOpener) {
200
+ return LoginOutcome.Orphaned
201
+ }
202
+ clearSurrogate()
203
+ window.opener.postMessage({ type: LOGIN_LOGOUT_MESSAGE, ok: true }, window.location.origin)
204
+ window.close()
205
+
206
+ return LoginOutcome.Handled
207
+ },
208
+ }
209
+
210
+ return plugin
211
+ }
@@ -0,0 +1,94 @@
1
+ import type { CSSProperties, FC } from 'react'
2
+ import { LoginIntent } from '@owlmeans/client-auth/login'
3
+
4
+ /** What the surrogate window is doing, from the point of view of the person watching it. */
5
+ export enum SurrogateStage {
6
+ /** Working. No control — there is nothing for the user to do but wait. */
7
+ Working = 'working',
8
+ /** Needs a click: the flow cannot continue without a fresh gesture. */
9
+ Gesture = 'gesture',
10
+ /** Done here; handing the result back and closing. */
11
+ Handing = 'handing',
12
+ /** Acted, but with no channel back to the window that asked. */
13
+ Orphaned = 'orphaned',
14
+ /** Ended with nothing. */
15
+ Failed = 'failed',
16
+ /** Not a surrogate at all — someone opened this address directly. */
17
+ Standalone = 'standalone',
18
+ }
19
+
20
+ export interface SurrogateViewProps {
21
+ stage: SurrogateStage
22
+ intent: LoginIntent
23
+ onAction?: () => void
24
+ error?: string
25
+ translate?: (key: string, defaultValue: string) => string
26
+ }
27
+
28
+ /**
29
+ * The whole of what a login window shows.
30
+ *
31
+ * Plain elements and inline styles on purpose. This renders in a popup opened by a `web-panel` app,
32
+ * a `mui-panel` app and a generated Tailwind app alike, and none of their stylesheets is loaded
33
+ * here — a class name would simply do nothing. It is also the reason this component carries no
34
+ * dependency on a UI family at all.
35
+ */
36
+ const box: CSSProperties = {
37
+ maxWidth: '22rem', margin: '15vh auto', padding: '1.5rem', textAlign: 'center',
38
+ fontFamily: 'system-ui, sans-serif', lineHeight: 1.5,
39
+ }
40
+
41
+ const action: CSSProperties = {
42
+ marginTop: '1rem', padding: '.625rem 1.25rem', fontSize: '1rem',
43
+ cursor: 'pointer', borderRadius: '.375rem', border: '1px solid currentColor',
44
+ }
45
+
46
+ const COPY: Record<SurrogateStage, { key: string, en: string, action?: [string, string] }> = {
47
+ [SurrogateStage.Working]: { key: 'working', en: 'Signing you in…' },
48
+ [SurrogateStage.Gesture]: {
49
+ key: 'gesture.title', en: 'Continue to sign in.', action: ['gesture.action', 'Sign in'],
50
+ },
51
+ [SurrogateStage.Handing]: { key: 'handing', en: 'Done. Returning to the application…' },
52
+ [SurrogateStage.Orphaned]: {
53
+ key: 'orphaned', en: 'Signed in. You can close this window and continue in the application.',
54
+ action: ['close', 'Close'],
55
+ },
56
+ [SurrogateStage.Failed]: {
57
+ key: 'failed', en: 'That did not complete. You can close this window and try again.',
58
+ action: ['close', 'Close'],
59
+ },
60
+ [SurrogateStage.Standalone]: {
61
+ key: 'standalone.title', en: 'This page is used to sign in from another window.',
62
+ action: ['standalone.action', 'Open the application'],
63
+ },
64
+ }
65
+
66
+ const LOGOUT_COPY: Partial<Record<SurrogateStage, { key: string, en: string }>> = {
67
+ [SurrogateStage.Working]: { key: 'working', en: 'Signing you out…' },
68
+ [SurrogateStage.Gesture]: { key: 'gesture.title', en: 'Continue to sign out.' },
69
+ [SurrogateStage.Handing]: { key: 'handing', en: 'Signed out. Returning to the application…' },
70
+ [SurrogateStage.Orphaned]: {
71
+ key: 'orphaned', en: 'Signed out. You can close this window.',
72
+ },
73
+ [SurrogateStage.Failed]: {
74
+ key: 'failed', en: 'Sign-out did not complete here. You can close this window.',
75
+ },
76
+ }
77
+
78
+ export const LoginSurrogateView: FC<SurrogateViewProps> = (
79
+ { stage, intent, onAction, error, translate }
80
+ ) => {
81
+ const t = translate ?? ((_key: string, defaultValue: string) => defaultValue)
82
+ const scope = intent === LoginIntent.Logout ? 'logout' : 'login'
83
+ const base = COPY[stage]
84
+ const copy = (intent === LoginIntent.Logout ? LOGOUT_COPY[stage] : undefined) ?? base
85
+
86
+ return <div style={box} data-surrogate-stage={stage} data-surrogate-intent={intent}>
87
+ <p>{t(`surrogate.${scope}.${copy.key}`, copy.en)}</p>
88
+ {error != null && error !== '' &&
89
+ <p style={{ color: '#b00', fontSize: '.875rem' }} role="alert">{error}</p>}
90
+ {base.action != null && onAction != null && <button type="button" style={action} onClick={onAction}>
91
+ {t(`surrogate.${scope}.${base.action[0]}`, base.action[1])}
92
+ </button>}
93
+ </div>
94
+ }
package/src/main.tsx CHANGED
@@ -25,5 +25,14 @@ export const render = (node: ReactNode, opts?: RenderOptions) => {
25
25
  opts?.onReady ?? true ? window.addEventListener('DOMContentLoaded', _callback) : _callback()
26
26
  }
27
27
 
28
- export const renderApp = <C extends AppConfig, T extends AppContext<C>>(context: T, opts?: RenderOptions) =>
29
- render(<WebApp context={context} />, opts)
28
+ /**
29
+ * Mount the application.
30
+ *
31
+ * `children` are rendered inside the context provider and BEFORE the router, which makes them the
32
+ * app's global overlay slot: a consent dialog, a toast surface, anything that must exist on every
33
+ * route and outlive navigation. A component mounted inside a route instead is torn down and
34
+ * rebuilt on every navigation, which for a dialog means it closes itself.
35
+ */
36
+ export const renderApp = <C extends AppConfig, T extends AppContext<C>>(
37
+ context: T, opts?: RenderOptions, children?: ReactNode
38
+ ) => render(<WebApp context={context}>{children}</WebApp>, opts)
package/src/middleware.ts CHANGED
@@ -11,18 +11,19 @@ export const logoutMiddleware: Middleware = {
11
11
  stage: MiddlewareStage.Loading,
12
12
  apply: async context => {
13
13
  context.entrypoints<Perked>().forEach(module => {
14
- if (module.route.route.type === AppType.Backend && module.call != null) {
14
+ if (module.route.route.type === AppType.Backend && module.invoke != null) {
15
15
  const guards = module.getGuards()
16
16
  if (guards.length > 0) {
17
17
  if (module._auth_web_middleware_applied === true) {
18
18
  return
19
19
  }
20
20
  module._auth_web_middleware_applied = true
21
- // const auth = context.service<GuardService>(service)
22
- const call = module.call
23
- module.call = async (req, res) => {
21
+ // Wrapping `invoke` covers `call` too: `call` reads `invoke` off the entrypoint at the
22
+ // moment it runs, so a rejected session is caught whichever verb the caller used.
23
+ const invoke = module.invoke
24
+ module.invoke = (async req => {
24
25
  try {
25
- return await call(req, res)
26
+ return await invoke(req)
26
27
  } catch (e) {
27
28
  if (e instanceof Error) {
28
29
  const err = ResilientError.ensure(e)
@@ -38,7 +39,7 @@ export const logoutMiddleware: Middleware = {
38
39
  }
39
40
  throw e
40
41
  }
41
- }
42
+ }) as typeof module.invoke
42
43
 
43
44
  }
44
45
  }
package/src/service.ts CHANGED
@@ -16,7 +16,7 @@ export const makeAuthWebService = (alias: string = DEFAULT_ALIAS): AuthService =
16
16
  await update(token)
17
17
  if (token == null) {
18
18
  const module = service.assertCtx().entrypoint<ClientEntrypoint<string>>(DISPATCHER)
19
- const [url] = await module.call()
19
+ const url = await module.url()
20
20
  document.location.href = url
21
21
  }
22
22
  }
package/src/types.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { AuthServiceAppend } from '@owlmeans/client-auth'
2
+ import type { LoginServiceAppend } from '@owlmeans/client-auth/login'
2
3
  import type { ClientConfig } from '@owlmeans/client-context'
3
4
  import type { ClientContext } from '@owlmeans/client'
4
5
 
@@ -13,5 +14,5 @@ export interface AppConfig extends ClientConfig {
13
14
  }
14
15
 
15
16
  export interface AppContext<C extends AppConfig = AppConfig> extends ClientContext<C>,
16
- AuthServiceAppend {
17
+ AuthServiceAppend, LoginServiceAppend {
17
18
  }
@@ -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
  }
@@ -1,2 +0,0 @@
1
- export declare const modules: import("@owlmeans/client-entrypoint").ClientEntrypoint<{}, import("@owlmeans/client-entrypoint").ClientRequest<{}>>[];
2
- //# sourceMappingURL=modules.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"modules.d.ts","sourceRoot":"","sources":["../src/modules.ts"],"names":[],"mappings":"AAQA,eAAO,MAAM,OAAO,uHAAO,CAAA"}
package/build/modules.js DELETED
@@ -1,8 +0,0 @@
1
- import { DISPATCHER } from '@owlmeans/auth';
2
- import { modules as list } from '@owlmeans/client-auth';
3
- import { elevate } from '@owlmeans/client-entrypoint';
4
- import { handler } from '@owlmeans/client';
5
- import { Dispatcher } from './components/dispatcher/component.js';
6
- elevate(list, DISPATCHER, handler(Dispatcher), { force: true });
7
- export const modules = list;
8
- //# sourceMappingURL=modules.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"modules.js","sourceRoot":"","sources":["../src/modules.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAA;AAC3C,OAAO,EAAE,OAAO,IAAI,IAAI,EAAE,MAAM,uBAAuB,CAAA;AACvD,OAAO,EAAE,OAAO,EAAE,MAAM,6BAA6B,CAAA;AACrD,OAAO,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAA;AAC1C,OAAO,EAAE,UAAU,EAAE,MAAM,sCAAsC,CAAA;AAEjE,OAAO,CAAC,IAAI,EAAE,UAAU,EAAE,OAAO,CAAC,UAAU,CAAC,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAA;AAE/D,MAAM,CAAC,MAAM,OAAO,GAAG,IAAI,CAAA"}