@vertesia/ui 1.6.0-dev.20261009.095813Z → 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 (143) hide show
  1. package/lib/boot/index.d.ts +12 -1
  2. package/lib/boot/index.d.ts.map +1 -1
  3. package/lib/boot/index.js +65 -10
  4. package/lib/boot/index.js.map +1 -1
  5. package/lib/boot/text.d.ts +13 -0
  6. package/lib/boot/text.d.ts.map +1 -0
  7. package/lib/boot/text.js +68 -0
  8. package/lib/boot/text.js.map +1 -0
  9. package/lib/features/agent/PayloadBuilder.d.ts +6 -0
  10. package/lib/features/agent/PayloadBuilder.d.ts.map +1 -1
  11. package/lib/features/agent/chat/ModernAgentConversation.js +6 -4
  12. package/lib/features/agent/chat/ModernAgentConversation.js.map +1 -1
  13. package/lib/features/agent/chat/ModernAgentOutput/SummaryConversation.d.ts.map +1 -1
  14. package/lib/features/agent/chat/ModernAgentOutput/SummaryConversation.js +58 -0
  15. package/lib/features/agent/chat/ModernAgentOutput/SummaryConversation.js.map +1 -1
  16. package/lib/features/agent/chat/SkillWidgetProvider.d.ts.map +1 -1
  17. package/lib/features/agent/chat/SkillWidgetProvider.js +3 -2
  18. package/lib/features/agent/chat/SkillWidgetProvider.js.map +1 -1
  19. package/lib/i18n/LanguageProvider.d.ts +9 -1
  20. package/lib/i18n/LanguageProvider.d.ts.map +1 -1
  21. package/lib/i18n/LanguageProvider.js +7 -4
  22. package/lib/i18n/LanguageProvider.js.map +1 -1
  23. package/lib/i18n/locales/ar.json +5 -1
  24. package/lib/i18n/locales/de.json +5 -1
  25. package/lib/i18n/locales/en.json +5 -1
  26. package/lib/i18n/locales/es.json +5 -1
  27. package/lib/i18n/locales/fr.json +5 -1
  28. package/lib/i18n/locales/it.json +5 -1
  29. package/lib/i18n/locales/ja.json +5 -1
  30. package/lib/i18n/locales/ko.json +5 -1
  31. package/lib/i18n/locales/pt.json +5 -1
  32. package/lib/i18n/locales/ru.json +5 -1
  33. package/lib/i18n/locales/tr.json +5 -1
  34. package/lib/i18n/locales/zh-TW.json +5 -1
  35. package/lib/i18n/locales/zh.json +5 -1
  36. package/lib/i18n/rtl.d.ts +4 -2
  37. package/lib/i18n/rtl.d.ts.map +1 -1
  38. package/lib/i18n/rtl.js +4 -2
  39. package/lib/i18n/rtl.js.map +1 -1
  40. package/lib/session/UserSessionProvider.d.ts.map +1 -1
  41. package/lib/session/UserSessionProvider.js +10 -4
  42. package/lib/session/UserSessionProvider.js.map +1 -1
  43. package/lib/session/auth/oauth.d.ts.map +1 -1
  44. package/lib/session/auth/oauth.js +15 -11
  45. package/lib/session/auth/oauth.js.map +1 -1
  46. package/lib/shell/VertesiaShell.d.ts +7 -2
  47. package/lib/shell/VertesiaShell.d.ts.map +1 -1
  48. package/lib/shell/VertesiaShell.js +2 -2
  49. package/lib/shell/VertesiaShell.js.map +1 -1
  50. package/lib/shell/login/InviteAcceptModal.d.ts.map +1 -1
  51. package/lib/shell/login/InviteAcceptModal.js +83 -42
  52. package/lib/shell/login/InviteAcceptModal.js.map +1 -1
  53. package/lib/vertesia-ui-boot.js +52 -15
  54. package/lib/vertesia-ui-boot.js.map +1 -1
  55. package/lib/vertesia-ui-core.js +43 -43
  56. package/lib/vertesia-ui-core.js.map +1 -1
  57. package/lib/vertesia-ui-features.js +334 -334
  58. package/lib/vertesia-ui-features.js.map +1 -1
  59. package/lib/vertesia-ui-i18n.B1Nd8-dE.chunk.js +5 -0
  60. package/lib/vertesia-ui-i18n.B1Nd8-dE.chunk.js.map +1 -0
  61. package/lib/vertesia-ui-i18n.B2Z7mx1x.chunk.js +5 -0
  62. package/lib/vertesia-ui-i18n.B2Z7mx1x.chunk.js.map +1 -0
  63. package/lib/vertesia-ui-i18n.B756-YtS.chunk.js +5 -0
  64. package/lib/vertesia-ui-i18n.B756-YtS.chunk.js.map +1 -0
  65. package/lib/vertesia-ui-i18n.BICdgAEn.chunk.js +5 -0
  66. package/lib/vertesia-ui-i18n.BICdgAEn.chunk.js.map +1 -0
  67. package/lib/vertesia-ui-i18n.B_yx0c2A.chunk.js +5 -0
  68. package/lib/vertesia-ui-i18n.B_yx0c2A.chunk.js.map +1 -0
  69. package/lib/vertesia-ui-i18n.BwPrGiQn.chunk.js +5 -0
  70. package/lib/vertesia-ui-i18n.BwPrGiQn.chunk.js.map +1 -0
  71. package/lib/vertesia-ui-i18n.CfQbOHm3.chunk.js +5 -0
  72. package/lib/vertesia-ui-i18n.CfQbOHm3.chunk.js.map +1 -0
  73. package/lib/vertesia-ui-i18n.DMISlF16.chunk.js +5 -0
  74. package/lib/vertesia-ui-i18n.DMISlF16.chunk.js.map +1 -0
  75. package/lib/vertesia-ui-i18n.D_WA36Yq.chunk.js +5 -0
  76. package/lib/vertesia-ui-i18n.D_WA36Yq.chunk.js.map +1 -0
  77. package/lib/vertesia-ui-i18n.Dp3KuMs-.chunk.js +5 -0
  78. package/lib/vertesia-ui-i18n.Dp3KuMs-.chunk.js.map +1 -0
  79. package/lib/vertesia-ui-i18n.DsioKoQ3.chunk.js +5 -0
  80. package/lib/vertesia-ui-i18n.DsioKoQ3.chunk.js.map +1 -0
  81. package/lib/vertesia-ui-i18n.QEIqEPlv.chunk.js +5 -0
  82. package/lib/vertesia-ui-i18n.QEIqEPlv.chunk.js.map +1 -0
  83. package/lib/vertesia-ui-i18n.js +2 -2
  84. package/lib/vertesia-ui-i18n.js.map +1 -1
  85. package/lib/vertesia-ui-layout.js +15 -15
  86. package/lib/vertesia-ui-layout.js.map +1 -1
  87. package/lib/vertesia-ui-rich-text.js +44 -44
  88. package/lib/vertesia-ui-rich-text.js.map +1 -1
  89. package/lib/vertesia-ui-session.js +1 -1
  90. package/lib/vertesia-ui-session.js.map +1 -1
  91. package/lib/vertesia-ui-shell.js +51 -51
  92. package/lib/vertesia-ui-shell.js.map +1 -1
  93. package/lib/vertesia-ui-widgets.js +51 -51
  94. package/lib/vertesia-ui-widgets.js.map +1 -1
  95. package/package.json +20 -20
  96. package/src/boot/index.ts +74 -11
  97. package/src/boot/text.ts +80 -0
  98. package/src/features/agent/chat/ModernAgentConversation.tsx +7 -4
  99. package/src/features/agent/chat/ModernAgentOutput/SummaryConversation.ts +60 -0
  100. package/src/features/agent/chat/SkillWidgetProvider.tsx +3 -2
  101. package/src/i18n/LanguageProvider.tsx +16 -4
  102. package/src/i18n/locales/ar.json +5 -1
  103. package/src/i18n/locales/de.json +5 -1
  104. package/src/i18n/locales/en.json +5 -1
  105. package/src/i18n/locales/es.json +5 -1
  106. package/src/i18n/locales/fr.json +5 -1
  107. package/src/i18n/locales/it.json +5 -1
  108. package/src/i18n/locales/ja.json +5 -1
  109. package/src/i18n/locales/ko.json +5 -1
  110. package/src/i18n/locales/pt.json +5 -1
  111. package/src/i18n/locales/ru.json +5 -1
  112. package/src/i18n/locales/tr.json +5 -1
  113. package/src/i18n/locales/zh-TW.json +5 -1
  114. package/src/i18n/locales/zh.json +5 -1
  115. package/src/i18n/rtl.ts +5 -2
  116. package/src/session/UserSessionProvider.tsx +11 -4
  117. package/src/session/auth/oauth.ts +26 -12
  118. package/src/shell/VertesiaShell.tsx +8 -2
  119. package/src/shell/login/InviteAcceptModal.tsx +112 -65
  120. package/lib/vertesia-ui-i18n.1NWiB6fw.chunk.js +0 -5
  121. package/lib/vertesia-ui-i18n.1NWiB6fw.chunk.js.map +0 -1
  122. package/lib/vertesia-ui-i18n.6Ozlf6Ks.chunk.js +0 -5
  123. package/lib/vertesia-ui-i18n.6Ozlf6Ks.chunk.js.map +0 -1
  124. package/lib/vertesia-ui-i18n.AttJpg8U.chunk.js +0 -5
  125. package/lib/vertesia-ui-i18n.AttJpg8U.chunk.js.map +0 -1
  126. package/lib/vertesia-ui-i18n.BI_xdp49.chunk.js +0 -5
  127. package/lib/vertesia-ui-i18n.BI_xdp49.chunk.js.map +0 -1
  128. package/lib/vertesia-ui-i18n.C-2EEX6w.chunk.js +0 -5
  129. package/lib/vertesia-ui-i18n.C-2EEX6w.chunk.js.map +0 -1
  130. package/lib/vertesia-ui-i18n.CAmo54FO.chunk.js +0 -5
  131. package/lib/vertesia-ui-i18n.CAmo54FO.chunk.js.map +0 -1
  132. package/lib/vertesia-ui-i18n.CbBzmHNP.chunk.js +0 -5
  133. package/lib/vertesia-ui-i18n.CbBzmHNP.chunk.js.map +0 -1
  134. package/lib/vertesia-ui-i18n.DZgTzkIg.chunk.js +0 -5
  135. package/lib/vertesia-ui-i18n.DZgTzkIg.chunk.js.map +0 -1
  136. package/lib/vertesia-ui-i18n.DZsITxq5.chunk.js +0 -5
  137. package/lib/vertesia-ui-i18n.DZsITxq5.chunk.js.map +0 -1
  138. package/lib/vertesia-ui-i18n.Dbf2GHth.chunk.js +0 -5
  139. package/lib/vertesia-ui-i18n.Dbf2GHth.chunk.js.map +0 -1
  140. package/lib/vertesia-ui-i18n.lleWnH5n.chunk.js +0 -5
  141. package/lib/vertesia-ui-i18n.lleWnH5n.chunk.js.map +0 -1
  142. package/lib/vertesia-ui-i18n.wG_XSkbr.chunk.js +0 -5
  143. package/lib/vertesia-ui-i18n.wG_XSkbr.chunk.js.map +0 -1
