@owlmeans/web-client 0.1.18-rc.8 → 0.1.18

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 (123) hide show
  1. package/README.md +247 -43
  2. package/agent-meta/manifest.json +3 -3
  3. package/agent-meta/skills/web-client/SKILL.md +67 -56
  4. package/build/components/dispatcher/component.d.ts.map +1 -1
  5. package/build/components/dispatcher/component.js +55 -9
  6. package/build/components/dispatcher/component.js.map +1 -1
  7. package/build/components/dispatcher/parametrise.d.ts.map +1 -1
  8. package/build/components/dispatcher/parametrise.js +0 -3
  9. package/build/components/dispatcher/parametrise.js.map +1 -1
  10. package/build/components/dispatcher/types.d.ts +2 -2
  11. package/build/components/dispatcher/types.d.ts.map +1 -1
  12. package/build/context.d.ts.map +1 -1
  13. package/build/context.js +0 -1
  14. package/build/context.js.map +1 -1
  15. package/build/entrypoints.d.ts +16 -0
  16. package/build/entrypoints.d.ts.map +1 -0
  17. package/build/entrypoints.js +20 -0
  18. package/build/entrypoints.js.map +1 -0
  19. package/build/errors.d.ts.map +1 -1
  20. package/build/errors.js.map +1 -1
  21. package/build/exports.d.ts +3 -2
  22. package/build/exports.d.ts.map +1 -1
  23. package/build/exports.js +2 -2
  24. package/build/exports.js.map +1 -1
  25. package/build/helper.d.ts.map +1 -1
  26. package/build/i18n/auth-be.json +32 -0
  27. package/build/i18n/auth-de.json +32 -0
  28. package/build/i18n/auth-en.json +32 -0
  29. package/build/i18n/auth-es.json +32 -0
  30. package/build/i18n/auth-fr.json +38 -0
  31. package/build/i18n/auth-pl.json +32 -0
  32. package/build/i18n/auth-ru.json +32 -0
  33. package/build/i18n/auth-uk.json +32 -0
  34. package/build/i18n.js +10 -8
  35. package/build/i18n.js.map +1 -1
  36. package/build/index.d.ts +1 -1
  37. package/build/index.d.ts.map +1 -1
  38. package/build/index.js +1 -1
  39. package/build/index.js.map +1 -1
  40. package/build/login/consts.d.ts +36 -0
  41. package/build/login/consts.d.ts.map +1 -1
  42. package/build/login/consts.js +38 -0
  43. package/build/login/consts.js.map +1 -1
  44. package/build/login/consts.local.d.ts +22 -0
  45. package/build/login/consts.local.d.ts.map +1 -0
  46. package/build/login/consts.local.js +48 -0
  47. package/build/login/consts.local.js.map +1 -0
  48. package/build/login/index.d.ts +4 -0
  49. package/build/login/index.d.ts.map +1 -1
  50. package/build/login/index.js +3 -0
  51. package/build/login/index.js.map +1 -1
  52. package/build/login/pump.d.ts +20 -0
  53. package/build/login/pump.d.ts.map +1 -0
  54. package/build/login/pump.js +46 -0
  55. package/build/login/pump.js.map +1 -0
  56. package/build/login/redirect.d.ts.map +1 -1
  57. package/build/login/redirect.js +17 -1
  58. package/build/login/redirect.js.map +1 -1
  59. package/build/login/screen.d.ts +19 -0
  60. package/build/login/screen.d.ts.map +1 -0
  61. package/build/login/screen.js +119 -0
  62. package/build/login/screen.js.map +1 -0
  63. package/build/login/service.d.ts.map +1 -1
  64. package/build/login/surrogate.d.ts +16 -0
  65. package/build/login/surrogate.d.ts.map +1 -1
  66. package/build/login/surrogate.js +139 -71
  67. package/build/login/surrogate.js.map +1 -1
  68. package/build/login/types.d.ts +10 -0
  69. package/build/login/types.d.ts.map +1 -0
  70. package/build/login/types.js +2 -0
  71. package/build/login/types.js.map +1 -0
  72. package/build/login/view.d.ts +4 -0
  73. package/build/login/view.d.ts.map +1 -0
  74. package/build/login/view.js +12 -0
  75. package/build/login/view.js.map +1 -0
  76. package/build/main.d.ts +9 -1
  77. package/build/main.d.ts.map +1 -1
  78. package/build/main.js +18 -5
  79. package/build/main.js.map +1 -1
  80. package/build/middleware.d.ts +1 -1
  81. package/build/middleware.d.ts.map +1 -1
  82. package/build/middleware.js +7 -6
  83. package/build/middleware.js.map +1 -1
  84. package/build/service.d.ts.map +1 -1
  85. package/build/service.js +1 -1
  86. package/build/service.js.map +1 -1
  87. package/build/types.local.d.ts +5 -0
  88. package/build/types.local.d.ts.map +1 -0
  89. package/build/types.local.js +2 -0
  90. package/build/types.local.js.map +1 -0
  91. package/package.json +22 -21
  92. package/src/components/dispatcher/component.tsx +71 -8
  93. package/src/components/dispatcher/parametrise.tsx +0 -3
  94. package/src/components/dispatcher/types.ts +2 -2
  95. package/src/context.ts +0 -2
  96. package/src/entrypoints.ts +20 -0
  97. package/src/exports.ts +7 -2
  98. package/src/i18n/auth-be.json +32 -0
  99. package/src/i18n/auth-de.json +32 -0
  100. package/src/i18n/auth-en.json +32 -0
  101. package/src/i18n/auth-es.json +32 -0
  102. package/src/i18n/auth-fr.json +38 -0
  103. package/src/i18n/auth-pl.json +32 -0
  104. package/src/i18n/auth-ru.json +32 -0
  105. package/src/i18n/auth-uk.json +32 -0
  106. package/src/i18n.ts +10 -8
  107. package/src/index.ts +1 -1
  108. package/src/login/consts.local.ts +52 -0
  109. package/src/login/consts.ts +38 -0
  110. package/src/login/index.ts +4 -0
  111. package/src/login/pump.ts +53 -0
  112. package/src/login/redirect.ts +23 -2
  113. package/src/login/screen.tsx +141 -0
  114. package/src/login/surrogate.ts +170 -86
  115. package/src/login/types.ts +10 -0
  116. package/src/login/view.tsx +22 -0
  117. package/src/main.tsx +19 -6
  118. package/src/middleware.ts +9 -12
  119. package/src/service.ts +1 -1
  120. package/src/types.local.ts +5 -0
  121. package/tests/surrogate.spec.ts +74 -0
  122. package/tsconfig.json +1 -1
  123. package/src/modules.ts +0 -9
