@owlmeans/client-auth 0.1.19-rc.0 → 0.1.20-alpha.0

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 (74) hide show
  1. package/README.md +2 -2
  2. package/agent-meta/manifest.json +4 -2
  3. package/agent-meta/skills/client-auth/SKILL.md +36 -3
  4. package/agent-meta/skills/client-auth/parts/login.md +2 -1
  5. package/build/components/dispatcher/component.d.ts.map +1 -1
  6. package/build/components/dispatcher/component.js +44 -5
  7. package/build/components/dispatcher/component.js.map +1 -1
  8. package/build/login/consts.local.d.ts +4 -0
  9. package/build/login/consts.local.d.ts.map +1 -1
  10. package/build/login/consts.local.js +8 -0
  11. package/build/login/consts.local.js.map +1 -1
  12. package/build/login/failure/consts.d.ts +17 -0
  13. package/build/login/failure/consts.d.ts.map +1 -0
  14. package/build/login/failure/consts.js +18 -0
  15. package/build/login/failure/consts.js.map +1 -0
  16. package/build/login/failure/consts.local.d.ts +17 -0
  17. package/build/login/failure/consts.local.d.ts.map +1 -0
  18. package/build/login/failure/consts.local.js +30 -0
  19. package/build/login/failure/consts.local.js.map +1 -0
  20. package/build/login/failure/types.d.ts +57 -0
  21. package/build/login/failure/types.d.ts.map +1 -0
  22. package/build/login/failure/types.js +2 -0
  23. package/build/login/failure/types.js.map +1 -0
  24. package/build/login/failure-view.d.ts +14 -0
  25. package/build/login/failure-view.d.ts.map +1 -0
  26. package/build/login/failure-view.js +19 -0
  27. package/build/login/failure-view.js.map +1 -0
  28. package/build/login/failure.d.ts +4 -0
  29. package/build/login/failure.d.ts.map +1 -0
  30. package/build/login/failure.js +50 -0
  31. package/build/login/failure.js.map +1 -0
  32. package/build/login/i18n/be.json +7 -1
  33. package/build/login/i18n/de.json +7 -1
  34. package/build/login/i18n/en.json +7 -1
  35. package/build/login/i18n/es.json +7 -1
  36. package/build/login/i18n/fr.json +7 -1
  37. package/build/login/i18n/pl.json +7 -1
  38. package/build/login/i18n/ru.json +7 -1
  39. package/build/login/i18n/uk.json +7 -1
  40. package/build/login/index.d.ts +5 -0
  41. package/build/login/index.d.ts.map +1 -1
  42. package/build/login/index.js +4 -0
  43. package/build/login/index.js.map +1 -1
  44. package/build/login/screen.d.ts.map +1 -1
  45. package/build/login/screen.js +9 -2
  46. package/build/login/screen.js.map +1 -1
  47. package/build/login/translate.d.ts +15 -0
  48. package/build/login/translate.d.ts.map +1 -0
  49. package/build/login/translate.js +26 -0
  50. package/build/login/translate.js.map +1 -0
  51. package/build/login/types.d.ts +7 -0
  52. package/build/login/types.d.ts.map +1 -1
  53. package/package.json +27 -24
  54. package/src/components/dispatcher/component.tsx +48 -5
  55. package/src/login/consts.local.ts +10 -0
  56. package/src/login/failure/consts.local.ts +35 -0
  57. package/src/login/failure/consts.ts +16 -0
  58. package/src/login/failure/types.ts +60 -0
  59. package/src/login/failure-view.tsx +35 -0
  60. package/src/login/failure.ts +67 -0
  61. package/src/login/i18n/be.json +7 -1
  62. package/src/login/i18n/de.json +7 -1
  63. package/src/login/i18n/en.json +7 -1
  64. package/src/login/i18n/es.json +7 -1
  65. package/src/login/i18n/fr.json +7 -1
  66. package/src/login/i18n/pl.json +7 -1
  67. package/src/login/i18n/ru.json +7 -1
  68. package/src/login/i18n/uk.json +7 -1
  69. package/src/login/index.ts +5 -0
  70. package/src/login/screen.tsx +13 -1
  71. package/src/login/translate.ts +32 -0
  72. package/src/login/types.ts +8 -0
  73. package/src/manager/README.md +1 -1
  74. package/tests/failure.spec.tsx +176 -0