@@ -251,7 +251,7 @@
251
251
  "agent.toggleRightSidebar": "오른쪽 사이드바 전환",
252
252
  "agent.typeDifferentResponse": "또는 다른 응답을 입력하세요...",
253
253
  "agent.typeYourMessage": "메시지를 입력하세요...",
254
- "agent.typeYourResponse": "응답을 입력하세요...",
254
+ "agent.typeYourResponse": "또는 다른 응답을 입력하세요...",
255
255
  "agent.unableToOpenPrint": "인쇄 미리보기를 열 수 없습니다",
256
256
  "agent.unknownError": "알 수 없는 오류",
257
257
  "agent.unknownTime": "알 수 없는 시간",
@@ -379,6 +379,10 @@
379
379
  "layout.searchPlaceholder": "검색...",
380
380
  "layout.toggleNavigationMenu": "탐색 메뉴 전환",
381
381
  "login.accept": "수락",
382
+ "login.inviteActionFailed": "이 초대를 업데이트할 수 없습니다. 목록을 새로 고쳤습니다.",
383
+ "login.inviteRole": "역할: {{role}}",
384
+ "login.invitedBy": "초대한 사람: {{name}}",
385
+ "login.invitesIntro": "다음 계정에 참여하도록 초대받았습니다. 각 초대를 수락하거나 거절하세요.",
382
386
  "login.reject": "거부",