@@ -0,0 +1,53 @@
1
+ import { LOGIN_WATCH_INTERVAL, LoginOutcome } from '@owlmeans/client-auth/login'
2
+
3
+ /**
4
+ * Wait on a surrogate window until it says something, or goes away.
5
+ *
6
+ * Extracted so login and logout share one implementation rather than two that agree until one of
7
+ * them is changed. Every guard here was a real failure at some point:
8
+ *
9
+ * - the **origin pin** is not decoration — the message carries a bearer token, and a listener that
10
+ * accepted any origin would take one from any page that could reach this window;
11
+ * - the **`settled` latch** stops a second message, or a close racing a message, resolving twice;
12
+ * - the **`closed` poll** is the only exit when the user simply closes the window, because a window
13
+ * the user closes announces nothing at all;
14
+ * - the listener and the interval are removed on every path, including the ones that resolve.
15
+ */
16
+ export const awaitSurrogate = async (
17
+ surrogate: Window,
18
+ messageType: string,
19
+ onMessage: (data: { type?: string, token?: string, ok?: boolean }) => Promise<LoginOutcome>
20
+ ): Promise<LoginOutcome> => await new Promise<LoginOutcome>(resolve => {
21
+ let settled = false
22
+ let watch: ReturnType<typeof setInterval> | undefined
23
+
24
+ const finish = (result: LoginOutcome): void => {
25
+ if (settled) {
26
+ return
27
+ }
28
+ settled = true
29
+ window.removeEventListener('message', handler)
30
+ if (watch != null) {
31
+ clearInterval(watch)
32
+ }
33
+ resolve(result)
34
+ }
35
+
36
+ function handler(event: MessageEvent): void {
37
+ if (event.origin !== window.location.origin) {
38
+ return
39
+ }
40
+ const data = event.data as { type?: string, token?: string, ok?: boolean } | null
41
+ if (data?.type !== messageType) {
42
+ return
43
+ }
44
+ void onMessage(data).then(finish).catch(() => finish(LoginOutcome.Failed))
45
+ }
46
+
47
+ window.addEventListener('message', handler)
48
+ watch = setInterval(() => {
49
+ if (surrogate.closed) {
50
+ finish(LoginOutcome.Failed)
51
+ }
52
+ }, LOGIN_WATCH_INTERVAL)
53
+ })
@@ -1,5 +1,5 @@
1
- import { LoginOutcome } from '@owlmeans/client-auth/login'
2
- import type { LoginPlugin } from '@owlmeans/client-auth/login'
1
+ import { LoginOutcome, loginTokenOf } from '@owlmeans/client-auth/login'
2
+ import type { LoginContext, LoginPlugin } from '@owlmeans/client-auth/login'
3
3
  import { REDIRECT_LOGIN } from './consts.js'
4
4
 
