@vertesia/ui 1.6.0-dev.20261008.064737Z → 1.6.0-dev.20261014.015055Z

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 (204) hide show
  1. package/README.md +24 -0
  2. package/lib/boot/index.d.ts +12 -1
  3. package/lib/boot/index.d.ts.map +1 -1
  4. package/lib/boot/index.js +65 -10
  5. package/lib/boot/index.js.map +1 -1
  6. package/lib/boot/text.d.ts +13 -0
  7. package/lib/boot/text.d.ts.map +1 -0
  8. package/lib/boot/text.js +68 -0
  9. package/lib/boot/text.js.map +1 -0
  10. package/lib/core/components/shadcn/button.d.ts +1 -0
  11. package/lib/core/components/shadcn/button.d.ts.map +1 -1
  12. package/lib/core/components/shadcn/button.js +2 -2
  13. package/lib/core/components/shadcn/button.js.map +1 -1
  14. package/lib/core/components/shadcn/selectBox.d.ts.map +1 -1
  15. package/lib/core/components/shadcn/selectBox.js +2 -2
  16. package/lib/core/components/shadcn/selectBox.js.map +1 -1
  17. package/lib/env/index.d.ts +1 -1
  18. package/lib/env/index.d.ts.map +1 -1
  19. package/lib/env/index.js +1 -1
  20. package/lib/env/index.js.map +1 -1
  21. package/lib/features/agent/PayloadBuilder.d.ts +6 -0
  22. package/lib/features/agent/PayloadBuilder.d.ts.map +1 -1
  23. package/lib/features/agent/chat/AskUserWidget.d.ts.map +1 -1
  24. package/lib/features/agent/chat/AskUserWidget.js +2 -1
  25. package/lib/features/agent/chat/AskUserWidget.js.map +1 -1
  26. package/lib/features/agent/chat/ModernAgentConversation.js +6 -4
  27. package/lib/features/agent/chat/ModernAgentConversation.js.map +1 -1
  28. package/lib/features/agent/chat/ModernAgentOutput/AllMessagesMixed.d.ts.map +1 -1
  29. package/lib/features/agent/chat/ModernAgentOutput/AllMessagesMixed.js +2 -253
  30. package/lib/features/agent/chat/ModernAgentOutput/AllMessagesMixed.js.map +1 -1
  31. package/lib/features/agent/chat/ModernAgentOutput/CollapsibleAgentMarkdown.d.ts +1 -3
  32. package/lib/features/agent/chat/ModernAgentOutput/CollapsibleAgentMarkdown.d.ts.map +1 -1
  33. package/lib/features/agent/chat/ModernAgentOutput/CollapsibleAgentMarkdown.js +2 -8
  34. package/lib/features/agent/chat/ModernAgentOutput/CollapsibleAgentMarkdown.js.map +1 -1
  35. package/lib/features/agent/chat/ModernAgentOutput/SummaryConversation.d.ts.map +1 -1
  36. package/lib/features/agent/chat/ModernAgentOutput/SummaryConversation.js +58 -0
  37. package/lib/features/agent/chat/ModernAgentOutput/SummaryConversation.js.map +1 -1
  38. package/lib/features/agent/chat/ModernAgentOutput/agentMarkdownStyles.d.ts +4 -0
  39. package/lib/features/agent/chat/ModernAgentOutput/agentMarkdownStyles.d.ts.map +1 -0
  40. package/lib/features/agent/chat/ModernAgentOutput/agentMarkdownStyles.js +248 -0
  41. package/lib/features/agent/chat/ModernAgentOutput/agentMarkdownStyles.js.map +1 -0
  42. package/lib/features/agent/chat/SkillWidgetProvider.d.ts.map +1 -1
  43. package/lib/features/agent/chat/SkillWidgetProvider.js +3 -2
  44. package/lib/features/agent/chat/SkillWidgetProvider.js.map +1 -1
  45. package/lib/i18n/LanguageProvider.d.ts +9 -1
  46. package/lib/i18n/LanguageProvider.d.ts.map +1 -1
  47. package/lib/i18n/LanguageProvider.js +7 -4
  48. package/lib/i18n/LanguageProvider.js.map +1 -1
  49. package/lib/i18n/locales/ar.json +6 -1
  50. package/lib/i18n/locales/de.json +6 -1
  51. package/lib/i18n/locales/en.json +6 -1
  52. package/lib/i18n/locales/es.json +6 -1
  53. package/lib/i18n/locales/fr.json +6 -1
  54. package/lib/i18n/locales/it.json +6 -1
  55. package/lib/i18n/locales/ja.json +6 -1
  56. package/lib/i18n/locales/ko.json +6 -1
  57. package/lib/i18n/locales/pt.json +6 -1
  58. package/lib/i18n/locales/ru.json +6 -1
  59. package/lib/i18n/locales/tr.json +6 -1
  60. package/lib/i18n/locales/zh-TW.json +6 -1
  61. package/lib/i18n/locales/zh.json +6 -1
  62. package/lib/i18n/rtl.d.ts +4 -2
  63. package/lib/i18n/rtl.d.ts.map +1 -1
  64. package/lib/i18n/rtl.js +4 -2
  65. package/lib/i18n/rtl.js.map +1 -1
  66. package/lib/session/UserSessionProvider.d.ts.map +1 -1
  67. package/lib/session/UserSessionProvider.js +10 -4
  68. package/lib/session/UserSessionProvider.js.map +1 -1
  69. package/lib/session/auth/composable.d.ts +1 -5
  70. package/lib/session/auth/composable.d.ts.map +1 -1
  71. package/lib/session/auth/composable.js +1 -10
  72. package/lib/session/auth/composable.js.map +1 -1
  73. package/lib/session/auth/firebase.d.ts.map +1 -1
  74. package/lib/session/auth/firebase.js +8 -5
  75. package/lib/session/auth/firebase.js.map +1 -1
  76. package/lib/session/auth/oauth.d.ts.map +1 -1
  77. package/lib/session/auth/oauth.js +53 -25
  78. package/lib/session/auth/oauth.js.map +1 -1
  79. package/lib/session/auth/vertesiaHosts.d.ts +0 -40
  80. package/lib/session/auth/vertesiaHosts.d.ts.map +1 -1
  81. package/lib/session/auth/vertesiaHosts.js +2 -45
  82. package/lib/session/auth/vertesiaHosts.js.map +1 -1
  83. package/lib/session/index.d.ts +1 -1
  84. package/lib/session/index.d.ts.map +1 -1
  85. package/lib/session/index.js +1 -1
  86. package/lib/session/index.js.map +1 -1
  87. package/lib/session/scopeSelection.d.ts +4 -0
  88. package/lib/session/scopeSelection.d.ts.map +1 -1
  89. package/lib/session/scopeSelection.js +11 -0
  90. package/lib/session/scopeSelection.js.map +1 -1
  91. package/lib/shell/VertesiaShell.d.ts +7 -2
  92. package/lib/shell/VertesiaShell.d.ts.map +1 -1
  93. package/lib/shell/VertesiaShell.js +2 -2
  94. package/lib/shell/VertesiaShell.js.map +1 -1
  95. package/lib/shell/login/InviteAcceptModal.d.ts.map +1 -1
  96. package/lib/shell/login/InviteAcceptModal.js +83 -42
  97. package/lib/shell/login/InviteAcceptModal.js.map +1 -1
  98. package/lib/vertesia-ui-boot.js +52 -15
  99. package/lib/vertesia-ui-boot.js.map +1 -1
  100. package/lib/vertesia-ui-core.js +44 -44
  101. package/lib/vertesia-ui-core.js.map +1 -1
  102. package/lib/vertesia-ui-env.js +1 -1
  103. package/lib/vertesia-ui-env.js.map +1 -1
  104. package/lib/vertesia-ui-features.js +570 -585
  105. package/lib/vertesia-ui-features.js.map +1 -1
  106. package/lib/vertesia-ui-i18n.B1Nd8-dE.chunk.js +5 -0
  107. package/lib/vertesia-ui-i18n.B1Nd8-dE.chunk.js.map +1 -0
  108. package/lib/vertesia-ui-i18n.B2Z7mx1x.chunk.js +5 -0
  109. package/lib/vertesia-ui-i18n.B2Z7mx1x.chunk.js.map +1 -0
  110. package/lib/vertesia-ui-i18n.B756-YtS.chunk.js +5 -0
  111. package/lib/vertesia-ui-i18n.B756-YtS.chunk.js.map +1 -0
  112. package/lib/vertesia-ui-i18n.BICdgAEn.chunk.js +5 -0
  113. package/lib/vertesia-ui-i18n.BICdgAEn.chunk.js.map +1 -0
  114. package/lib/vertesia-ui-i18n.B_yx0c2A.chunk.js +5 -0
  115. package/lib/vertesia-ui-i18n.B_yx0c2A.chunk.js.map +1 -0
  116. package/lib/vertesia-ui-i18n.BwPrGiQn.chunk.js +5 -0
  117. package/lib/vertesia-ui-i18n.BwPrGiQn.chunk.js.map +1 -0
  118. package/lib/vertesia-ui-i18n.CfQbOHm3.chunk.js +5 -0
  119. package/lib/vertesia-ui-i18n.CfQbOHm3.chunk.js.map +1 -0
  120. package/lib/vertesia-ui-i18n.DMISlF16.chunk.js +5 -0
  121. package/lib/vertesia-ui-i18n.DMISlF16.chunk.js.map +1 -0
  122. package/lib/vertesia-ui-i18n.D_WA36Yq.chunk.js +5 -0
  123. package/lib/vertesia-ui-i18n.D_WA36Yq.chunk.js.map +1 -0
  124. package/lib/vertesia-ui-i18n.Dp3KuMs-.chunk.js +5 -0
  125. package/lib/vertesia-ui-i18n.Dp3KuMs-.chunk.js.map +1 -0
  126. package/lib/vertesia-ui-i18n.DsioKoQ3.chunk.js +5 -0
  127. package/lib/vertesia-ui-i18n.DsioKoQ3.chunk.js.map +1 -0
  128. package/lib/vertesia-ui-i18n.QEIqEPlv.chunk.js +5 -0
  129. package/lib/vertesia-ui-i18n.QEIqEPlv.chunk.js.map +1 -0
  130. package/lib/vertesia-ui-i18n.js +2 -2
  131. package/lib/vertesia-ui-i18n.js.map +1 -1
  132. package/lib/vertesia-ui-layout.js +15 -15
  133. package/lib/vertesia-ui-layout.js.map +1 -1
  134. package/lib/vertesia-ui-rich-text.js +44 -44
  135. package/lib/vertesia-ui-rich-text.js.map +1 -1
  136. package/lib/vertesia-ui-session.js +1 -1
  137. package/lib/vertesia-ui-session.js.map +1 -1
  138. package/lib/vertesia-ui-shell.js +51 -51
  139. package/lib/vertesia-ui-shell.js.map +1 -1
  140. package/lib/vertesia-ui-widgets.js +51 -51
  141. package/lib/vertesia-ui-widgets.js.map +1 -1
  142. package/package.json +20 -20
  143. package/src/boot/index.ts +74 -11
  144. package/src/boot/text.ts +80 -0
  145. package/src/core/components/shadcn/button.tsx +3 -2
  146. package/src/core/components/shadcn/selectBox.tsx +12 -4
  147. package/src/css/index.css +1 -1
  148. package/src/css/typography.css +111 -88
  149. package/src/env/index.ts +0 -2
  150. package/src/features/agent/chat/AskUserWidget.tsx +3 -1
  151. package/src/features/agent/chat/ModernAgentConversation.tsx +7 -4
  152. package/src/features/agent/chat/ModernAgentOutput/AllMessagesMixed.tsx +2 -253
  153. package/src/features/agent/chat/ModernAgentOutput/CollapsibleAgentMarkdown.tsx +2 -8
  154. package/src/features/agent/chat/ModernAgentOutput/SummaryConversation.ts +60 -0
  155. package/src/features/agent/chat/ModernAgentOutput/agentMarkdownStyles.ts +248 -0
  156. package/src/features/agent/chat/SkillWidgetProvider.tsx +3 -2
  157. package/src/i18n/LanguageProvider.tsx +16 -4
  158. package/src/i18n/locales/ar.json +6 -1
  159. package/src/i18n/locales/de.json +6 -1
  160. package/src/i18n/locales/en.json +6 -1
  161. package/src/i18n/locales/es.json +6 -1
  162. package/src/i18n/locales/fr.json +6 -1
  163. package/src/i18n/locales/it.json +6 -1
  164. package/src/i18n/locales/ja.json +6 -1
  165. package/src/i18n/locales/ko.json +6 -1
  166. package/src/i18n/locales/pt.json +6 -1
  167. package/src/i18n/locales/ru.json +6 -1
  168. package/src/i18n/locales/tr.json +6 -1
  169. package/src/i18n/locales/zh-TW.json +6 -1
  170. package/src/i18n/locales/zh.json +6 -1
  171. package/src/i18n/rtl.ts +5 -2
  172. package/src/session/UserSessionProvider.tsx +11 -4
  173. package/src/session/auth/composable.ts +1 -11
  174. package/src/session/auth/firebase.ts +9 -6
  175. package/src/session/auth/oauth.ts +65 -25
  176. package/src/session/auth/vertesiaHosts.ts +2 -47
  177. package/src/session/index.ts +1 -1
  178. package/src/session/scopeSelection.ts +13 -0
  179. package/src/shell/VertesiaShell.tsx +8 -2
  180. package/src/shell/login/InviteAcceptModal.tsx +112 -65
  181. package/lib/vertesia-ui-i18n.B0orbJb6.chunk.js +0 -5
  182. package/lib/vertesia-ui-i18n.B0orbJb6.chunk.js.map +0 -1
  183. package/lib/vertesia-ui-i18n.BChwIQVA.chunk.js +0 -5
  184. package/lib/vertesia-ui-i18n.BChwIQVA.chunk.js.map +0 -1
  185. package/lib/vertesia-ui-i18n.BTEcAVQm.chunk.js +0 -5
  186. package/lib/vertesia-ui-i18n.BTEcAVQm.chunk.js.map +0 -1
  187. package/lib/vertesia-ui-i18n.BeJQKbv7.chunk.js +0 -5
  188. package/lib/vertesia-ui-i18n.BeJQKbv7.chunk.js.map +0 -1
  189. package/lib/vertesia-ui-i18n.BpGr0-5g.chunk.js +0 -5
  190. package/lib/vertesia-ui-i18n.BpGr0-5g.chunk.js.map +0 -1
  191. package/lib/vertesia-ui-i18n.Bs8x-1wI.chunk.js +0 -5
  192. package/lib/vertesia-ui-i18n.Bs8x-1wI.chunk.js.map +0 -1
  193. package/lib/vertesia-ui-i18n.BymQoOm0.chunk.js +0 -5
  194. package/lib/vertesia-ui-i18n.BymQoOm0.chunk.js.map +0 -1
  195. package/lib/vertesia-ui-i18n.CNT_wabV.chunk.js +0 -5
  196. package/lib/vertesia-ui-i18n.CNT_wabV.chunk.js.map +0 -1
  197. package/lib/vertesia-ui-i18n.Cxh1iVk0.chunk.js +0 -5
  198. package/lib/vertesia-ui-i18n.Cxh1iVk0.chunk.js.map +0 -1
  199. package/lib/vertesia-ui-i18n.DLYCM9fV.chunk.js +0 -5
  200. package/lib/vertesia-ui-i18n.DLYCM9fV.chunk.js.map +0 -1
  201. package/lib/vertesia-ui-i18n.QvAVguDs.chunk.js +0 -5
  202. package/lib/vertesia-ui-i18n.QvAVguDs.chunk.js.map +0 -1
  203. package/lib/vertesia-ui-i18n.smozeSmI.chunk.js +0 -5
  204. package/lib/vertesia-ui-i18n.smozeSmI.chunk.js.map +0 -1