383
387
  "login.reviewInvites": "초대 검토",
384
388
  "login.terminal.account": "계정",
@@ -253,7 +253,7 @@
253
253
  "agent.toggleRightSidebar": "Alternar painel lateral",
254
254
  "agent.typeDifferentResponse": "Ou digite uma resposta diferente...",
255
255
  "agent.typeYourMessage": "Digite sua mensagem...",
256
- "agent.typeYourResponse": "Digite sua resposta...",
256
+ "agent.typeYourResponse": "Ou digite uma resposta diferente...",
257
257
  "agent.unableToOpenPrint": "Não foi possível abrir a visualização de impressão",
258
258
  "agent.unknownError": "Erro desconhecido",
259
259
  "agent.unknownTime": "Hora desconhecida",
@@ -381,6 +381,10 @@
381
381
  "layout.searchPlaceholder": "Pesquisar...",
382
382
  "layout.toggleNavigationMenu": "Alternar menu de navegação",
383
383
  "login.accept": "Aceitar",
384
+ "login.inviteActionFailed": "Não foi possível atualizar este convite. A lista foi atualizada.",
385
+ "login.inviteRole": "Função: {{role}}",
386
+ "login.invitedBy": "Convidado por {{name}}",
387
+ "login.invitesIntro": "Você foi convidado a participar das seguintes contas. Aceite ou recuse cada convite.",
384
388
  "login.reject": "Rejeitar",