@@ -0,0 +1,35 @@
1
+ import type { FC } from 'react'
2
+ import { loginFailureHelper } from './failure.js'
3
+ import { box, failureAction, page } from './consts.local.js'
4
+ import type { LoginFailureViewProps } from './failure/types.js'
5
+
6
+ /**
7
+ * The end state of a sign-in that did not complete: one phrased sentence, the incident reference of
8
+ * a failure, and the way on — back to the sign-in choice for a refusal, the same attempt again
9
+ * otherwise.
10
+ *
11
+ * Plain like `FallbackLoginScreen` — raw elements, inline styles, no class a consumer's stylesheet
12
+ * must scan for — so any OwlMeans surface can end on it, whatever UI family it belongs to. Hooks for
13
+ * tests and styling: `[data-login-failure="<kind>"]`, `[data-login-failure-message]`,
14
+ * `[data-login-failure-incident]`, `[data-login-failure-action]`.
15
+ */
16
+ export const LoginFailureView: FC<LoginFailureViewProps> = ({ failure, translate, onAction, page: full, style }) => {
17
+ const phrase = loginFailureHelper.phrase(failure, translate)
18
+
19
+ const notice = <div
20
+ role="alert"
21
+ data-login-failure={failure.kind}
22
+ data-login-refused={failure.refused ? 'true' : undefined}
23
+ style={{ ...box, ...style }}
24
+ >
25
+ <p data-login-failure-message style={{ margin: 0 }}>{phrase.message}</p>
26
+ {phrase.incident != null && <p data-login-failure-incident style={{ marginTop: '.5rem', fontSize: '.75rem', opacity: .7 }}>
27
+ {phrase.incident}
28
+ </p>}
29
+ {onAction != null && <button type="button" data-login-failure-action style={failureAction} onClick={onAction}>
30
+ {phrase.action}
31
+ </button>}
32
+ </div>
33
+
34
+ return full === true ? <div data-login-failure-page style={page}>{notice}</div> : notice
35
+ }
@@ -0,0 +1,67 @@
1
+ import { AuthRefused } from '@owlmeans/auth'
2
+ import { apiStatusHelper } from '@owlmeans/api/status'
3
+ import { LoginFailureKind } from './failure/consts.js'
4
+ import {
5
+ BACK_COPY, FAILURE_COPY, INCIDENT_COPY, INCIDENT_PLACEHOLDER, REFUSAL_CODES, RETRY_COPY,
6
+ } from './failure/consts.local.js'
7
+ import type { LoginFailure, LoginFailureHelper, LoginFailurePhrase } from './failure/types.js'
8
+ import type { LoginTranslate } from './types.js'
9
+
10
+ export const createLoginFailureHelper = (): LoginFailureHelper => {
11
+ const english: LoginTranslate = (_key, defaultValue) => defaultValue
12
+
13
+ /** The message and type a caught value carries — a non-object carries none. */
14
+ const textOf = (error: unknown): string => {
15
+ if (error == null || typeof error !== 'object') {
16
+ return typeof error === 'string' ? error : ''
17
+ }
18
+ const { message, type } = error as { message?: unknown, type?: unknown }
19
+
20
+ return [message, type].filter((part): part is string => typeof part === 'string').join(' ')
21
+ }
22
+
23
+ const codeOf = (error: unknown): LoginFailureKind | null => {
24
+ const text = textOf(error)
25
+
26
+ return REFUSAL_CODES.find(([marker]) => marker.test(text))?.[1] ?? null
27
+ }
28
+
29
+ /** `AuthRefused` of this or another module copy: `instanceof` answers across copies by type name. */
30
+ const isRefusedClass = (error: unknown): boolean =>
31
+ error instanceof AuthRefused
32
+ || (error != null && typeof error === 'object' && (error as { type?: unknown }).type === AuthRefused.typeName)
33
+
34
+ const failureOf = (error: unknown): LoginFailure => {
35
+ const kind = codeOf(error)
36
+ ?? (isRefusedClass(error) || apiStatusHelper.isAccessDenied(error) ? LoginFailureKind.Refused : LoginFailureKind.Failed)
37
+ const [key, fallback] = FAILURE_COPY[kind]
38
+
39
+ return {
40
+ kind,
41
+ refused: kind !== LoginFailureKind.Failed,
42
+ key,
43
+ fallback,
44
+ incidentId: apiStatusHelper.incidentIdOf(error),
45
+ }
46
+ }
47
+
48
+ const isRefusal = (error: unknown): boolean => failureOf(error).refused
49
+
50
+ const phrase = (failure: LoginFailure, translate: LoginTranslate = english): LoginFailurePhrase => {
51
+ const [actionKey, actionFallback] = failure.refused ? BACK_COPY : RETRY_COPY
52
+ // A refusal is the person's condition, not an incident to report: no reference line.
53
+ const incident = !failure.refused && failure.incidentId != null
54
+ ? translate(INCIDENT_COPY[0], INCIDENT_COPY[1]).split(INCIDENT_PLACEHOLDER).join(failure.incidentId)
55
+ : null
56
+
57
+ return {
58
+ message: translate(failure.key, failure.fallback),
59
+ incident,
60
+ action: translate(actionKey, actionFallback),
61
+ }
62
+ }
63
+
64
+ return { failureOf, isRefusal, phrase }
65
+ }
66
+
67
+ export const loginFailureHelper = createLoginFailureHelper()
@@ -34,7 +34,13 @@
34
34
  },