@@ -1,54 +1,9 @@
1
1
  const LOOPBACK_IPV4 = /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
2
2
  /** A single DNS label: alphanumeric, inner hyphens allowed. */
3
- export const LABEL = '[a-z0-9](?:[a-z0-9-]*[a-z0-9])?';
3
+ const LABEL = '[a-z0-9](?:[a-z0-9-]*[a-z0-9])?';
4
4
 
5
5
  /** The region label is optional throughout, because each regional host has a global US alias. */
6
- export const REGION = `(?:${LABEL}\\.)?`;
7
-
8
- /**
9
- * Hostnames served by the UI backend — Vertesia's own code, not merely a Vertesia-owned name:
10
- * `cloud.vertesia.io`, `cloud.us1.vertesia.io` — the Cloud UI
11
- * `preprod.cloud.vertesia.io`, `preview.cloud.us1.vertesia.io` — its preview/preprod tiers
12
- * `acme.cloud.us1.vertesia.io` — per-IdP-tenant branded hosts
13
- * `dev-feat-x.ui.dev1.vertesia.io` — dynamic per-branch dev envs
14
- *
15
- * Mirrors the load-balancer host rules (region-lb/main.tf: `cloud.${var.domain}`,
16
- * `*.cloud.${var.domain}`, `*.ui.${var.domain}`) rather than the DNS zone, so a name we merely own
17
- * is not trusted for what serves it.
18
- */
19
- export const FIRST_PARTY_HOST_PATTERNS: readonly RegExp[] = [
20
- new RegExp(`^cloud\\.${REGION}vertesia\\.io$`),
21
- new RegExp(`^${LABEL}\\.cloud\\.${REGION}vertesia\\.io$`),
22
- new RegExp(`^${LABEL}\\.ui\\.${LABEL}\\.vertesia\\.io$`),
23
- ];
24
-
25
- /**
26
- * Public app-gateway origins. Stable tiers use the `apps*` browser-facing host, while development
27
- * environments reach the gateway through its `*.api.*` Cloud Run route:
28
- * `apps.us1.vertesia.io`, `apps-preview.us1.vertesia.io`
29
- * `app-gateway-dev1.api.dev1.vertesia.io`, `app-gateway-us3.api.us3.vertesia.io`
30
- *
31
- * The second label is deliberately unconstrained. Branch environments do NOT each get a gateway —
32
- * they share their region's (`detectGatewayUrl` in packages/server-common/src/env/ServerEnv.ts:444
33
- * and `getAppgenServerBaseUrl` in packages/workflows/.../app_workspace/workspace-utils.ts), so the
34
- * host published to browsers is region-scoped, while the per-branch `app-gateway-dev-<branch>`
35
- * shape in the infrastructure README also exists. Matching `app-gateway-<anything>` covers both,
36
- * and every Cloud Run service named that way *is* a gateway (region-compute/main.tf:373).
37
- *
38
- * These serve every tenant's published application from one shared origin, and published assets
39
- * need no authentication — so this is generated, low-trust code sitting on a Vertesia-owned
40
- * hostname. Owning the domain is not the test; which backend serves the origin is.
41
- *
42
- * Two load-balancer variables can map further hostnames onto the same backend —
43
- * `appgen_host_aliases` and `appgen_preview_host_aliases` in
44
- * infrastructure/terraform/modules/region-lb/variables.tf. Both are empty in every region today; if
45
- * one is ever set, add the alias here too, or it silently falls through to the permissive branch of
46
- * Policy L and becomes an origin that may receive a token at any path.
47
- */
48
- export const GATEWAY_HOST_PATTERNS: readonly RegExp[] = [
49
- new RegExp(`^apps(?:-preview|-preprod)?\\.${REGION}vertesia\\.io$`),
50
- new RegExp(`^app-gateway-${LABEL}\\.api\\.${LABEL}\\.vertesia\\.io$`),
51
- ];
6
+ const REGION = `(?:${LABEL}\\.)?`;
52
7
 