385
389
  "login.reviewInvites": "Revisar convites",
386
390
  "login.terminal.account": "Conta",
@@ -257,7 +257,7 @@
257
257
  "agent.toggleRightSidebar": "Переключить правую панель",
258
258
  "agent.typeDifferentResponse": "Или введите другой ответ...",
259
259
  "agent.typeYourMessage": "Введите сообщение...",
260
- "agent.typeYourResponse": "Введите ваш ответ...",
260
+ "agent.typeYourResponse": "Или введите другой ответ...",
261
261
  "agent.unableToOpenPrint": "Не удалось открыть предпросмотр печати",
262
262
  "agent.unknownError": "Неизвестная ошибка",
263
263
  "agent.unknownTime": "Неизвестное время",
@@ -385,6 +385,10 @@
385
385
  "layout.searchPlaceholder": "Поиск...",
386
386
  "layout.toggleNavigationMenu": "Переключить меню навигации",
387
387
  "login.accept": "Принять",
388
+ "login.inviteActionFailed": "Не удалось обновить это приглашение. Список обновлён.",
389
+ "login.inviteRole": "Роль: {{role}}",
390
+ "login.invitedBy": "Пригласил(а): {{name}}",
391
+ "login.invitesIntro": "Вас пригласили присоединиться к следующим аккаунтам. Примите или отклоните каждое приглашение.",
388
392
  "login.reject": "Отклонить",
389
393
  "login.reviewInvites": "Просмотреть приглашения",
390
394
  "login.terminal.account": "Аккаунт",
@@ -251,7 +251,7 @@
251
251
  "agent.toggleRightSidebar": "Sağ kenar çubuğunu aç/kapat",
252
252
  "agent.typeDifferentResponse": "Ya da farklı bir yanıt yazın...",
253
253
  "agent.typeYourMessage": "Mesajınızı yazın...",
254
- "agent.typeYourResponse": "Yanıtınızı yazın...",
254
+ "agent.typeYourResponse": "Ya da farklı bir yanıt yazın...",
255
255
  "agent.unableToOpenPrint": "Yazdırma önizlemesi açılamadı",
256
256
  "agent.unknownError": "Bilinmeyen hata",