35
35
  "error": {
36
36
  "failed": "Уваход не завершаны. Паспрабуйце яшчэ раз.",
37
- "blocked": "Акно ўваходу заблакавана. Націсніце значок заблакаванага ўсплывальнага акна ў адрасным радку браўзера, каб адкрыць яго."
37
+ "blocked": "Акно ўваходу заблакавана. Націсніце значок заблакаванага ўсплывальнага акна ў адрасным радку браўзера, каб адкрыць яго.",
38
+ "refused": "З гэтым акаўнтам тут увайсці нельга.",
39
+ "account-disabled": "Гэты акаўнт адключаны, таму ўвайсці з ім тут нельга.",
40
+ "profile-disabled": "Доступ гэтага акаўнта да праграмы заблакаваны, таму ўвайсці з ім тут нельга.",
41
+ "retry": "Паспрабаваць зноў",
42
+ "back": "Вярнуцца да ўваходу",
43
+ "incident": "Код звароту: {{incident}}"
38
44
  },
39
45
  "orphaned": "Уваход выкананы. Можаце закрыць гэтае акно і вярнуцца ў праграму."
40
46
  }
@@ -34,7 +34,13 @@
34
34
  },
35
35
  "error": {
36
36
  "failed": "Die Anmeldung wurde nicht abgeschlossen. Bitte versuchen Sie es erneut.",
37
- "blocked": "Das Anmeldefenster wurde blockiert. Klicken Sie auf das Symbol für blockierte Pop-ups in der Adressleiste Ihres Browsers, um es zu öffnen."
37
+ "blocked": "Das Anmeldefenster wurde blockiert. Klicken Sie auf das Symbol für blockierte Pop-ups in der Adressleiste Ihres Browsers, um es zu öffnen.",
38
+ "refused": "Mit diesem Konto ist hier keine Anmeldung möglich.",
39
+ "account-disabled": "Dieses Konto wurde deaktiviert; eine Anmeldung ist hier nicht möglich.",
40
+ "profile-disabled": "Der Zugang dieses Kontos zu dieser Anwendung wurde gesperrt; eine Anmeldung ist hier nicht möglich.",
41
+ "retry": "Erneut versuchen",
42
+ "back": "Zurück zur Anmeldung",
43
+ "incident": "Referenz: {{incident}}"
38
44
  },
39
45
  "orphaned": "Angemeldet. Sie können dieses Fenster schließen und in der Anwendung fortfahren."
40
46
  }
@@ -34,7 +34,13 @@
34
34
  },
35
35
  "error": {
36
36
  "failed": "Sign-in did not complete. Please try again.",
37
- "blocked": "The sign-in window was blocked. Click the blocked pop-up icon in your browser's address bar to open it."
37
+ "blocked": "The sign-in window was blocked. Click the blocked pop-up icon in your browser's address bar to open it.",
38
+ "refused": "This account cannot sign in here.",
39
+ "account-disabled": "This account has been disabled and cannot sign in here.",
40
+ "profile-disabled": "This account’s access to this application has been blocked, so it cannot sign in here.",
41
+ "retry": "Try again",
42
+ "back": "Back to sign-in",
43
+ "incident": "Reference: {{incident}}"
38
44
  },
39
45
  "orphaned": "Signed in. You can close this window and continue in the application."
40
46
  }
@@ -34,7 +34,13 @@
34
34
  },
35
35
  "error": {
36
36
  "failed": "El inicio de sesión no se completó. Inténtalo de nuevo.",
37
- "blocked": "Se bloqueó la ventana de inicio de sesión. Haz clic en el icono de ventana emergente bloqueada en la barra de direcciones del navegador para abrirla."
37
+ "blocked": "Se bloqueó la ventana de inicio de sesión. Haz clic en el icono de ventana emergente bloqueada en la barra de direcciones del navegador para abrirla.",
38
+ "refused": "Esta cuenta no puede iniciar sesión aquí.",
39
+ "account-disabled": "Esta cuenta ha sido desactivada y no puede iniciar sesión aquí.",
40
+ "profile-disabled": "El acceso de esta cuenta a esta aplicación ha sido bloqueado, por lo que no puede iniciar sesión aquí.",
41
+ "retry": "Intentar de nuevo",
42
+ "back": "Volver al inicio de sesión",
43
+ "incident": "Referencia: {{incident}}"
38
44
  },