53
8
  export function isLoopbackHostname(hostname: string): boolean {
54
9
  if (hostname === 'localhost' || hostname.endsWith('.localhost')) {
@@ -8,7 +8,7 @@ export {
8
8
  redirectToCentralAuth,
9
9
  } from './auth/domainRouting';
10
10
  export * from './auth/firebase';
11
- export { isAppOAuthLoginPending, startAppOAuthLogin } from './auth/oauth';
11
+ export { isAppOAuthLoginPending, revokeAppOAuthSession, startAppOAuthLogin } from './auth/oauth';
12
12
  export * from './auth/useAuthState';
13
13
  export * from './auth/useCurrentTenant';
14
14
  export * from './DevSessionProvider';
@@ -6,6 +6,7 @@
6
6
  * another tab's next token refresh. `localStorage` holds the last selection made in any tab, and
7
7
  * only seeds a tab that has no scope of its own yet (a new tab, or the app after a restart).
8
8
  */
9
+ import { Env } from '@vertesia/ui/env';
9
10
  import { LastSelectedAccountId_KEY, LastSelectedProjectId_KEY } from './constants';
10
11
 
11
12
  const TabAccountId_KEY = 'composableai.tabAccountId';
@@ -52,6 +53,18 @@ export function readScopeSelection(accountId?: string): ScopeSelection {
52
53
  return { accountId: account, projectId: projectId || undefined };
53
54
  }
54
55
 
56
+ export function resolveAuthSelection(currentUrl: URL): { accountId?: string; projectId?: string } {
57
+ // A URL selection owns the whole pair: never attach an unrelated configured account/project.
58
+ const hasUrlScope = currentUrl.searchParams.has('a') || currentUrl.searchParams.has('p');
59
+ const defaults = hasUrlScope ? undefined : Env.defaultAuthSelection;
60
+ const urlAccount = currentUrl.searchParams.get('a') ?? defaults?.accountId;
61
+ const urlProject = currentUrl.searchParams.get('p') ?? defaults?.projectId;
62
+ // A project alone identifies its account, so the stored selection only fills in a missing project.
63
+ const stored = urlProject === undefined ? readScopeSelection(urlAccount) : undefined;
64
+
65
+ return { accountId: urlAccount ?? stored?.accountId, projectId: urlProject ?? stored?.projectId };
66
+ }
67
+
55
68
  /** Make this the tab's scope and the default for new tabs. */
56
69
  export function rememberScopeSelection(accountId: string, projectId?: string): void {
57
70
  sessionSet(TabAccountId_KEY, accountId);
@@ -28,8 +28,13 @@ export interface VertesiaShellProps {
28
28
  suppressSignInErrorPrefixes?: string | string[];
29
29
  /** Use an already-issued Vertesia token instead of starting the normal sign-in flow. */
30
30
  authToken?: string;
31
- /** Force a default language. If omitted, falls back to localStorage then navigator.language then 'en'. */
31
+ /** Language used only when there is neither a saved choice nor a browser language. */
32
32
  defaultLanguage?: SupportedLanguage;
33
+ /**
34
+ * Pin the language the shell renders in, whatever the saved choice or browser language.
35
+ * Embedded apps still receive the user's own language (`useLanguage().preferredLanguage`).
36
+ */
37
+ language?: SupportedLanguage;
33
38
  }
34
39
  export function VertesiaShell({
35
40
  children,
@@ -41,6 +46,7 @@ export function VertesiaShell({
41
46
  suppressSignInErrorPrefixes,
42
47
  authToken,
43
48
  defaultLanguage,
49
+ language,
44
50
  authScreens,
45
51
  branding,
46
52
  }: VertesiaShellProps) {
@@ -48,7 +54,7 @@ export function VertesiaShell({
48
54
  const brandedContent = (
49
55
  <TypeRegistryProvider>
50
56
  <ThemeProvider defaultTheme="system" storageKey="vite-ui-theme">
51
- <LanguageProvider defaultLanguage={defaultLanguage}>
57
+ <LanguageProvider defaultLanguage={defaultLanguage} language={language}>
52
58
  <LanguageBoundI18nProvider>
53
59
  <AppBrandingBoundary branding={branding}>
54
60
  <IframeAppContextSync />
@@ -2,7 +2,12 @@ import type { UserInviteToken } from '@vertesia/common';
2
2
  import { Button, Modal, ModalBody, ModalTitle } from '@vertesia/ui/core';
3
3
  import { useUITranslation } from '@vertesia/ui/i18n';
4
4
  import { useUserSession } from '@vertesia/ui/session';
5
- import { useEffect, useState } from 'react';
5
+ import { useCallback, useEffect, useState } from 'react';
6
+
7
+ // Legacy invites have no account data and cannot be shown or accepted here.
8
+ function withAccount(invites: UserInviteToken[]) {
9
+ return invites.filter((i) => i.data.account);
10
+ }
6
11
 
7
12
  export function InviteAcceptModal() {
8
13
  const session = useUserSession();
@@ -10,95 +15,137 @@ export function InviteAcceptModal() {
10
15
  const { t } = useUITranslation();
11
16
  const [showModal, setShowModal] = useState(false);
12
17
  const [invites, setInvites] = useState<UserInviteToken[]>([]);
18
+ const [pendingId, setPendingId] = useState<string>();
19
+ const [error, setError] = useState<string>();
20
+ const [acceptedAccountIds, setAcceptedAccountIds] = useState<string[]>([]);
21
+ const isOpen = showModal && invites.length > 0;
22
+
23
+ const loadInvites = useCallback(async () => {
24
+ const valid = withAccount(await client.account.listInvites());
25
+ setInvites(valid);
26
+ return valid;
27
+ }, [client]);
13
28
 
14
29
  useEffect(() => {
15
- client.account
16
- .listInvites()
17
- .then((invites) => {
18
- if (invites.length > 0) {
19
- // Filter out legacy invites that do not have account data
20
- const notLegacyInvites = invites.filter((i) => i.data.account);
21
- if (notLegacyInvites.length === 0) {
22
- console.log('No valid invites found, closing modal');
23
- return;
24
- }
25
- // If we have valid invites, show the modal
26
- console.log('Found valid invites', notLegacyInvites.length);
30
+ loadInvites()
31
+ .then((valid) => {
32
+ if (valid.length > 0) {
33
+ console.log('Found valid invites', valid.length);
27
34
  setShowModal(true);
28
- setInvites(notLegacyInvites);
29
- } else {
30
- console.log('No invites found, closing modal');
31
- setShowModal(false);
32
35
  }
33
36
  })
34
37
  .catch((err) => {
35
38
  console.error('Error fetching invites', err);
36
39
  });
37
- }, [client.account.listInvites]);
40
+ }, [loadInvites]);
38
41
 
39
42
  const closeModal = () => setShowModal(false);
40
43
 
41
- const accept = async (invite: UserInviteToken) => {
42
- await client.account.acceptInvite(invite.id);
43
- await session.authCallback;
44
- await session.fetchAccounts();
45
- await session.fetchProjects(invite.data.account.id);
46
- const remainingInvites = invites.filter((i) => i.id !== invite.id);
44
+ // Refreshing the session re-renders the app around the modal, so it waits until the modal has closed and
45
+ // then picks up every accepted invite at once.
46
+ useEffect(() => {
47
+ if (isOpen || acceptedAccountIds.length === 0) return;
48
+ setAcceptedAccountIds([]);
49
+ void (async () => {
50
+ try {
51
+ // The current token predates the new memberships and does not list the new accounts, so the
52
+ // server rejects reads of them until a fresh token is minted.
53
+ await session.refreshAuthToken();
54
+ await session.fetchAccounts();
55
+ } catch (err) {
56
+ console.error('Failed to refresh the session after accepting invites', err);
57
+ return;
58
+ }
59
+ // Each account loads on its own, so one failure does not hide the others.
60
+ for (const accountId of new Set(acceptedAccountIds)) {
61
+ await session.fetchProjects(accountId).catch((err) => {
62
+ console.error('Failed to load projects for an accepted invite', err);
63
+ });
64
+ }
65
+ })();
66
+ }, [isOpen, acceptedAccountIds, session]);
47
67
 
48
- const notLegacyInvites = remainingInvites.filter((i) => i.data.account);
49
- if (notLegacyInvites.length > 0) {
50
- setInvites(notLegacyInvites);
51
- } else {
52
- closeModal();
53
- }
68
+ const removeInvite = (inviteId: string) => setInvites((current) => current.filter((i) => i.id !== inviteId));
69
+
70
+ // An accept or reject can fail because the invite was already used, e.g. from another tab: show the server's list.
71
+ const recoverFromFailure = async (action: string, err: unknown) => {
72
+ console.error(`Failed to ${action} invite`, err);
73
+ setError(t('login.inviteActionFailed'));
74
+ return loadInvites().catch((reloadErr) => {
75
+ console.error('Error fetching invites', reloadErr);
76
+ return undefined;
77
+ });
54
78
  };
55
79
 
56
- const reject = async (invite: UserInviteToken) => {
57
- await client.account.rejectInvite(invite.id);
58
- const remainingInvites = invites.filter((i) => i.id !== invite.id);
59
- setInvites(remainingInvites);
60
- if (remainingInvites.length === 0) {
61
- closeModal();
80
+ const accept = async (invite: UserInviteToken) => {
81
+ setPendingId(invite.id);
82
+ setError(undefined);
83
+ try {
84
+ await client.account.acceptInvite(invite.id);
85
+ removeInvite(invite.id);
86
+ setAcceptedAccountIds((current) => [...current, invite.data.account.id]);
87
+ } catch (err) {
88
+ const remaining = await recoverFromFailure('accept', err);
89
+ // A gone invite was most likely accepted after all (e.g. the response was lost), so its account
90
+ // still needs loading. If it was rejected elsewhere instead, that load fails harmlessly.
91
+ if (remaining && !remaining.some((i) => i.id === invite.id)) {
92
+ setAcceptedAccountIds((current) => [...current, invite.data.account.id]);
93
+ }
94
+ } finally {
95
+ setPendingId(undefined);
62
96
  }
63
97
  };
64
98
 
65
- const inviteList = invites.map((invite) => {
66
- if (!invite.data.account) {
67
- console.warn('Invite has no account data', invite);
68
- return null; // Skip rendering this invite
99
+ const reject = async (invite: UserInviteToken) => {
100
+ setPendingId(invite.id);
101
+ setError(undefined);
102
+ try {
103
+ await client.account.rejectInvite(invite.id);
104
+ removeInvite(invite.id);
105
+ } catch (err) {
106
+ await recoverFromFailure('reject', err);
107
+ } finally {
108
+ setPendingId(undefined);
69
109
  }
110
+ };
70
111
 
71
- return (
72
- <div key={invite.id} className="flex flex-row w-full justify-between border rounded-sm px-2 py-2 ">
73
- <div className="flex flex-col">
74
- <div className="w-full font-semibold">{invite.data.account.name ?? invite.data.account}</div>
75
- {invite.data.project && <div className="w-full text-base">- {invite.data.project.name}</div>}
76
- <div className="text-xs">Role: {invite.data.role}</div>
77
- {invite.data.invited_by && (
78
- <div className="text-xs">by {invite.data.invited_by.name ?? invite.data.invited_by}</div>
79
- )}
80
- </div>
81
- <div className="flex flex-col gap-4">
82
- <Button size={'xs'} onClick={() => accept(invite)}>
83
- {t('login.accept')}
84
- </Button>{' '}
85
- <Button size={'xs'} variant="secondary" onClick={() => reject(invite)}>
86
- {t('login.reject')}
87
- </Button>
88
- </div>
112
+ const inviteList = invites.map((invite) => (
113
+ <div key={invite.id} className="flex flex-row w-full justify-between border rounded-sm px-2 py-2 ">
114
+ <div className="flex flex-col">
115
+ <div className="w-full font-semibold">{invite.data.account.name}</div>
116
+ {invite.data.project && <div className="w-full text-base">- {invite.data.project.name}</div>}
117
+ <div className="text-xs">{t('login.inviteRole', { role: invite.data.role })}</div>
118
+ {invite.data.invited_by && (
119
+ <div className="text-xs">{t('login.invitedBy', { name: invite.data.invited_by.name })}</div>
120
+ )}
89
121
  </div>
90
- );
91
- });
122
+ <div className="flex flex-col gap-4">
123
+ <Button
124
+ size={'xs'}
125
+ isDisabled={!!pendingId}
126
+ isLoading={pendingId === invite.id}
127
+ onClick={() => void accept(invite)}
128
+ >
129
+ {t('login.accept')}
130
+ </Button>{' '}
131
+ <Button size={'xs'} variant="secondary" isDisabled={!!pendingId} onClick={() => void reject(invite)}>
132
+ {t('login.reject')}
133
+ </Button>
134
+ </div>
135
+ </div>
136
+ ));
92
137
 
93
138
  return (
94
139
  <div>
95
- <Modal isOpen={showModal} onClose={closeModal}>
140
+ <Modal isOpen={isOpen} onClose={closeModal}>
96
141
  <ModalTitle>{t('login.reviewInvites')}</ModalTitle>
97
142
  <ModalBody>
98
- <div className="text-sm pb-4">
99
- You have received the following invites to join other accounts. Please review and accept or
100
- declined them.
101
- </div>
143
+ <div className="text-sm pb-4">{t('login.invitesIntro')}</div>
144
+ {error && (
145
+ <div role="alert" className="text-sm text-destructive pb-4">
146
+ {error}
147
+ </div>
148
+ )}
102
149
  {inviteList}
103
150
  </ModalBody>
104
151
  </Modal>