5
5
  /**
@@ -35,4 +35,25 @@ export const makeRedirectLoginPlugin = (): LoginPlugin => ({
35
35
 
36
36
  // Nothing to hand anywhere — the token was issued in the document that will use it.
37
37
  complete: async () => LoginOutcome.Passed,
38
+
39
+ // `resume` is deliberately NOT implemented. The host defaults to `Passed`, which is "keep the
40
+ // session you already have and carry on" — byte for byte what an ordinary tab has always done.
41
+ // Implementing it here would be the one way to regress every unframed application.
42
+
43
+ logout: (ctx, request) => loginTokenOf(ctx as LoginContext).revokeToken().then(async () => {
44
+ if (request.navigate != null) {
45
+ await request.navigate()
46
+
47
+ return LoginOutcome.Handled
48
+ }
49
+ // A reload rather than a client-side navigation: `useOwlAuth` and its kin read through
50
+ // cached values, so the honest way to make a whole application forget a session is to
51
+ // rebuild it. This is what `useLogout` did inline before the mechanic became a plugin.
52
+ window.location.reload()
53
+
54
+ return LoginOutcome.Handled
55
+ }),
56
+
57
+ // Nothing to tell anyone — the session was cleared in the document that owned it.
58
+ logoutComplete: async () => LoginOutcome.Passed,
38
59
  })
@@ -0,0 +1,141 @@
1
+ import { useCallback, useEffect, useState, type FC } from 'react'
2
+ import { useContext } from '@owlmeans/client'
3
+ import { useI18nLib } from '@owlmeans/client-i18n'
4
+ import { LoginIntent, LoginOutcome, LOGIN_INTENT_QUERY, LOGIN_METHOD_QUERY, LOGIN_NEXT_QUERY, type LoginService } from '@owlmeans/client-auth/login'
5
+ import { USER_ID } from '@owlmeans/client-auth'
6
+ import type { AppContext } from '../types.js'
7
+ import { LoginSurrogateView } from './view.js'
8
+ import { SurrogateStage } from './consts.js'
9
+ import { logger } from '@owlmeans/log'
10
+ import { SurrogateLoginStep } from './consts.js'
11
+
12
+ const log = logger('web-client:login')
13
+ /**
14
+ * A sign-in request never reuses the session this window happens to hold.
15
+ *
16
+ * The opener asks for a sign-in only when it has no session its server accepts — and the window it
17
+ * opens does not share its storage: an embedded application's storage is partitioned by the top
18
+ * level site, the popup's is first-party. So the popup can hold a session the server has already
19
+ * REFUSED (its record gone after a restart, revoked, fenced). Handing that back closes the popup
20
+ * at once — a blink — and the opener fails its next request exactly as before, forever, because
21
+ * nothing in the opener can reach the popup's storage to clear it. A fresh round trip costs only
22
+ * redirects while the provider still has a session, and always yields a session the server holds.
23
+ *
24
+ * Only without a dispatcher address is the stored session handed back as it is: there is nothing
25
+ * to authenticate through, and the opener's own 401 handling drops it if it is refused.
26
+ */
27
+ export const surrogateLoginStep = (token: string | null | undefined, next: string | null): SurrogateLoginStep => {
28
+ const stored = token != null && token !== ''
29
+ if (!stored) {
30
+ return SurrogateLoginStep.Authenticate
31
+ }
32
+
33
+ return next != null && next !== '' ? SurrogateLoginStep.Forget : SurrogateLoginStep.Resume
34
+ }
35
+
36
+ export const SurrogateScreen: FC = () => {
37
+ const context = useContext() as unknown as AppContext
38
+ const [query] = context.router().useSearchParams()
39
+ const t = useI18nLib('auth')
40
+
41
+ const intent = query.get(LOGIN_INTENT_QUERY) === LoginIntent.Logout
42
+ ? LoginIntent.Logout
43
+ : LoginIntent.Login
44
+
45
+ const [stage, setStage] = useState<SurrogateStage>(SurrogateStage.Working)
46
+ const [error, setError] = useState<string | undefined>()
47
+
48
+ const onAction = useCallback(() => {
49
+ if (stage === SurrogateStage.Standalone) {
50
+ window.location.href = '/'
51
+ return
52
+ }
53
+ if (stage === SurrogateStage.Gesture) {
54
+ const next = query.get(LOGIN_NEXT_QUERY)
55
+ void context.login().begin({ url: next ?? window.location.href })
56
+ return
57
+ }
58
+ window.close()
59
+ }, [context, stage, query])
60
+
61
+ useEffect(() => {
62
+ const login = context.service<LoginService>('login-service')
63
+ // First statement, unconditionally: `markSurrogate` reads `window.name`, and the browser
64
+ // clears that the moment this window's top-level context goes cross-origin. Recording it
65
+ // later means recording nothing.
66
+ login.enter()
67
+
68
+ const env = login.env()
69
+ if (!env.surrogate) {
70
+ // Somebody opened this address directly, or the window lost its marker. Never navigate on
71
+ // its behalf — offer a link and stop.
72
+ setStage(env.embedded ? SurrogateStage.Gesture : SurrogateStage.Standalone)
73
+ return
74
+ }
75
+
76
+ const run = async (): Promise<void> => {
77
+ const token = await context.auth().authenticated()
78
+
79
+ if (intent === LoginIntent.Logout) {
80
+ // Revoked FIRST, before anything can be awaited on a channel that may never answer: a
81
+ // window the user closes half a second later must still have ended the session it opened
82
+ // to end.
83
+ await login.revoke()
84
+ const outcome = await login.logoutComplete()
85
+ setStage(
86
+ outcome === LoginOutcome.Handled ? SurrogateStage.Handing
87
+ : outcome === LoginOutcome.Orphaned ? SurrogateStage.Orphaned
88
+ : SurrogateStage.Failed
89
+ )
90
+ return
91
+ }
92
+
93
+ const next = query.get(LOGIN_NEXT_QUERY)
94
+ const step = surrogateLoginStep(token, next)
95
+
96
+ if (step === SurrogateLoginStep.Forget) {
97
+ // Dropped from this window's storage only — the web auth service's own clearing path
98
+ // navigates to the dispatcher, and `next` below is exactly where this window goes anyway.
99
+ const auth = context.auth()
100
+ await auth.store().delete(USER_ID)
101
+ auth.token = undefined
102
+ auth.auth = undefined
103
+ }
104
+
105
+ if (step === SurrogateLoginStep.Resume && token != null) {
106
+ // Signed in here and nowhere to authenticate afresh: hand what there is to the opener.
107
+ const outcome = await login.resume(token)
108
+ setStage(
109
+ outcome === LoginOutcome.Handled ? SurrogateStage.Handing
110
+ : outcome === LoginOutcome.Orphaned ? SurrogateStage.Orphaned
111
+ : SurrogateStage.Failed
112
+ )
113
+ return
114
+ }
115
+
116
+ // The dispatcher owns the authorization round trip, and the provider's callback lands there
117
+ // rather than on this route. The method the user already chose travels with it, so the
118
+ // dispatcher does not ask a second time in a window with no one to ask.
119
+ if (next == null || next === '') {
120
+ setStage(SurrogateStage.Gesture)
121
+ return
122
+ }
123
+ const method = query.get(LOGIN_METHOD_QUERY)
124
+ const target = method != null && method !== ''
125
+ ? `${next}${next.includes('?') ? '&' : '?'}${LOGIN_METHOD_QUERY}=${encodeURIComponent(method)}`
126
+ : next
127
+ window.location.href = target
128
+ }
129
+
130
+ void run().catch((e: Error) => {
131
+ log.error('Surrogate login failed', e)
132
+ setError(e.message)
133
+ setStage(SurrogateStage.Failed)
134
+ })
135
+ }, [context, intent])
136
+
137
+ return <LoginSurrogateView
138
+ stage={stage} intent={intent} onAction={onAction} error={error}
139
+ translate={(key, defaultValue) => t(key, { defaultValue })}
140
+ />
141
+ }
@@ -1,9 +1,34 @@
1
- import {
2
- adoptToken, clearSurrogate, markSurrogate, LoginOutcome,
3
- LOGIN_SURROGATE_FEATURES, LOGIN_SURROGATE_NAME, LOGIN_TOKEN_MESSAGE, LOGIN_WATCH_INTERVAL,
4
- } from '@owlmeans/client-auth/login'
5
- import type { LoginContext, LoginPlugin } from '@owlmeans/client-auth/login'
1
+ import { surrogatePath, LoginIntent, LoginOutcome, LOGIN_LOGOUT_MESSAGE, LOGIN_SURROGATE_HEIGHT, LOGIN_SURROGATE_NAME, LOGIN_SURROGATE_WIDTH, LOGIN_TOKEN_MESSAGE, loginEnvHelper, loginTokenOf } from '@owlmeans/client-auth/login'
2
+ import type { LoginContext, LoginEnv, LoginPlugin } from '@owlmeans/client-auth/login'
6
3
  import { SURROGATE_LOGIN, SURROGATE_LOGIN_PRIORITY } from './consts.js'