39
45
  "orphaned": "Sesión iniciada. Puedes cerrar esta ventana y continuar en la aplicación."
40
46
  }
@@ -34,7 +34,13 @@
34
34
  },
35
35
  "error": {
36
36
  "failed": "La connexion n’a pas abouti. Réessayez.",
37
- "blocked": "La fenêtre de connexion a été bloquée. Cliquez sur l’icône de fenêtre contextuelle bloquée dans la barre d’adresse pour l’ouvrir."
37
+ "blocked": "La fenêtre de connexion a été bloquée. Cliquez sur l’icône de fenêtre contextuelle bloquée dans la barre d’adresse pour l’ouvrir.",
38
+ "refused": "Ce compte ne peut pas se connecter ici.",
39
+ "account-disabled": "Ce compte a été désactivé et ne peut pas se connecter ici.",
40
+ "profile-disabled": "L’accès de ce compte à cette application a été bloqué ; il ne peut donc pas se connecter ici.",
41
+ "retry": "Réessayer",
42
+ "back": "Retour à la connexion",
43
+ "incident": "Référence : {{incident}}"
38
44
  },
39
45
  "orphaned": "Vous êtes connecté. Vous pouvez fermer cette fenêtre et continuer dans l’application."
40
46
  }
@@ -34,7 +34,13 @@
34
34
  },
35
35
  "error": {
36
36
  "failed": "Logowanie nie zostało ukończone. Spróbuj ponownie.",
37
- "blocked": "Okno logowania zostało zablokowane. Kliknij ikonę zablokowanego okienka w pasku adresu przeglądarki, aby je otworzyć."
37
+ "blocked": "Okno logowania zostało zablokowane. Kliknij ikonę zablokowanego okienka w pasku adresu przeglądarki, aby je otworzyć.",
38
+ "refused": "To konto nie może się tutaj zalogować.",
39
+ "account-disabled": "To konto zostało wyłączone i nie może się tutaj zalogować.",
40
+ "profile-disabled": "Dostęp tego konta do tej aplikacji został zablokowany, więc nie może się ono tutaj zalogować.",
41
+ "retry": "Spróbuj ponownie",
42
+ "back": "Wróć do logowania",
43
+ "incident": "Kod zgłoszenia: {{incident}}"
38
44
  },
39
45
  "orphaned": "Zalogowano. Możesz zamknąć to okno i kontynuować w aplikacji."
40
46
  }
@@ -34,7 +34,13 @@
34
34
  },
35
35
  "error": {
36
36
  "failed": "Вход не завершён. Попробуйте ещё раз.",
37
- "blocked": "Окно входа заблокировано. Нажмите на значок заблокированного всплывающего окна в адресной строке браузера, чтобы открыть его."
37
+ "blocked": "Окно входа заблокировано. Нажмите на значок заблокированного всплывающего окна в адресной строке браузера, чтобы открыть его.",
38
+ "refused": "С этим аккаунтом здесь войти нельзя.",
39
+ "account-disabled": "Этот аккаунт отключён, поэтому войти с ним здесь нельзя.",
40
+ "profile-disabled": "Доступ этого аккаунта к приложению заблокирован, поэтому войти с ним здесь нельзя.",
41
+ "retry": "Попробовать снова",
42
+ "back": "Вернуться ко входу",
43
+ "incident": "Код обращения: {{incident}}"
38
44
  },
39
45
  "orphaned": "Вход выполнен. Можете закрыть это окно и вернуться в приложение."
40
46
  }
@@ -34,7 +34,13 @@
34
34
  },
35
35
  "error": {
36
36
  "failed": "Вхід не завершено. Спробуйте ще раз.",
37
- "blocked": "Вікно входу заблоковано. Натисніть значок заблокованого спливаючого вікна в адресному рядку браузера, щоб відкрити його."
37
+ "blocked": "Вікно входу заблоковано. Натисніть значок заблокованого спливаючого вікна в адресному рядку браузера, щоб відкрити його.",
38
+ "refused": "З цим обліковим записом тут увійти не можна.",
39
+ "account-disabled": "Цей обліковий запис вимкнено, тому увійти з ним тут не можна.",
40
+ "profile-disabled": "Доступ цього облікового запису до застосунку заблоковано, тому увійти з ним тут не можна.",
41
+ "retry": "Спробувати ще раз",
42
+ "back": "Повернутися до входу",
43
+ "incident": "Код звернення: {{incident}}"
38
44
  },