257
257
  "agent.unknownTime": "Bilinmeyen zaman",
@@ -379,6 +379,10 @@
379
379
  "layout.searchPlaceholder": "Ara...",
380
380
  "layout.toggleNavigationMenu": "Gezinme menüsünü aç veya kapat",
381
381
  "login.accept": "Kabul Et",
382
+ "login.inviteActionFailed": "Bu davet güncellenemedi. Liste yenilendi.",
383
+ "login.inviteRole": "Rol: {{role}}",
384
+ "login.invitedBy": "Davet eden: {{name}}",
385
+ "login.invitesIntro": "Aşağıdaki hesaplara katılmaya davet edildiniz. Her daveti kabul edin veya reddedin.",
382
386
  "login.reject": "Reddet",
383
387
  "login.reviewInvites": "Davetleri İncele",
384
388
  "login.terminal.account": "Hesap",
@@ -251,7 +251,7 @@
251
251
  "agent.toggleRightSidebar": "切換右側邊欄",
252
252
  "agent.typeDifferentResponse": "或輸入其他回覆...",
253
253
  "agent.typeYourMessage": "輸入訊息...",
254
- "agent.typeYourResponse": "輸入您的回覆...",
254
+ "agent.typeYourResponse": "或輸入其他回覆...",
255
255
  "agent.unableToOpenPrint": "無法開啟列印預覽",
256
256
  "agent.unknownError": "未知錯誤",
257
257
  "agent.unknownTime": "未知時間",
@@ -379,6 +379,10 @@
379
379
  "layout.searchPlaceholder": "搜尋...",
380
380
  "layout.toggleNavigationMenu": "切換導覽選單",
381
381
  "login.accept": "接受",
382
+ "login.inviteActionFailed": "無法更新此邀請。清單已重新整理。",
383
+ "login.inviteRole": "角色:{{role}}",
384
+ "login.invitedBy": "邀請人:{{name}}",
385
+ "login.invitesIntro": "您已受邀加入以下帳戶。請接受或拒絕每個邀請。",
382
386
  "login.reject": "拒絕",
383
387
  "login.reviewInvites": "查看邀請",
384
388
  "login.terminal.account": "帳戶",
@@ -251,7 +251,7 @@
251
251
  "agent.toggleRightSidebar": "切换右侧边栏",
252
252
  "agent.typeDifferentResponse": "或输入其他回复...",
253
253
  "agent.typeYourMessage": "输入消息...",
254
- "agent.typeYourResponse": "输入您的回复...",
254
+ "agent.typeYourResponse": "或输入其他回复...",
255
255
  "agent.unableToOpenPrint": "无法打开打印预览",
256
256
  "agent.unknownError": "未知错误",
257
257
  "agent.unknownTime": "未知时间",
@@ -379,6 +379,10 @@
379
379
  "layout.searchPlaceholder": "搜索...",
380
380
  "layout.toggleNavigationMenu": "切换导航菜单",
381
381
  "login.accept": "接受",
382
+ "login.inviteActionFailed": "无法更新此邀请。列表已刷新。",
383
+ "login.inviteRole": "角色:{{role}}",
384
+ "login.invitedBy": "邀请人:{{name}}",
385
+ "login.invitesIntro": "您已被邀请加入以下账户。请接受或拒绝每个邀请。",
382
386
  "login.reject": "拒绝",
383
387
  "login.reviewInvites": "查看邀请",
384
388
  "login.terminal.account": "账户",