4
+ import { awaitSurrogate } from './pump.js'
5
+
6
+ /**
7
+ * `window.open` features that size and CENTER the surrogate on the screen the browser window
8
+ * currently occupies.
9
+ *
10
+ * Computed fresh per call, never a constant: centering depends on where the browser window sits
11
+ * NOW, which can change between one login attempt and the next. `screenLeft`/`screenTop` and
12
+ * `outerWidth`/`outerHeight` describe the browser's own chrome rather than this document's
13
+ * viewport — and, unlike `innerWidth` or anything reached through `window.top`, they stay readable
14
+ * from a cross-origin iframe. That matters here specifically: a generated app's preview runs
15
+ * framed on a different origin than the manager that embeds it, so `env.embedded` is exactly the
16
+ * case where `window.top.innerWidth` would throw, and reading this document's own `innerWidth`
17
+ * instead would center the popup on the IFRAME's rectangle rather than on the actual window a
18
+ * person sees. Falling back to `window.screen` covers a window that reports no outer geometry at
19
+ * all (a headless test runner).
20
+ */
21
+ export const centeredPopupFeatures = (width: number, height: number): string => {
22
+ const screenLeft = window.screenLeft ?? window.screenX ?? 0
23
+ const screenTop = window.screenTop ?? window.screenY ?? 0
24
+ const outerWidth = window.outerWidth || window.screen.width
25
+ const outerHeight = window.outerHeight || window.screen.height
26
+
27
+ const left = Math.round(screenLeft + Math.max(0, (outerWidth - width) / 2))
28
+ const top = Math.round(screenTop + Math.max(0, (outerHeight - height) / 2))
29
+
30
+ return `popup=yes,width=${width},height=${height},left=${left},top=${top}`
31
+ }
7
32
 