39
45
  "orphaned": "Вхід виконано. Можете закрити це вікно й повернутися до застосунку."
40
46
  }
@@ -11,6 +11,11 @@ export type * from './credit/types.js'
11
11
  export type * from './surrogate/types.js'
12
12
  export type * from './land/types.js'
13
13
  export type * from './start/types.js'
14
+ export type * from './failure/types.js'
15
+ export * from './failure/consts.js'
16
+ export * from './failure.js'
17
+ export * from './failure-view.js'
18
+ export * from './translate.js'
14
19
  export * from './env.js'
15
20
  export * from './adopt.js'
16
21
  export * from './methods.js'
@@ -8,6 +8,9 @@ import { loginMethodsHelper } from './methods.js'
8
8
  import { loginTermsHelper } from './terms.js'
9
9
  import { resolveCredit } from './credit.js'
10
10
  import { box, page } from './consts.local.js'
11
+ import { loginFailureHelper } from './failure.js'
12
+ import { LoginFailureView } from './failure-view.js'
13
+ import type { LoginFailure } from './failure/types.js'
11
14
 
12
15
  const renderParts = (parts: TermsSentencePart[]): ReactNode =>
13
16
  parts.map((part, index) => part.href != null
@@ -49,6 +52,8 @@ export const FallbackLoginScreen: FC<LoginScreenProps> = props => {
49
52
 
50
53
  const [accepted, setAccepted] = useState(() => loginTermsHelper.termsAccepted(resolved))
51
54
  const [attempted, setAttempted] = useState(false)
55
+ // A start that rejected — phrased, never the raw message; the method buttons are the retry.
56
+ const [failure, setFailure] = useState<LoginFailure | null>(null)
52
57
 
53
58
  const all = login.methods({ context, env })
54
59
  const methods = typeof props.methods === 'function'
@@ -63,7 +68,10 @@ export const FallbackLoginScreen: FC<LoginScreenProps> = props => {
63
68
  setAttempted(true)
64
69
  return
65
70
  }
66
- void method.start({ context, env })
71
+ setFailure(null)
72
+ void method.start({ context, env }).catch((error: unknown) => {
73
+ setFailure(loginFailureHelper.failureOf(error))
74
+ })
67
75
  }, [blocked, context, env])
68
76
 
69
77
  const onAccept = useCallback((value: boolean) => {
@@ -131,6 +139,10 @@ export const FallbackLoginScreen: FC<LoginScreenProps> = props => {
131
139
  ))}
132
140
  </p>}
133
141
 