package/src/i18n/rtl.ts CHANGED
@@ -29,6 +29,9 @@ export const SUPPORTED_LANGUAGES = [
29
29
 
30
30
  export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
31
31
 
32
+ /** Where `LanguageProvider` persists the chosen language; the boot runtime reads it too. */
33
+ export const LANGUAGE_STORAGE_KEY = 'vertesia-ui-language';
34
+
32
35
  export const RTL_LANGUAGES: ReadonlySet<string> = new Set(['ar', 'he', 'fa', 'ur']);
33
36
 
34
37
  export function isRTL(lng?: string): boolean {
@@ -42,8 +45,8 @@ export function isRTL(lng?: string): boolean {
42
45
  * 2. base-language fallback (`pt-BR` -> `pt`)
43
46
  * 3. `'en'` fallback
44
47
  *
45
- * The inline FOUC script in `index.html` must reimplement this same logic
46
- * verbatim (it cannot import ESM before bundle load).
48
+ * The pre-React boot runtime (`../boot/index.ts`) reimplements this same logic
49
+ * in its inline script, which runs before any bundle loads.
47
50
  */
48
51
  export function resolveLanguage(raw: string | undefined | null): SupportedLanguage {
49
52
  if (!raw) return 'en';
@@ -280,10 +280,17 @@ export function UserSessionProvider({ children, loadOnboardingStatus = false }:
280
280
  })
281
281
  .catch((err) => {
282
282
  if (surfaceAuthError(err)) return;
283
- console.error('Unexpected failure while fetching a user token', err);
284
- Env.logger.error('Unexpected failure while fetching a user token', {
285
- vertesia: { account_id: selectedAccount, project_id: selectedProject, error: err },
286
- });
283
+ // A customer-domain user without an invite is an expected sign-in outcome, already
284
+ // logged at warn where the 403 is received.
285
+ const inviteRequired =
286
+ err instanceof Error &&
287
+ err.message === 'Customer-domain user requires an invite to join';
288
+ if (!inviteRequired) {
289
+ console.error('Unexpected failure while fetching a user token', err);
290
+ Env.logger.error('Unexpected failure while fetching a user token', {
291
+ vertesia: { account_id: selectedAccount, project_id: selectedProject, error: err },
292
+ });
293
+ }
287
294
  session.logout();
288
295
  session.isLoading = false;
289
296
  session.authError = err;
@@ -13,7 +13,14 @@ const MAX_TRANSACTION_AGE = 10 * 60_000;
13
13
  let pending: Promise<string> | undefined;
14
14
  // Long-lived credentials never enter browser storage. A reload resumes via the broker when access expires.
15
15
  let refreshSession:
16
- | { token: string; clientId: string; issuer: string; tokenEndpoint: string; revocationEndpoint?: string }
16
+ | {
17
+ token: string;
18
+ clientId: string;
19
+ issuer: string;
20
+ resource?: string;
21
+ tokenEndpoint: string;
22
+ revocationEndpoint?: string;
23
+ }
17
24
  | undefined;
18
25
 
19
26
  export class OAuthLoginError extends Error {}
@@ -61,6 +68,7 @@ interface Transaction {
61
68
  clientId: string;
62
69
  redirectUri: string;
63
70
  issuer: string;
71
+ resource?: string;
64
72
  target: string;
65
73
  rememberedScope?: ScopeSelection;
66
74
  retryScope?: ScopeSelection;
@@ -90,6 +98,12 @@ async function acquireToken(forceRefresh = false): Promise<string> {
90
98
  const redirectUri = redirect.toString();
91
99
  if (new URL(redirectUri).origin !== window.location.origin) throw new Error('OAuth callback must be same-origin');
92
100
  const issuer = (config.issuer ? httpsUrl(config.issuer).toString() : Env.endpoints.sts).replace(/\/+$/, '');
101
+ // A token is issued for one audience. The default audience follows the issuer, which is already part of every
102
+ // stored credential's identity; a configured one is recorded alongside it, so changing it never reuses a token,
103
+ // refresh session or login transaction issued for the previous audience.
104
+ const configuredResource = config.resource ? httpsUrl(config.resource).toString() : undefined;
105
+ const sameClient = (stored: { clientId: string; issuer: string; resource?: string }) =>
106
+ stored.clientId === clientId && stored.issuer === issuer && stored.resource === configuredResource;
93
107
  let current = new URL(window.location.href);
94
108
  let { accountId, projectId } = resolveAuthSelection(current);
95
109
  const callback = current.origin + current.pathname === new URL(redirectUri).origin + new URL(redirectUri).pathname;
@@ -105,9 +119,8 @@ async function acquireToken(forceRefresh = false): Promise<string> {
105
119
  if (
106
120
  !transaction ||
107
121
  transaction.state !== state ||
108
- transaction.clientId !== clientId ||
122
+ !sameClient(transaction) ||
109
123
  transaction.redirectUri !== redirectUri ||
110
- transaction.issuer !== issuer ||
111
124
  Date.now() - transaction.created > MAX_TRANSACTION_AGE ||
112
125
  transaction.created > Date.now()
113
126
  ) {
@@ -133,25 +146,21 @@ async function acquireToken(forceRefresh = false): Promise<string> {
133
146
  token: string;
134
147
  clientId: string;
135
148
  issuer: string;
149
+ resource?: string;
136
150
  refreshToken?: string;
137
151
  };
138
152
  const claims = jwtDecode<AuthTokenPayload>(cached.token);
139
153
  if (
140
154
  config.offlineAccess &&
141
- cached.clientId === clientId &&
142
- cached.issuer === issuer &&
155
+ sameClient(cached) &&
143
156
  (!projectId || claims.project?.id === projectId) &&
144
157
  (!accountId || claims.account?.id === accountId)
145
158
  )
146
- refreshCredential =
147
- refreshSession?.clientId === clientId && refreshSession.issuer === issuer
148
- ? refreshSession.token
149
- : undefined;
159
+ refreshCredential = refreshSession && sameClient(refreshSession) ? refreshSession.token : undefined;
150
160
  // Discard credentials persisted by older SDK versions rather than loading them into the new session.
151
161
  if (cached.refreshToken) sessionStorage.removeItem(TOKEN_KEY);
152
162
  if (
153
- cached.clientId === clientId &&
154
- cached.issuer === issuer &&
163
+ sameClient(cached) &&
155
164
  !forceRefresh &&
156
165
  claims.exp > Date.now() / 1000 + (config.offlineAccess ? 300 : 30) &&
157
166
  (!projectId || claims.project?.id === projectId) &&
@@ -179,7 +188,7 @@ async function acquireToken(forceRefresh = false): Promise<string> {
179
188
  const tokenEndpoint = httpsUrl(metadata.token_endpoint);
180
189
  const revocationEndpoint =
181
190
  metadata.revocation_endpoint === undefined ? undefined : httpsUrl(metadata.revocation_endpoint).toString();
182
- const resource = config.resource ? httpsUrl(config.resource).toString() : new URL(metadata.issuer).toString();
191
+ const resource = configuredResource ?? new URL(metadata.issuer).toString();
183
192
  if (refreshCredential) {
184
193
  try {
185
194
  const response = await fetch(tokenEndpoint, {
@@ -215,12 +224,14 @@ async function acquireToken(forceRefresh = false): Promise<string> {
215
224
  token: grant.access_token,
216
225
  clientId,
217
226
  issuer,
227
+ resource: configuredResource,
218
228
  }),
219
229
  );
220
230
  refreshSession = {
221
231
  token: grant.refresh_token ?? refreshCredential,
222
232
  clientId,
223
233
  issuer,
234
+ resource: configuredResource,
224
235
  tokenEndpoint: tokenEndpoint.toString(),
225
236
  revocationEndpoint,
226
237
  };
@@ -262,6 +273,7 @@ async function acquireToken(forceRefresh = false): Promise<string> {
262
273
  token: grant.access_token,
263
274
  clientId,
264
275
  issuer,
276
+ resource: configuredResource,
265
277
  }),
266
278
  );
267
279
  refreshSession =
@@ -270,6 +282,7 @@ async function acquireToken(forceRefresh = false): Promise<string> {
270
282
  token: grant.refresh_token,
271
283
  clientId,
272
284
  issuer,
285
+ resource: configuredResource,
273
286
  tokenEndpoint: tokenEndpoint.toString(),
274
287
  revocationEndpoint,
275
288
  }
@@ -307,6 +320,7 @@ async function acquireToken(forceRefresh = false): Promise<string> {
307
320
  clientId,
308
321
  redirectUri,
309
322
  issuer,
323
+ resource: configuredResource,
310
324
  target: current.toString(),
311
325
  rememberedScope:
312
326
  accountId && explicitScope?.projectId === undefined && (!explicitScope?.accountId || projectId)
@@ -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>