8
33
  /**
9
34
  * Login for a document that cannot complete the flow where it is.
@@ -23,101 +48,160 @@ import { SURROGATE_LOGIN, SURROGATE_LOGIN_PRIORITY } from './consts.js'
23
48
  * top-level, so it authorizes by an ordinary redirect and differs only in what it does with the
24
49
  * token at the end.
25
50
  */
26
- export const makeSurrogateLoginPlugin = (): LoginPlugin => ({
27
- alias: SURROGATE_LOGIN,
28
- priority: SURROGATE_LOGIN_PRIORITY,
29
- mode: 'surrogate',
30
- match: env => env.hasWindow && (env.embedded || env.surrogate),
51
+ export const makeSurrogateLoginPlugin = (): LoginPlugin => {
52
+ /**
53
+ * Give the token to whoever is entitled to it, and get out of the way.
54
+ *
55
+ * Shared by `complete` (a token was just issued here) and `resume` (one was already here),
56
+ * because what happens to it is the same in both cases and only the question differs.
57
+ */
58
+ const handBack = async (token: string, env: LoginEnv): Promise<LoginOutcome> => {
59
+ if (!env.surrogate) {
60
+ // This document started the flow and finished it in place — it already has what it needs.
61
+ return LoginOutcome.Passed
62
+ }
63
+ if (!env.hasOpener) {
64
+ // Authenticated with nowhere to send it. `Cross-Origin-Opener-Policy: same-origin` from the
65
+ // provider severs `window.opener` permanently, so this is not a timing problem to retry.
66
+ return LoginOutcome.Orphaned
67
+ }
31
68
 
32
- enter: () => { markSurrogate() },
69
+ loginEnvHelper.clearSurrogate()
70
+ // Both windows are the same origin (the surrogate is this application's own login route), so
71
+ // the origin is pinned rather than passed as `*` — the message carries a bearer token.
72
+ window.opener.postMessage({ type: LOGIN_TOKEN_MESSAGE, token }, window.location.origin)
73
+ window.close()
33
74
 
34
- begin: (ctx, request, env) => {
35
- // Already one window up — nothing to open, just run the flow.
36
- if (env.surrogate) {
37
- window.location.href = request.url
75
+ return LoginOutcome.Handled
76
+ }
38
77
 
39
- return Promise.resolve(LoginOutcome.Redirected)
40
- }
78
+ const plugin: LoginPlugin = {
79
+ alias: SURROGATE_LOGIN,
80
+ priority: SURROGATE_LOGIN_PRIORITY,
81
+ mode: 'surrogate',
82
+ match: env => env.hasWindow && (env.embedded || env.surrogate),
41
83
 
42
- // Opened FIRST and synchronously: `window.open` escapes the popup blocker only while the user
43
- // gesture is still being handled, so nothing may be awaited before this line. That is why this
44
- // function is not `async`.
45
- const surrogate = window.open(request.url, LOGIN_SURROGATE_NAME, LOGIN_SURROGATE_FEATURES)
46
- if (surrogate == null) {
47
- return Promise.resolve(LoginOutcome.Failed)
48
- }
84
+ enter: () => { loginEnvHelper.markSurrogate() },
49
85
 
50
- return new Promise<LoginOutcome>(resolve => {
51
- let settled = false
52
- let watch: ReturnType<typeof setInterval> | undefined
86
+ begin: (ctx, request, env) => {
87
+ // Already one window up — nothing to open, just run the flow.
88
+ if (env.surrogate) {
89
+ window.location.href = request.url
53
90
 
54
- const finish = (result: LoginOutcome): void => {
55
- if (settled) {
56
- return
57
- }
58
- settled = true
59
- window.removeEventListener('message', onMessage)
60
- if (watch != null) {
61
- clearInterval(watch)
62
- }
63
- resolve(result)
91
+ return Promise.resolve(LoginOutcome.Redirected)
64
92
  }
65
93
 
66
- function onMessage(event: MessageEvent): void {
67
- if (event.origin !== window.location.origin) {
68
- return
69
- }
70
- const data = event.data as { type?: string, token?: string } | null
71
- if (data?.type !== LOGIN_TOKEN_MESSAGE || data.token == null || data.token === '') {
72
- return
73
- }
74
- void adoptToken(ctx as LoginContext, data.token)
75
- .then(() => finish(LoginOutcome.Handled))
76
- .catch(() => finish(LoginOutcome.Failed))
94
+ // Opened FIRST and synchronously: `window.open` escapes the popup blocker only while the
95
+ // user gesture is still being handled, so nothing may be awaited before this line. That is
96
+ // why this function is not `async`.
97
+ //
98
+ // The window opens on the surrogate route rather than on the dispatcher, so its very first
99
+ // paint is a bare "signing you in" panel instead of the application with its navigation. The
100
+ // dispatcher address travels as `next`, because the provider's callback still lands there and
101
+ // whatever flow parameters the caller's URL carried must survive the hop.
102
+ const path = surrogatePath(ctx as LoginContext, {
103
+ intent: LoginIntent.Login, next: request.url,
104
+ })
105
+ const surrogate = window.open(
106
+ path ?? request.url, LOGIN_SURROGATE_NAME,
107
+ centeredPopupFeatures(LOGIN_SURROGATE_WIDTH, LOGIN_SURROGATE_HEIGHT)
108
+ )
109
+ if (surrogate == null) {
110
+ // Distinct from `Failed` below: nothing ever opened, so there is nothing left to wait on.
111
+ return Promise.resolve(LoginOutcome.Blocked)
77
112
  }
78
113
 
79
- window.addEventListener('message', onMessage)
80
- // A window the user simply closes announces nothing, so the only way to stop waiting on it
81
- // is to watch for it going away.
82
- watch = setInterval(() => {
83
- if (surrogate.closed) {
84
- finish(LoginOutcome.Failed)
114
+ return awaitSurrogate(surrogate, LOGIN_TOKEN_MESSAGE, async data => {
115
+ if (data.token == null || data.token === '') {
116
+ return LoginOutcome.Failed
85
117
  }
86
- }, LOGIN_WATCH_INTERVAL)
87
- })
88
- },
118
+ await loginTokenOf(ctx as LoginContext).adoptToken(data.token)
119
+ // Adopting the token is not the end of the flow — running the caller's continuation is,
120
+ // exactly as the redirect plugin does before it reports `Handled`. Without it the token
121
+ // lands in the auth service and nothing else happens: the framed app keeps rendering its
122
+ // signed-out tree, and the user watches the login window close over a preview that never
123
+ // changes. `Handled` promises the caller there is nothing left to do.
124
+ await request.navigate?.()
89
125
 
90
- authorize: async (_ctx, url, env) => {
91
- // The surrogate is top-level, so it redirects like any ordinary tab.
92
- if (env.surrogate) {
93
- window.location.href = url
126
+ return LoginOutcome.Handled
127
+ })
128
+ },
94
129
 
95
- return LoginOutcome.Redirected
96
- }
130
+ authorize: async (_ctx, url, env) => {
131
+ // The surrogate is top-level, so it redirects like any ordinary tab.
132
+ if (env.surrogate) {
133
+ window.location.href = url
97
134
 
98
- // Framed, and not yet one window up. A redirect from here would land in the frame and be
99
- // refused, and the window that would fix it cannot be opened without a user gesture — so the
100
- // caller is told to render a control rather than to navigate.
101
- return LoginOutcome.Gesture
102
- },
135
+ return LoginOutcome.Redirected
136
+ }
103
137
 
104
- complete: async (_ctx, token, env) => {
105
- if (!env.surrogate) {
106
- // This document started the flow and finished it in place — it already has what it needs.
107
- return LoginOutcome.Passed
108
- }
109
- if (!env.hasOpener) {
110
- // Authenticated with nowhere to send it. `Cross-Origin-Opener-Policy: same-origin` from the
111
- // provider severs `window.opener` permanently, so this is not a timing problem to retry.
112
- return LoginOutcome.Orphaned
113
- }
138
+ // Framed, and not yet one window up. A redirect from here would land in the frame and be
139
+ // refused, and the window that would fix it cannot be opened without a user gesture — so the
140
+ // caller is told to render a control rather than to navigate.
141
+ return LoginOutcome.Gesture
142
+ },
114
143
 
115
- clearSurrogate()
116
- // Both windows are the same origin (the surrogate is this application's own login route), so
117
- // the origin is pinned rather than passed as `*` — the message carries a bearer token.
118
- window.opener.postMessage({ type: LOGIN_TOKEN_MESSAGE, token }, window.location.origin)
119
- window.close()
144
+ complete: async (_ctx, token, env) => await handBack(token, env),
120
145
 
121
- return LoginOutcome.Handled
122
- },
123
- })
146
+ // A session that was already here is worth exactly as much as one just issued — to the window
147
+ // that asked for it. Without this the surrogate would render the signed-in application to
148
+ // itself and the framed app would stay signed out, which is the whole reported defect.
149
+ resume: async (_ctx, token, env) => await handBack(token, env),
150
+
151
+ logout: (ctx, request, env) => {
152
+ // Already one window up — end the session here and tell the opener.
153
+ if (env.surrogate) {
154
+ return loginTokenOf(ctx as LoginContext).revokeToken()
155
+ .then(async () => await plugin.logoutComplete!(ctx, env))
156
+ }
157
+
158
+ const path = surrogatePath(ctx as LoginContext, { intent: LoginIntent.Logout })
159
+ // Opened first and synchronously, for the same reason `begin` does.
160
+ const surrogate = path != null
161
+ ? window.open(path, LOGIN_SURROGATE_NAME,
162
+ centeredPopupFeatures(LOGIN_SURROGATE_WIDTH, LOGIN_SURROGATE_HEIGHT))
163
+ : null
164
+
165
+ // The local session goes next, and UNCONDITIONALLY. A blocked window, a severed opener or a
166
+ // user who closes the popup must never leave THIS document signed in: a logout that only
167
+ // half happened is bad, and one that did not happen at all is worse.
168
+ const local = loginTokenOf(ctx as LoginContext).revokeToken()
169
+ if (surrogate == null) {
170
+ // `path == null` is an app whose entrypoint list predates the surrogate route — nothing a
171
+ // retry would do differently. A `window.open` the browser refused is the one case worth
172
+ // telling the user about, so only that one reports `Blocked`.
173
+ const outcome = path != null ? LoginOutcome.Blocked : LoginOutcome.Failed
174
+
175
+ return local.then(async () => {
176
+ await request.navigate?.()
177
+
178
+ return outcome
179
+ })
180
+ }
181
+
182
+ return local.then(async () => await awaitSurrogate(
183
+ surrogate, LOGIN_LOGOUT_MESSAGE, async () => {
184
+ await request.navigate?.()
185
+
186
+ return LoginOutcome.Handled
187
+ }
188
+ ))
189
+ },
190
+
191
+ logoutComplete: async (_ctx, env) => {
192
+ if (!env.surrogate) {
193
+ return LoginOutcome.Passed
194
+ }
195
+ if (!env.hasOpener) {
196
+ return LoginOutcome.Orphaned
197
+ }
198
+ loginEnvHelper.clearSurrogate()
199
+ window.opener.postMessage({ type: LOGIN_LOGOUT_MESSAGE, ok: true }, window.location.origin)
200
+ window.close()
201
+
202
+ return LoginOutcome.Handled
203
+ },
204
+ }
205
+
206
+ return plugin
207
+ }
@@ -0,0 +1,10 @@
1
+ import { LoginIntent } from '@owlmeans/client-auth/login'
2
+ import { SurrogateStage } from './consts.js'
3
+
4
+ export interface SurrogateViewProps {
5
+ stage: SurrogateStage
6
+ intent: LoginIntent
7
+ onAction?: () => void
8
+ error?: string
9
+ translate?: (key: string, defaultValue: string) => string
10
+ }
@@ -0,0 +1,22 @@
1
+ import type { FC } from 'react'
2
+ import { LoginIntent } from '@owlmeans/client-auth/login'
3
+ import { action, box, COPY, LOGOUT_COPY } from './consts.local.js'
4
+ import type { SurrogateViewProps } from './types.js'
5
+
6
+ export const LoginSurrogateView: FC<SurrogateViewProps> = (
7
+ { stage, intent, onAction, error, translate }
8
+ ) => {
9
+ const t = translate ?? ((_key: string, defaultValue: string) => defaultValue)
10
+ const scope = intent === LoginIntent.Logout ? 'logout' : 'login'
11
+ const base = COPY[stage]
12
+ const copy = (intent === LoginIntent.Logout ? LOGOUT_COPY[stage] : undefined) ?? base
13
+
14
+ return <div style={box} data-surrogate-stage={stage} data-surrogate-intent={intent}>
15
+ <p>{t(`surrogate.${scope}.${copy.key}`, copy.en)}</p>
16
+ {error != null && error !== '' &&
17
+ <p style={{ color: '#b00', fontSize: '.875rem' }} role="alert">{error}</p>}
18
+ {base.action != null && onAction != null && <button type="button" style={action} onClick={onAction}>
19
+ {t(`surrogate.${scope}.${base.action[0]}`, base.action[1])}
20
+ </button>}
21
+ </div>
22
+ }
package/src/main.tsx CHANGED
@@ -9,9 +9,6 @@ export const render = (node: ReactNode, opts?: RenderOptions) => {
9
9
  const _callback = () => {
10
10
  const key = opts?.domId ?? DEFAULT_ROOT
11
11
  const root = document.getElementById(key)
12
- if (opts?.debug) {
13
- console.debug(`Render react app to ${key}`)
14
- }
15
12
  if (root == null) {
16
13
  throw new Error(`Root element not found with id: ${key}`)
17
14
  }
@@ -22,8 +19,24 @@ export const render = (node: ReactNode, opts?: RenderOptions) => {
22
19
  }
23
20
  }
24
21
 
25
- opts?.onReady ?? true ? window.addEventListener('DOMContentLoaded', _callback) : _callback()
22
+ // Wait for the DOM only while it is still loading: a caller that awaits something first
23
+ // (`await prepareI18n(cfg); render(...)`) can run after `DOMContentLoaded` has already fired,
24
+ // and a listener added then never runs — the app would never mount.
25
+ if ((opts?.onReady ?? true) && document.readyState === 'loading') {
26
+ window.addEventListener('DOMContentLoaded', _callback, { once: true })
27
+ } else {
28
+ _callback()
29
+ }
26
30
  }
27
31
 
28
- export const renderApp = <C extends AppConfig, T extends AppContext<C>>(context: T, opts?: RenderOptions) =>
29
- render(<WebApp context={context} />, opts)
32
+ /**
33
+ * Mount the application.
34
+ *
35
+ * `children` are rendered inside the context provider and BEFORE the router, which makes them the
36
+ * app's global overlay slot: a consent dialog, a toast surface, anything that must exist on every
37
+ * route and outlive navigation. A component mounted inside a route instead is torn down and
38
+ * rebuilt on every navigation, which for a dialog means it closes itself.
39
+ */
40
+ export const renderApp = <C extends AppConfig, T extends AppContext<C>>(
41
+ context: T, opts?: RenderOptions, children?: ReactNode
42
+ ) => render(<WebApp context={context}>{children}</WebApp>, opts)
package/src/middleware.ts CHANGED
@@ -1,28 +1,28 @@
1
- import type { Middleware } from '@owlmeans/context'
2
- import { MiddlewareType, MiddlewareStage, AppType } from '@owlmeans/context'
3
- import type { ClientEntrypoint } from '@owlmeans/client-entrypoint'
1
+ import { type Middleware, MiddlewareType, MiddlewareStage, AppType } from '@owlmeans/context'
4
2
  import { AuthUnknown } from '@owlmeans/auth'
5
3
  import { ResilientError } from '@owlmeans/error'
6
4
  import { DEFAULT_ALIAS } from '@owlmeans/client-auth'
7
5
  import { AuthService } from '@owlmeans/auth-common'
6
+ import type { Perked } from './types.local.js'
8
7
 
9
8
  export const logoutMiddleware: Middleware = {
10
9
  type: MiddlewareType.Context,
11
10
  stage: MiddlewareStage.Loading,
12
11
  apply: async context => {
13
12
  context.entrypoints<Perked>().forEach(module => {
14
- if (module.route.route.type === AppType.Backend && module.call != null) {
13
+ if (module.route.route.type === AppType.Backend && module.invoke != null) {
15
14
  const guards = module.getGuards()
16
15
  if (guards.length > 0) {
17
16
  if (module._auth_web_middleware_applied === true) {
18
17
  return
19
18
  }
20
19
  module._auth_web_middleware_applied = true
21
- // const auth = context.service<GuardService>(service)
22
- const call = module.call
23
- module.call = async (req, res) => {
20
+ // Wrapping `invoke` covers `call` too: `call` reads `invoke` off the entrypoint at the
21
+ // moment it runs, so a rejected session is caught whichever verb the caller used.
22
+ const invoke = module.invoke
23
+ module.invoke = (async req => {
24
24
  try {
25
- return await call(req, res)
25
+ return await invoke(req)
26
26
  } catch (e) {
27
27
  if (e instanceof Error) {
28
28
  const err = ResilientError.ensure(e)
@@ -38,7 +38,7 @@ export const logoutMiddleware: Middleware = {
38
38
  }
39
39
  throw e
40
40
  }
41
- }
41
+ }) as typeof module.invoke
42
42
 
43
43
  }
44
44
  }
@@ -46,6 +46,3 @@ export const logoutMiddleware: Middleware = {
46
46
  }
47
47
  }
48
48
 
49
- interface Perked extends ClientEntrypoint<unknown> {
50
- _auth_web_middleware_applied?: boolean
51
- }
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
  }
@@ -0,0 +1,5 @@
1
+ import type { ClientEntrypoint } from '@owlmeans/client-entrypoint'
2
+
3
+ export interface Perked extends ClientEntrypoint<unknown> {
4
+ _auth_web_middleware_applied?: boolean
5
+ }