142
+ {failure != null && <LoginFailureView
143
+ failure={failure} translate={t} style={{ padding: 0, marginTop: '1rem', color: '#b00', fontSize: '.875rem' }}
144
+ />}
145
+
134
146
  {props.footer ?? <p style={{ marginTop: '2rem', fontSize: '.75rem', opacity: .7 }}>
135
147
  {credit.poweredBy && <span>{t('login.credit.powered', 'Powered by OwlMeans')}</span>}
136
148
  {credit.poweredBy && credit.line != null && ' · '}
@@ -0,0 +1,32 @@
1
+ import type { ClientConfig } from '@owlmeans/client-context'
2
+ import { DEFAULT_LNG, LIB_NAMESPACE, i18nHelper } from '@owlmeans/i18n'
3
+ import { i18nInstanceHelper } from '@owlmeans/client-i18n/utils'
4
+ import { LOGIN_I18N_RESOURCE } from './consts.local.js'
5
+ import type { LoginTranslate } from './types.js'
6
+
7
+ /**
8
+ * A translate over the `auth` library resource that reads no React context.
9
+ *
10
+ * For a sign-in surface that renders outside any screen binding a `translate` — a dispatcher's
11
+ * failed exchange, a plugin's own form — where a hook that reaches for an i18n provider would throw
12
+ * inside render in an app mounted without one. It reads the app's one i18next instance
13
+ * (`i18nInstanceHelper.getI18nInstance`, the same instance `useI18nLib` drives) and drains the
14
+ * `auth` bundle into it when no component has yet: `initI18nResource` is one-shot per slot, so
15
+ * whichever of the two runs first loads it and the other no-ops. App-tier overrides of the `auth`
16
+ * keys (`addI18nApp(lng, 'auth', …, { ns: LIB_NAMESPACE })`) resolve here as everywhere else.
17
+ */
18
+ export const makeAuthTranslate = (cfg: ClientConfig): LoginTranslate => {
19
+ const i18n = i18nInstanceHelper.getI18nInstance(cfg)
20
+ const fallback = typeof i18n.options?.fallbackLng === 'string' ? i18n.options.fallbackLng : DEFAULT_LNG
21
+ for (const lng of new Set([i18n.language, fallback])) {
22
+ if (lng == null) {
23
+ continue
24
+ }
25
+ i18nHelper.initI18nResource(lng, LOGIN_I18N_RESOURCE, LIB_NAMESPACE)?.forEach(resource => i18n.addResourceBundle(
26
+ lng, LIB_NAMESPACE, { [LOGIN_I18N_RESOURCE]: resource.data }, true, true
27
+ ))
28
+ }
29
+
30
+ return (key, defaultValue) =>
31
+ i18n.t(`${LOGIN_I18N_RESOURCE}.${key}`, { ns: LIB_NAMESPACE, defaultValue })
32
+ }
@@ -177,6 +177,14 @@ export interface LoginMethodSource {
177
177
  list: (ctx: LoginMethodContext) => LoginMethod[]
178
178
  }
179
179
 
180
+ /**
181
+ * `(key, defaultValue) => string` over the `auth` library resource — the one translate contract
182
+ * every sign-in renderer shares. Placeholders (`{{incident}}`) are filled after it answers.
183
+ */
184
+ export interface LoginTranslate {
185
+ (key: string, defaultValue: string): string
186
+ }
187
+
180
188
  /** What every rendering of the sign-in screen accepts, whatever its UI family. */
181
189
  export interface LoginScreenProps {
182
190
  /** The one thing a consuming application is expected to supply. */
@@ -37,7 +37,7 @@ Comprehensive error handling for authentication scenarios:
37
37
  This manager is part of the `@owlmeans/client-auth` package:
38
38
 
39
39
  ```bash
40
- npm install @owlmeans/client-auth@^0.1.19-rc.0
40
+ npm install @owlmeans/client-auth@0.1.20-alpha.0
41
41
  ```
42
42
 
43
43
  ## API Reference
@@ -0,0 +1,176 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { randomUUID } from 'node:crypto'
3
+ import { renderToStaticMarkup } from 'react-dom/server'
4
+ import { ACCESS_DENIED_KIND, ApiClientError, ServerCrashedError } from '@owlmeans/api'
5
+ import type { ResponseStatusCarrier } from '@owlmeans/api'
6
+ import { AuthenFailed, AuthForbidden, AuthRefused, AuthenticationStage } from '@owlmeans/auth'
7
+ import { AppType } from '@owlmeans/context'
8
+ import { ResilientError } from '@owlmeans/error'
9
+ import { LIB_NAMESPACE, i18nHelper } from '@owlmeans/i18n'
10
+ import type { ClientConfig } from '@owlmeans/client-context'
11
+ import type { ClientContext } from '@owlmeans/client'
12
+ import {
13
+ LoginFailureKind, LoginFailureView, loginFailureHelper, makeAuthTranslate,
14
+ } from '../src/login/index.js'
15
+ import type { LoginTranslate } from '../src/login/index.js'
16
+ import { makeControl } from '../src/manager/components/authentication/control.js'
17
+ import { makeTestContext } from './context.js'
18
+
19
+ /** A failed call as the API client rejects it: the error, stamped with the status and denial kind. */
20
+ const answered = <T extends Error>(error: T, status: number, denialKind?: string): T =>
21
+ Object.assign(error, { responseStatus: status, denialKind } satisfies ResponseStatusCarrier)
22
+
23
+ /** A development body rebuilt into its class on the far side, as `@owlmeans/api` rebuilds it. */
24
+ const rebuilt = (error: ResilientError, status: number, denialKind?: string): ResilientError =>
25
+ answered(ResilientError.ensure(ResilientError.marshal(error)), status, denialKind)
26
+
27
+ /** The registered `auth` bundle of a language, resolved the way i18next resolves a dotted key. */
28
+ const translateOf = (lng: string): LoginTranslate => {
29
+ const bundle = i18nHelper.resolveI18nResource(lng, 'auth', LIB_NAMESPACE) ?? {}
30
+
31
+ return (key, defaultValue) => {
32
+ const found = key.split('.').reduce<unknown>(
33
+ (node, part) => (node != null && typeof node === 'object' ? (node as Record<string, unknown>)[part] : undefined),
34
+ bundle,
35
+ )
36
+
37
+ return typeof found === 'string' ? found : defaultValue
38
+ }
39
+ }
40
+
41
+ describe('loginFailureHelper — a refused sign-in', () => {
42
+ // Production exposure: the body is the incident id alone, the status and the marker are the refusal.
43
+ test('the labelled 403 is a refusal, phrased "This account cannot sign in here."', () => {
44
+ const incident = randomUUID()
45
+ const failure = loginFailureHelper.failureOf(answered(new ApiClientError(`forbidden:${incident}`), 403, ACCESS_DENIED_KIND))
46
+
47
+ expect(failure).toMatchObject({ kind: LoginFailureKind.Refused, refused: true, key: 'login.error.refused' })
48
+ expect(loginFailureHelper.phrase(failure)).toEqual({
49
+ message: 'This account cannot sign in here.', incident: null, action: 'Back to sign-in',
50
+ })
51
+ // Development exposure rebuilds the class itself; a deployment's own refusal reads the same.
52
+ expect(loginFailureHelper.failureOf(rebuilt(new AuthRefused('operator:refused'), 403, ACCESS_DENIED_KIND)).kind)
53
+ .toBe(LoginFailureKind.Refused)
54
+ expect(loginFailureHelper.isRefusal(new AuthRefused('operator:unknown-account'))).toBe(true)
55
+ })
56
+
57
+ test('a visible refusal code gets its precise sentence', () => {
58
+ const account = loginFailureHelper.failureOf(rebuilt(new AuthRefused('account:disabled'), 403, ACCESS_DENIED_KIND))
59
+ const profile = loginFailureHelper.failureOf(rebuilt(new AuthRefused('profile:disabled'), 403, ACCESS_DENIED_KIND))
60
+
61
+ expect(account).toMatchObject({ kind: LoginFailureKind.AccountDisabled, refused: true })
62
+ expect(loginFailureHelper.phrase(account).message).toBe('This account has been disabled and cannot sign in here.')
63
+ expect(profile).toMatchObject({ kind: LoginFailureKind.ProfileDisabled, refused: true })
64
+ expect(loginFailureHelper.phrase(profile).message).toContain('access to this application has been blocked')
65
+ // A server still on `AuthenFailed`, and the integrated IAM finalizer's ban, name the same codes.
66
+ expect(loginFailureHelper.failureOf(rebuilt(new AuthenFailed('account:disabled'), 500)).kind)
67
+ .toBe(LoginFailureKind.AccountDisabled)
68
+ expect(loginFailureHelper.failureOf(rebuilt(new AuthForbidden('account:disabled'), 403, ACCESS_DENIED_KIND)).kind)
69
+ .toBe(LoginFailureKind.AccountDisabled)
70
+ })
71
+ })
72
+
73
+ describe('loginFailureHelper — any other failure', () => {
74
+ test('is worth a retry and carries the incident a person reports', () => {
75
+ const incident = randomUUID()
76
+ const crashed = loginFailureHelper.failureOf(answered(new ServerCrashedError(incident), 500))
77
+
78
+ expect(crashed).toMatchObject({ kind: LoginFailureKind.Failed, refused: false, key: 'login.error.failed', incidentId: incident })
79
+ expect(loginFailureHelper.phrase(crashed)).toEqual({
80
+ message: 'Sign-in did not complete. Please try again.', incident: `Reference: ${incident}`, action: 'Try again',
81
+ })
82
+ })
83
+
84
+ // Plan and entitlement refusals use 403 too, and a proxy's 403 carries no marker at all.
85
+ test('a 403 without the marker, a failed challenge, a network error or nothing are no refusals', () => {
86
+ for (const error of [
87
+ answered(new ApiClientError('forbidden'), 403),
88
+ rebuilt(new AuthenFailed('challenge'), 500),
89
+ new AuthenFailed('account:disabled-later'),
90
+ new Error('Network Error'),
91
+ null,
92
+ 'forbidden',
93
+ ]) {
94
+ expect(loginFailureHelper.failureOf(error)).toMatchObject({ kind: LoginFailureKind.Failed, refused: false })
95
+ }
96
+ })
97
+
98
+ test('phrases through the translate it is given — every shipped language has the keys', () => {
99
+ const incident = randomUUID()
100
+ const refused = loginFailureHelper.failureOf(new AuthRefused())
101
+ const failed = loginFailureHelper.failureOf(answered(new ServerCrashedError(incident), 500))
102
+
103
+ expect(loginFailureHelper.phrase(refused, translateOf('fr'))).toEqual({
104
+ message: 'Ce compte ne peut pas se connecter ici.', incident: null, action: 'Retour à la connexion',
105
+ })
106
+ expect(loginFailureHelper.phrase(failed, translateOf('de')).incident).toBe(`Referenz: ${incident}`)
107
+ for (const lng of ['en', 'pl', 'ru', 'be', 'uk', 'es', 'de', 'fr']) {
108
+ const translate = translateOf(lng)
109
+ for (const key of ['refused', 'account-disabled', 'profile-disabled', 'failed', 'retry', 'back', 'incident']) {
110
+ expect({ lng, key, found: translate(`login.error.${key}`, '') !== '' }).toEqual({ lng, key, found: true })
111
+ }
112
+ }
113
+ })
114
+ })
115
+
116
+ describe('makeAuthTranslate — the `auth` keys with no React context', () => {
117
+ test('reads the app\'s i18next instance, and an app override of a key wins', () => {
118
+ // Registered before the slot is first drained — exactly how an application overrides it.
119
+ i18nHelper.addI18nApp('en', 'auth', { login: { error: { refused: 'Not with this account.' } } }, { ns: LIB_NAMESPACE })
120
+ const cfg = { ready: false, service: 'client-auth-tests', type: AppType.Frontend, services: {} } as unknown as ClientConfig
121
+ const translate = makeAuthTranslate(cfg)
122
+
123
+ expect(loginFailureHelper.phrase(loginFailureHelper.failureOf(new AuthRefused()), translate))
124
+ .toEqual({ message: 'Not with this account.', incident: null, action: 'Back to sign-in' })
125
+ expect(translate('login.error.failed', 'missing')).toBe('Sign-in did not complete. Please try again.')
126
+ })
127
+ })
128
+
129
+ describe('LoginFailureView — the end state of a failed sign-in', () => {
130
+ const html = (node: unknown): string => renderToStaticMarkup(<>{node as never}</>)
131
+
132
+ test('a refusal: one phrased sentence and the way back, no reference', () => {
133
+ const out = html(<LoginFailureView
134
+ failure={loginFailureHelper.failureOf(rebuilt(new AuthRefused('account:disabled'), 403, ACCESS_DENIED_KIND))}
135
+ onAction={() => {}}
136
+ />)
137
+
138
+ expect(out).toContain('role="alert"')
139
+ expect(out).toContain('data-login-failure="account-disabled"')
140
+ expect(out).toContain('data-login-refused="true"')
141
+ expect(out).toContain('This account has been disabled and cannot sign in here.')
142
+ expect(out).toContain('>Back to sign-in</button>')
143
+ expect(out).not.toContain('data-login-failure-incident')
144
+ expect(out).not.toContain('auth:manager')
145
+ })
146
+
147
+ test('a failure: the generic sentence, its reference and a retry — a button only with an action', () => {
148
+ const incident = randomUUID()
149
+ const failure = loginFailureHelper.failureOf(answered(new ServerCrashedError(incident), 500))
150
+
151
+ const out = html(<LoginFailureView failure={failure} onAction={() => {}} page />)
152
+ expect(out).toContain('data-login-failure-page')
153
+ expect(out).toContain('data-login-failure="failed"')
154
+ expect(out).toContain('Sign-in did not complete. Please try again.')
155
+ expect(out).toContain(`Reference: ${incident}`)
156
+ expect(out).toContain('>Try again</button>')
157
+
158
+ expect(html(<LoginFailureView failure={failure} />)).not.toContain('<button')
159
+ })
160
+ })
161
+
162
+ describe('the authentication control keeps what a failure view reads', () => {
163
+ // The Google callback and the supervisor form end in `setError`: the stage moves to Error, and
164
+ // the stamped status and marker of the API client's rejection survive `ResilientError.ensure`.
165
+ test('setError moves to the Error stage with the refusal intact', async () => {
166
+ const stages: AuthenticationStage[] = []
167
+ const control = makeControl(makeTestContext() as unknown as ClientContext<ClientConfig>)
168
+ control.setStage = stage => { stages.push(stage) }
169
+
170
+ await control.setError(answered(new ApiClientError(`forbidden:${randomUUID()}`), 403, ACCESS_DENIED_KIND))
171
+
172
+ expect(control.stage).toBe(AuthenticationStage.Error)
173
+ expect(stages).toEqual([AuthenticationStage.Error])
174
+ expect(loginFailureHelper.failureOf(control.error).kind).toBe(LoginFailureKind.Refused)
175
+ })
176
+ })