@vertesia/ui 1.6.0-dev.20261004.085740Z → 1.6.0-dev.20261009.095813Z

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 (235) hide show
  1. package/README.md +24 -0
  2. package/lib/boot/vite.d.ts +3 -0
  3. package/lib/boot/vite.d.ts.map +1 -1
  4. package/lib/boot/vite.js +12 -0
  5. package/lib/boot/vite.js.map +1 -1
  6. package/lib/core/components/shadcn/button.d.ts +1 -0
  7. package/lib/core/components/shadcn/button.d.ts.map +1 -1
  8. package/lib/core/components/shadcn/button.js +2 -2
  9. package/lib/core/components/shadcn/button.js.map +1 -1
  10. package/lib/core/components/shadcn/selectBox.d.ts.map +1 -1
  11. package/lib/core/components/shadcn/selectBox.js +2 -2
  12. package/lib/core/components/shadcn/selectBox.js.map +1 -1
  13. package/lib/core/components/shadcn/tabs.d.ts +2 -1
  14. package/lib/core/components/shadcn/tabs.d.ts.map +1 -1
  15. package/lib/core/components/shadcn/tabs.js +6 -4
  16. package/lib/core/components/shadcn/tabs.js.map +1 -1
  17. package/lib/env/index.d.ts +9 -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/chat/AgentRequestInputOverlay.d.ts +1 -1
  22. package/lib/features/agent/chat/AgentRequestInputOverlay.d.ts.map +1 -1
  23. package/lib/features/agent/chat/AgentRequestInputOverlay.js +16 -10
  24. package/lib/features/agent/chat/AgentRequestInputOverlay.js.map +1 -1
  25. package/lib/features/agent/chat/AskUserWidget.d.ts +7 -1
  26. package/lib/features/agent/chat/AskUserWidget.d.ts.map +1 -1
  27. package/lib/features/agent/chat/AskUserWidget.js +17 -16
  28. package/lib/features/agent/chat/AskUserWidget.js.map +1 -1
  29. package/lib/features/agent/chat/ModernAgentOutput/AllMessagesMixed.d.ts.map +1 -1
  30. package/lib/features/agent/chat/ModernAgentOutput/AllMessagesMixed.js +2 -253
  31. package/lib/features/agent/chat/ModernAgentOutput/AllMessagesMixed.js.map +1 -1
  32. package/lib/features/agent/chat/ModernAgentOutput/CollapsibleAgentMarkdown.d.ts +1 -3
  33. package/lib/features/agent/chat/ModernAgentOutput/CollapsibleAgentMarkdown.d.ts.map +1 -1
  34. package/lib/features/agent/chat/ModernAgentOutput/CollapsibleAgentMarkdown.js +2 -8
  35. package/lib/features/agent/chat/ModernAgentOutput/CollapsibleAgentMarkdown.js.map +1 -1
  36. package/lib/features/agent/chat/ModernAgentOutput/agentMarkdownStyles.d.ts +4 -0
  37. package/lib/features/agent/chat/ModernAgentOutput/agentMarkdownStyles.d.ts.map +1 -0
  38. package/lib/features/agent/chat/ModernAgentOutput/agentMarkdownStyles.js +248 -0
  39. package/lib/features/agent/chat/ModernAgentOutput/agentMarkdownStyles.js.map +1 -0
  40. package/lib/features/media-viewer/ImagePanel.d.ts +1 -1
  41. package/lib/features/media-viewer/ImagePanel.d.ts.map +1 -1
  42. package/lib/features/media-viewer/ImagePanel.js +40 -10
  43. package/lib/features/media-viewer/ImagePanel.js.map +1 -1
  44. package/lib/features/store/types/IntakePolicyEditor.d.ts.map +1 -1
  45. package/lib/features/store/types/IntakePolicyEditor.js +1 -1
  46. package/lib/features/store/types/IntakePolicyEditor.js.map +1 -1
  47. package/lib/i18n/locales/ar.json +15 -0
  48. package/lib/i18n/locales/de.json +15 -0
  49. package/lib/i18n/locales/en.json +15 -0
  50. package/lib/i18n/locales/es.json +15 -0
  51. package/lib/i18n/locales/fr.json +15 -0
  52. package/lib/i18n/locales/it.json +15 -0
  53. package/lib/i18n/locales/ja.json +15 -0
  54. package/lib/i18n/locales/ko.json +15 -0
  55. package/lib/i18n/locales/pt.json +15 -0
  56. package/lib/i18n/locales/ru.json +15 -0
  57. package/lib/i18n/locales/tr.json +15 -0
  58. package/lib/i18n/locales/zh-TW.json +15 -0
  59. package/lib/i18n/locales/zh.json +15 -0
  60. package/lib/session/auth/composable.d.ts +1 -5
  61. package/lib/session/auth/composable.d.ts.map +1 -1
  62. package/lib/session/auth/composable.js +1 -10
  63. package/lib/session/auth/composable.js.map +1 -1
  64. package/lib/session/auth/firebase.d.ts.map +1 -1
  65. package/lib/session/auth/firebase.js +8 -5
  66. package/lib/session/auth/firebase.js.map +1 -1
  67. package/lib/session/auth/oauth.d.ts.map +1 -1
  68. package/lib/session/auth/oauth.js +43 -17
  69. package/lib/session/auth/oauth.js.map +1 -1
  70. package/lib/session/auth/vertesiaHosts.d.ts +0 -40
  71. package/lib/session/auth/vertesiaHosts.d.ts.map +1 -1
  72. package/lib/session/auth/vertesiaHosts.js +2 -45
  73. package/lib/session/auth/vertesiaHosts.js.map +1 -1
  74. package/lib/session/index.d.ts +1 -1
  75. package/lib/session/index.d.ts.map +1 -1
  76. package/lib/session/index.js +1 -1
  77. package/lib/session/index.js.map +1 -1
  78. package/lib/session/scopeSelection.d.ts +4 -0
  79. package/lib/session/scopeSelection.d.ts.map +1 -1
  80. package/lib/session/scopeSelection.js +11 -0
  81. package/lib/session/scopeSelection.js.map +1 -1
  82. package/lib/shell/AuthScreenPreview.d.ts.map +1 -1
  83. package/lib/shell/AuthScreenPreview.js +1 -0
  84. package/lib/shell/AuthScreenPreview.js.map +1 -1
  85. package/lib/shell/login/SignInFlow.d.ts +3 -1
  86. package/lib/shell/login/SignInFlow.d.ts.map +1 -1
  87. package/lib/shell/login/SignInFlow.js +13 -4
  88. package/lib/shell/login/SignInFlow.js.map +1 -1
  89. package/lib/shell/login/SignInIcons.d.ts.map +1 -1
  90. package/lib/shell/login/SignInIcons.js +2 -1
  91. package/lib/shell/login/SignInIcons.js.map +1 -1
  92. package/lib/shell/login/SignInPasswordStep.d.ts +9 -0
  93. package/lib/shell/login/SignInPasswordStep.d.ts.map +1 -0
  94. package/lib/shell/login/SignInPasswordStep.js +58 -0
  95. package/lib/shell/login/SignInPasswordStep.js.map +1 -0
  96. package/lib/shell/login/SignInPrimitives.d.ts +13 -0
  97. package/lib/shell/login/SignInPrimitives.d.ts.map +1 -1
  98. package/lib/shell/login/SignInPrimitives.js +11 -1
  99. package/lib/shell/login/SignInPrimitives.js.map +1 -1
  100. package/lib/shell/login/SignInProvidersStep.d.ts +3 -1
  101. package/lib/shell/login/SignInProvidersStep.d.ts.map +1 -1
  102. package/lib/shell/login/SignInProvidersStep.js +4 -2
  103. package/lib/shell/login/SignInProvidersStep.js.map +1 -1
  104. package/lib/shell/login/SignInReturningStep.d.ts +3 -1
  105. package/lib/shell/login/SignInReturningStep.d.ts.map +1 -1
  106. package/lib/shell/login/SignInReturningStep.js +18 -8
  107. package/lib/shell/login/SignInReturningStep.js.map +1 -1
  108. package/lib/shell/login/SignInTenantStep.d.ts +3 -1
  109. package/lib/shell/login/SignInTenantStep.d.ts.map +1 -1
  110. package/lib/shell/login/SignInTenantStep.js +2 -2
  111. package/lib/shell/login/SignInTenantStep.js.map +1 -1
  112. package/lib/shell/login/SignInWithProviderButton.d.ts +5 -3
  113. package/lib/shell/login/SignInWithProviderButton.d.ts.map +1 -1
  114. package/lib/shell/login/SignInWithProviderButton.js +8 -7
  115. package/lib/shell/login/SignInWithProviderButton.js.map +1 -1
  116. package/lib/shell/login/SigninScreen.d.ts.map +1 -1
  117. package/lib/shell/login/SigninScreen.js +9 -3
  118. package/lib/shell/login/SigninScreen.js.map +1 -1
  119. package/lib/shell/login/signInUtils.d.ts +24 -4
  120. package/lib/shell/login/signInUtils.d.ts.map +1 -1
  121. package/lib/shell/login/signInUtils.js +53 -1
  122. package/lib/shell/login/signInUtils.js.map +1 -1
  123. package/lib/vertesia-ui-boot.js.map +1 -1
  124. package/lib/vertesia-ui-core.js +42 -42
  125. package/lib/vertesia-ui-core.js.map +1 -1
  126. package/lib/vertesia-ui-env.js +1 -1
  127. package/lib/vertesia-ui-env.js.map +1 -1
  128. package/lib/vertesia-ui-features.js +574 -589
  129. package/lib/vertesia-ui-features.js.map +1 -1
  130. package/lib/vertesia-ui-i18n.1NWiB6fw.chunk.js +5 -0
  131. package/lib/vertesia-ui-i18n.1NWiB6fw.chunk.js.map +1 -0
  132. package/lib/vertesia-ui-i18n.6Ozlf6Ks.chunk.js +5 -0
  133. package/lib/vertesia-ui-i18n.6Ozlf6Ks.chunk.js.map +1 -0
  134. package/lib/vertesia-ui-i18n.AttJpg8U.chunk.js +5 -0
  135. package/lib/vertesia-ui-i18n.AttJpg8U.chunk.js.map +1 -0
  136. package/lib/vertesia-ui-i18n.BI_xdp49.chunk.js +5 -0
  137. package/lib/vertesia-ui-i18n.BI_xdp49.chunk.js.map +1 -0
  138. package/lib/vertesia-ui-i18n.C-2EEX6w.chunk.js +5 -0
  139. package/lib/vertesia-ui-i18n.C-2EEX6w.chunk.js.map +1 -0
  140. package/lib/vertesia-ui-i18n.CAmo54FO.chunk.js +5 -0
  141. package/lib/vertesia-ui-i18n.CAmo54FO.chunk.js.map +1 -0
  142. package/lib/vertesia-ui-i18n.CbBzmHNP.chunk.js +5 -0
  143. package/lib/vertesia-ui-i18n.CbBzmHNP.chunk.js.map +1 -0
  144. package/lib/vertesia-ui-i18n.DZgTzkIg.chunk.js +5 -0
  145. package/lib/vertesia-ui-i18n.DZgTzkIg.chunk.js.map +1 -0
  146. package/lib/vertesia-ui-i18n.DZsITxq5.chunk.js +5 -0
  147. package/lib/vertesia-ui-i18n.DZsITxq5.chunk.js.map +1 -0
  148. package/lib/vertesia-ui-i18n.Dbf2GHth.chunk.js +5 -0
  149. package/lib/vertesia-ui-i18n.Dbf2GHth.chunk.js.map +1 -0
  150. package/lib/vertesia-ui-i18n.js +2 -2
  151. package/lib/vertesia-ui-i18n.js.map +1 -1
  152. package/lib/vertesia-ui-i18n.lleWnH5n.chunk.js +5 -0
  153. package/lib/vertesia-ui-i18n.lleWnH5n.chunk.js.map +1 -0
  154. package/lib/vertesia-ui-i18n.wG_XSkbr.chunk.js +5 -0
  155. package/lib/vertesia-ui-i18n.wG_XSkbr.chunk.js.map +1 -0
  156. package/lib/vertesia-ui-layout.js +15 -15
  157. package/lib/vertesia-ui-layout.js.map +1 -1
  158. package/lib/vertesia-ui-rich-text.js +40 -40
  159. package/lib/vertesia-ui-rich-text.js.map +1 -1
  160. package/lib/vertesia-ui-router.js.map +1 -1
  161. package/lib/vertesia-ui-session.js +1 -1
  162. package/lib/vertesia-ui-session.js.map +1 -1
  163. package/lib/vertesia-ui-shell.js +54 -47
  164. package/lib/vertesia-ui-shell.js.map +1 -1
  165. package/lib/vertesia-ui-widgets.js +47 -47
  166. package/lib/vertesia-ui-widgets.js.map +1 -1
  167. package/package.json +20 -20
  168. package/src/boot/vite.ts +15 -0
  169. package/src/core/components/shadcn/button.tsx +3 -2
  170. package/src/core/components/shadcn/selectBox.tsx +12 -4
  171. package/src/core/components/shadcn/tabs.tsx +9 -3
  172. package/src/css/index.css +1 -1
  173. package/src/css/typography.css +111 -88
  174. package/src/env/index.ts +10 -3
  175. package/src/features/agent/chat/AgentRequestInputOverlay.tsx +73 -34
  176. package/src/features/agent/chat/AskUserWidget.tsx +225 -206
  177. package/src/features/agent/chat/ModernAgentOutput/AllMessagesMixed.tsx +2 -253
  178. package/src/features/agent/chat/ModernAgentOutput/CollapsibleAgentMarkdown.tsx +2 -8
  179. package/src/features/agent/chat/ModernAgentOutput/agentMarkdownStyles.ts +248 -0
  180. package/src/features/media-viewer/ImagePanel.tsx +51 -10
  181. package/src/features/store/types/IntakePolicyEditor.tsx +2 -1
  182. package/src/i18n/locales/ar.json +15 -0
  183. package/src/i18n/locales/de.json +15 -0
  184. package/src/i18n/locales/en.json +15 -0
  185. package/src/i18n/locales/es.json +15 -0
  186. package/src/i18n/locales/fr.json +15 -0
  187. package/src/i18n/locales/it.json +15 -0
  188. package/src/i18n/locales/ja.json +15 -0
  189. package/src/i18n/locales/ko.json +15 -0
  190. package/src/i18n/locales/pt.json +15 -0
  191. package/src/i18n/locales/ru.json +15 -0
  192. package/src/i18n/locales/tr.json +15 -0
  193. package/src/i18n/locales/zh-TW.json +15 -0
  194. package/src/i18n/locales/zh.json +15 -0
  195. package/src/session/auth/composable.ts +1 -11
  196. package/src/session/auth/firebase.ts +9 -6
  197. package/src/session/auth/oauth.ts +47 -16
  198. package/src/session/auth/vertesiaHosts.ts +2 -47
  199. package/src/session/index.ts +1 -1
  200. package/src/session/scopeSelection.ts +13 -0
  201. package/src/shell/AuthScreenPreview.tsx +1 -0
  202. package/src/shell/login/SignInFlow.tsx +23 -2
  203. package/src/shell/login/SignInIcons.tsx +2 -1
  204. package/src/shell/login/SignInPasswordStep.tsx +103 -0
  205. package/src/shell/login/SignInPrimitives.tsx +53 -1
  206. package/src/shell/login/SignInProvidersStep.tsx +8 -4
  207. package/src/shell/login/SignInReturningStep.tsx +20 -8
  208. package/src/shell/login/SignInTenantStep.tsx +6 -2
  209. package/src/shell/login/SignInWithProviderButton.tsx +11 -7
  210. package/src/shell/login/SigninScreen.tsx +8 -3
  211. package/src/shell/login/signInUtils.ts +68 -8
  212. package/lib/vertesia-ui-i18n.BCPusS2_.chunk.js +0 -5
  213. package/lib/vertesia-ui-i18n.BCPusS2_.chunk.js.map +0 -1
  214. package/lib/vertesia-ui-i18n.BHWlHchf.chunk.js +0 -5
  215. package/lib/vertesia-ui-i18n.BHWlHchf.chunk.js.map +0 -1
  216. package/lib/vertesia-ui-i18n.BkNx8SZu.chunk.js +0 -5
  217. package/lib/vertesia-ui-i18n.BkNx8SZu.chunk.js.map +0 -1
  218. package/lib/vertesia-ui-i18n.C1LasLrO.chunk.js +0 -5
  219. package/lib/vertesia-ui-i18n.C1LasLrO.chunk.js.map +0 -1
  220. package/lib/vertesia-ui-i18n.D8PQ5yKU.chunk.js +0 -5
  221. package/lib/vertesia-ui-i18n.D8PQ5yKU.chunk.js.map +0 -1
  222. package/lib/vertesia-ui-i18n.DUtVV1Bq.chunk.js +0 -5
  223. package/lib/vertesia-ui-i18n.DUtVV1Bq.chunk.js.map +0 -1
  224. package/lib/vertesia-ui-i18n.DgA36_k5.chunk.js +0 -5
  225. package/lib/vertesia-ui-i18n.DgA36_k5.chunk.js.map +0 -1
  226. package/lib/vertesia-ui-i18n.DmG-fjjZ.chunk.js +0 -5
  227. package/lib/vertesia-ui-i18n.DmG-fjjZ.chunk.js.map +0 -1
  228. package/lib/vertesia-ui-i18n.DreWvQIk.chunk.js +0 -5
  229. package/lib/vertesia-ui-i18n.DreWvQIk.chunk.js.map +0 -1
  230. package/lib/vertesia-ui-i18n.Pp8Tj48d.chunk.js +0 -5
  231. package/lib/vertesia-ui-i18n.Pp8Tj48d.chunk.js.map +0 -1
  232. package/lib/vertesia-ui-i18n.gais--x5.chunk.js +0 -5
  233. package/lib/vertesia-ui-i18n.gais--x5.chunk.js.map +0 -1
  234. package/lib/vertesia-ui-i18n.xkqaKpY_.chunk.js +0 -5
  235. package/lib/vertesia-ui-i18n.xkqaKpY_.chunk.js.map +0 -1
@@ -139,6 +139,7 @@
139
139
  "agent.fullscreen": "全屏",
140
140
  "agent.hideActiveWorkstreams": "隐藏活跃工作流",
141
141
  "agent.hideDetails": "隐藏详情",
142
+ "agent.hideQuestions": "隐藏问题",
142
143
  "agent.hideSidebar": "隐藏侧边栏",
143
144
  "agent.hideThinkingDetails": "隐藏思考详情",
144
145
  "agent.inProgress": "进行中",
@@ -181,6 +182,7 @@
181
182
  "agent.pdfExportFailed": "PDF导出失败",
182
183
  "agent.pdfExportReady": "PDF导出已就绪",
183
184
  "agent.pending": "待处理",
185
+ "agent.pendingQuestions": "等待您的回复",
184
186
  "agent.plan": "计划",
185
187
  "agent.planProgress": "计划进度",
186
188
  "agent.plansWillAppear": "当代理创建计划时,将在此处显示",
@@ -225,6 +227,7 @@
225
227
  "agent.showDetails": "显示详情",
226
228
  "agent.showLess": "显示更少",
227
229
  "agent.showMore": "显示更多",
230
+ "agent.showQuestions": "显示问题",
228
231
  "agent.showSidebar": "显示侧边栏",
229
232
  "agent.showSystemArtifacts": "系统文件",
230
233
  "agent.staged": "已暂存",
@@ -246,6 +249,7 @@
246
249
  "agent.thinking": "思考中...",
247
250
  "agent.timeout": "已超时",
248
251
  "agent.toggleRightSidebar": "切换右侧边栏",
252
+ "agent.typeDifferentResponse": "或输入其他回复...",
249
253
  "agent.typeYourMessage": "输入消息...",
250
254
  "agent.typeYourResponse": "输入您的回复...",
251
255
  "agent.unableToOpenPrint": "无法打开打印预览",
@@ -286,6 +290,17 @@
286
290
  "auth.email.placeholder": "you@company.com",
287
291
  "auth.email.title": "欢迎使用 Vertesia",
288
292
  "auth.error": "错误:{{message}}",
293
+ "auth.password.body": "请输入此账户的密码。",
294
+ "auth.password.continue": "使用密码继续",
295
+ "auth.password.eyebrow": "密码登录",
296
+ "auth.password.failed": "无法登录,请重试。",
297
+ "auth.password.invalidCredentials": "电子邮件或密码不正确。",
298
+ "auth.password.label": "密码",
299
+ "auth.password.notAvailable": "此电子邮件不支持密码登录。",
300
+ "auth.password.required": "请输入密码。",
301
+ "auth.password.submit": "登录",
302
+ "auth.password.title": "输入密码",
303
+ "auth.password.tooManyAttempts": "尝试次数过多。请等待几分钟后重试。",
289
304
  "auth.pending.authenticating": "正在认证",
290
305
  "auth.pending.body": "请稍候 — 我们正在为您安全跳转。只需稍候片刻。",
291
306
  "auth.pending.cancel": "取消",
@@ -116,17 +116,7 @@ function identityFromAcceptedToken(token: string): AuthenticatedIdentity | undef
116
116
  }
117
117
  }
118
118
 
119
- export function resolveAuthSelection(currentUrl: URL): { accountId?: string; projectId?: string } {
120
- // A URL selection owns the whole pair: never attach an unrelated configured account/project.
121
- const hasUrlScope = currentUrl.searchParams.has('a') || currentUrl.searchParams.has('p');
122
- const defaults = hasUrlScope ? undefined : Env.defaultAuthSelection;
123
- const urlAccount = currentUrl.searchParams.get('a') ?? defaults?.accountId;
124
- const urlProject = currentUrl.searchParams.get('p') ?? defaults?.projectId;
125
- // A project alone identifies its account, so the stored selection only fills in a missing project.
126
- const stored = urlProject === undefined ? readScopeSelection(urlAccount) : undefined;
127
-
128
- return { accountId: urlAccount ?? stored?.accountId, projectId: urlProject ?? stored?.projectId };
129
- }
119
+ export { resolveAuthSelection } from '../scopeSelection';
130
120
 
131
121
  function decodeToken(token: string): AuthTokenPayload {
132
122
  return jwtDecode(token) as AuthTokenPayload;
@@ -92,10 +92,9 @@ export async function setFirebaseTenant(tenantEmail?: string) {
92
92
  // Handle HTTP error responses
93
93
  if (!response.ok) {
94
94
  // If the error is 404 Not Found, no need to retry
95
+ // How older servers answered "this address has no SSO tenant": an ordinary outcome.
95
96
  if (response.status === 404) {
96
- Env.logger.warn('Firebase tenant was not found; continuing with the default tenant', {
97
- vertesia: { status: response.status },
98
- });
97
+ Env.logger.debug('No Firebase tenant for this address; using the default tenant');
99
98
  return;
100
99
  }
101
100
 
@@ -103,10 +102,14 @@ export async function setFirebaseTenant(tenantEmail?: string) {
103
102
  throw new Error(errorData?.error ?? `HTTP error ${response.status}`);
104
103
  }
105
104
 
106
- // Successfully got a response, parse it
107
- const data = (await response.json()) as UIResolvedTenant;
105
+ // Successfully got a response, parse it. `null` means the address has no SSO tenant.
106
+ const data = (await response.json()) as UIResolvedTenant | null;
107
+ if (data === null) {
108
+ Env.logger.debug('No Firebase tenant for this address; using the default tenant');
109
+ return;
110
+ }
108
111
 
109
- if (data?.firebaseTenantId) {
112
+ if (data.firebaseTenantId) {
110
113
  const auth = getFirebaseAuth();
111
114
  auth.tenantId = data.firebaseTenantId;
112
115
  Env.firebase.providerType = data.provider ?? 'oidc';
@@ -1,6 +1,7 @@
1
- import type { AuthTokenPayload } from '@vertesia/common';
1
+ import { type AuthTokenPayload, REQUESTED_SCOPE_UNAVAILABLE_ERROR_CODE } from '@vertesia/common';
2
2
  import { Env } from '@vertesia/ui/env';
3
3
  import { jwtDecode } from 'jwt-decode';
4
+ import { forgetRejectedScopeSelection, resolveAuthSelection, type ScopeSelection } from '../scopeSelection';
4
5
  import { markCentralAuthRoundTripStarted } from './authRoundTrip';
5
6
  import { verifyAuthState } from './authState';
6
7
  import { usesGatewaySession } from './gateway';
@@ -61,6 +62,8 @@ interface Transaction {
61
62
  redirectUri: string;
62
63
  issuer: string;
63
64
  target: string;
65
+ rememberedScope?: ScopeSelection;
66
+ retryScope?: ScopeSelection;
64
67
  }
65
68
 
66
69
  export function clearAppOAuth(): void {
@@ -86,8 +89,9 @@ async function acquireToken(forceRefresh = false): Promise<string> {
86
89
  }
87
90
  const redirectUri = redirect.toString();
88
91
  if (new URL(redirectUri).origin !== window.location.origin) throw new Error('OAuth callback must be same-origin');
89
- const issuer = Env.endpoints.sts.replace(/\/+$/, '');
90
- const current = new URL(window.location.href);
92
+ const issuer = (config.issuer ? httpsUrl(config.issuer).toString() : Env.endpoints.sts).replace(/\/+$/, '');
93
+ let current = new URL(window.location.href);
94
+ let { accountId, projectId } = resolveAuthSelection(current);
91
95
  const callback = current.origin + current.pathname === new URL(redirectUri).origin + new URL(redirectUri).pathname;
92
96
  const state = callback ? current.searchParams.get('state') : null;
93
97
  const code = callback ? current.searchParams.get('code') : null;
@@ -109,7 +113,19 @@ async function acquireToken(forceRefresh = false): Promise<string> {
109
113
  ) {
110
114
  throw new Error('Invalid or expired OAuth login state');
111
115
  }
112
- if (error || !code) throw new Error(`Application sign-in was not completed (${error || 'missing code'})`);
116
+ if (error === REQUESTED_SCOPE_UNAVAILABLE_ERROR_CODE && transaction.rememberedScope?.accountId) {
117
+ const target = new URL(transaction.target);
118
+ if (target.origin !== window.location.origin) throw new Error('Invalid OAuth return target');
119
+ forgetRejectedScopeSelection(transaction.rememberedScope.accountId, transaction.rememberedScope.projectId);
120
+ clearAppOAuth();
121
+ current = target;
122
+ window.history.replaceState(window.history.state, '', current);
123
+ accountId = transaction.retryScope?.accountId;
124
+ projectId = undefined;
125
+ transaction = undefined;
126
+ } else if (error || !code) {
127
+ throw new Error(`Application sign-in was not completed (${error || 'missing code'})`);
128
+ }
113
129
  } else {
114
130
  const raw = sessionStorage.getItem(TOKEN_KEY);
115
131
  if (raw) {
@@ -124,8 +140,8 @@ async function acquireToken(forceRefresh = false): Promise<string> {
124
140
  config.offlineAccess &&
125
141
  cached.clientId === clientId &&
126
142
  cached.issuer === issuer &&
127
- (!current.searchParams.get('p') || claims.project?.id === current.searchParams.get('p')) &&
128
- (!current.searchParams.get('a') || claims.account?.id === current.searchParams.get('a'))
143
+ (!projectId || claims.project?.id === projectId) &&
144
+ (!accountId || claims.account?.id === accountId)
129
145
  )
130
146
  refreshCredential =
131
147
  refreshSession?.clientId === clientId && refreshSession.issuer === issuer
@@ -138,14 +154,19 @@ async function acquireToken(forceRefresh = false): Promise<string> {
138
154
  cached.issuer === issuer &&
139
155
  !forceRefresh &&
140
156
  claims.exp > Date.now() / 1000 + (config.offlineAccess ? 300 : 30) &&
141
- (!current.searchParams.get('p') || claims.project?.id === current.searchParams.get('p')) &&
142
- (!current.searchParams.get('a') || claims.account?.id === current.searchParams.get('a'))
157
+ (!projectId || claims.project?.id === projectId) &&
158
+ (!accountId || claims.account?.id === accountId)
143
159
  ) {
144
160
  return cached.token;
145
161
  }
146
162
  }
147
163
  }
148
- const discovery = await fetch(`${issuer}/.well-known/oauth-authorization-server`);
164
+ const issuerUrl = new URL(issuer);
165
+ const discoveryUrl = new URL(
166
+ `/.well-known/oauth-authorization-server${issuerUrl.pathname.replace(/\/$/, '')}`,
167
+ issuerUrl.origin,
168
+ );
169
+ const discovery = await fetch(discoveryUrl);
149
170
  if (!discovery.ok) throw new Error(`OAuth discovery failed (${discovery.status})`);
150
171
  const metadata: {
151
172
  issuer: string;
@@ -158,7 +179,7 @@ async function acquireToken(forceRefresh = false): Promise<string> {
158
179
  const tokenEndpoint = httpsUrl(metadata.token_endpoint);
159
180
  const revocationEndpoint =
160
181
  metadata.revocation_endpoint === undefined ? undefined : httpsUrl(metadata.revocation_endpoint).toString();
161
- const resource = new URL(metadata.issuer).toString();
182
+ const resource = config.resource ? httpsUrl(config.resource).toString() : new URL(metadata.issuer).toString();
162
183
  if (refreshCredential) {
163
184
  try {
164
185
  const response = await fetch(tokenEndpoint, {
@@ -184,8 +205,8 @@ async function acquireToken(forceRefresh = false): Promise<string> {
184
205
  claims.iss.replace(/\/+$/, '') !== issuer ||
185
206
  claims.client_id !== clientId ||
186
207
  claims.exp <= Date.now() / 1000 ||
187
- (!!current.searchParams.get('a') && claims.account?.id !== current.searchParams.get('a')) ||
188
- (!!current.searchParams.get('p') && claims.project?.id !== current.searchParams.get('p'))
208
+ (!!accountId && claims.account?.id !== accountId) ||
209
+ (!!projectId && claims.project?.id !== projectId)
189
210
  )
190
211
  throw new OAuthLoginError('Invalid OAuth refresh identity');
191
212
  sessionStorage.setItem(
@@ -272,6 +293,13 @@ async function acquireToken(forceRefresh = false): Promise<string> {
272
293
  throw new Error('Application CIMD does not allow this callback');
273
294
  if (!config.scopes && document.scope) scopes = document.scope.split(/\s+/);
274
295
  }
296
+ const explicitScope =
297
+ current.searchParams.has('a') || current.searchParams.has('p')
298
+ ? {
299
+ accountId: current.searchParams.get('a') ?? undefined,
300
+ projectId: current.searchParams.get('p') ?? undefined,
301
+ }
302
+ : Env.defaultAuthSelection;
275
303
  const verifier = randomValue();
276
304
  const next: Transaction = {
277
305
  state: randomValue(),
@@ -280,6 +308,11 @@ async function acquireToken(forceRefresh = false): Promise<string> {
280
308
  redirectUri,
281
309
  issuer,
282
310
  target: current.toString(),
311
+ rememberedScope:
312
+ accountId && explicitScope?.projectId === undefined && (!explicitScope?.accountId || projectId)
313
+ ? { accountId, projectId }
314
+ : undefined,
315
+ retryScope: explicitScope?.accountId ? { accountId: explicitScope.accountId } : undefined,
283
316
  created: Date.now(),
284
317
  };
285
318
  const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
@@ -297,10 +330,8 @@ async function acquireToken(forceRefresh = false): Promise<string> {
297
330
  : scopes.filter((s) => s !== 'offline_access')
298
331
  ).join(' '),
299
332
  );
300
- const account = current.searchParams.get('a');
301
- if (account) authorize.searchParams.set('account_id', account);
302
- const project = current.searchParams.get('p');
303
- if (project) authorize.searchParams.set('project_id', project);
333
+ if (accountId) authorize.searchParams.set('account_id', accountId);
334
+ if (projectId) authorize.searchParams.set('project_id', projectId);
304
335
  sessionStorage.setItem(TRANSACTION_KEY, JSON.stringify(next));
305
336
  markCentralAuthRoundTripStarted();
306
337
  window.location.replace(authorize.toString());
@@ -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);
@@ -95,6 +95,7 @@ export function AuthScreenPreview({
95
95
  setTenant: noop,
96
96
  setStoredSession: noop,
97
97
  onProceedFromEmail: noop,
98
+ onPasswordRequired: noop,
98
99
  onBack: noop,
99
100
  onNotYou: noop,
100
101
  onProviderClicked: noop,
@@ -13,6 +13,7 @@
13
13
  import { useCallback, useEffect, useState } from 'react';
14
14
  import SignInAuthPending from './SignInAuthPending';
15
15
  import SignInEmailStep, { type TenantInfo } from './SignInEmailStep';
16
+ import SignInPasswordStep from './SignInPasswordStep';
16
17
  import SignInProvidersStep from './SignInProvidersStep';
17
18
  import SignInReturningStep from './SignInReturningStep';
18
19
  import SignInTenantStep from './SignInTenantStep';
@@ -28,7 +29,7 @@ import {
28
29
  } from './signInUtils';
29
30
 
30
31
  /** The modes every host shares. Hosts may add their own; see the `M` parameter below. */
31
- export type SignInCoreMode = 'email' | 'providers' | 'tenant' | 'returning' | 'pending';
32
+ export type SignInCoreMode = 'email' | 'providers' | 'tenant' | 'password' | 'returning' | 'pending';
32
33
 
33
34
  export interface SignInFlowOptions {
34
35
  /**
@@ -64,6 +65,8 @@ export interface SignInFlowController<M extends string = never> {
64
65
  pendingProvider: ProviderId | null;
65
66
  /** Email accepted: go to the tenant step when one resolved, otherwise the provider list. */
66
67
  onProceedFromEmail: (email: string, tenant?: TenantInfo) => void;
68
+ /** Show password entry when tenant discovery or a remembered provider requires it. */
69
+ onPasswordRequired: (email: string) => void;
67
70
  /** Back to the email step, dropping the resolved tenant. */
68
71
  onBack: () => void;
69
72
  /** "Not you?" — forget the remembered identity and sign out. */
@@ -92,7 +95,12 @@ export function useSignInFlow<M extends string = never>(options: SignInFlowOptio
92
95
  const onProceedFromEmail = useCallback((e: string, t: TenantInfo | undefined) => {
93
96
  setEmail(e);
94
97
  setTenant(t);
95
- setMode(t ? 'tenant' : 'providers');
98
+ setMode(t?.provider === 'password' ? 'password' : t ? 'tenant' : 'providers');
99
+ }, []);
100
+
101
+ const onPasswordRequired = useCallback((e: string) => {
102
+ setEmail(e);
103
+ setMode('password');
96
104
  }, []);
97
105
 
98
106
  const onBack = useCallback(() => {
@@ -144,6 +152,7 @@ export function useSignInFlow<M extends string = never>(options: SignInFlowOptio
144
152
  setStoredSession,
145
153
  pendingProvider,
146
154
  onProceedFromEmail,
155
+ onPasswordRequired,
147
156
  onBack,
148
157
  onNotYou,
149
158
  onProviderClicked,
@@ -178,6 +187,15 @@ export function SignInFlowSteps({ flow, redirectTo }: SignInFlowStepsProps) {
178
187
  if (mode === 'pending' && pendingProvider) {
179
188
  return <SignInAuthPending provider={pendingProvider} />;
180
189
  }
190
+ if (mode === 'password' && email) {
191
+ return (
192
+ <SignInPasswordStep
193
+ email={email}
194
+ tenantName={tenant?.label || tenant?.name || storedSession?.tenantName || undefined}
195
+ onBack={flow.onBack}
196
+ />
197
+ );
198
+ }
181
199
  if (mode === 'tenant' && tenant) {
182
200
  return (
183
201
  <SignInTenantStep
@@ -185,6 +203,7 @@ export function SignInFlowSteps({ flow, redirectTo }: SignInFlowStepsProps) {
185
203
  tenant={tenant}
186
204
  onBack={flow.onBack}
187
205
  onProviderClicked={() => flow.onProviderClicked((tenant.provider ?? 'oidc') as ProviderId)}
206
+ onPasswordRequired={flow.onPasswordRequired}
188
207
  redirectTo={redirectTo}
189
208
  />
190
209
  );
@@ -195,6 +214,7 @@ export function SignInFlowSteps({ flow, redirectTo }: SignInFlowStepsProps) {
195
214
  email={email}
196
215
  onBack={flow.onBack}
197
216
  onProviderClicked={flow.onProviderClicked}
217
+ onPasswordRequired={flow.onPasswordRequired}
198
218
  redirectTo={redirectTo}
199
219
  />
200
220
  );
@@ -205,6 +225,7 @@ export function SignInFlowSteps({ flow, redirectTo }: SignInFlowStepsProps) {
205
225
  session={storedSession}
206
226
  onNotYou={flow.onNotYou}
207
227
  onProviderClicked={flow.onProviderClicked}
228
+ onPasswordRequired={flow.onPasswordRequired}
208
229
  redirectTo={redirectTo}
209
230
  />
210
231
  );
@@ -1,4 +1,4 @@
1
- import { LockKeyhole } from 'lucide-react';
1
+ import { KeyRound, LockKeyhole } from 'lucide-react';
2
2
  import type { ComponentType, SVGProps } from 'react';
3
3
  import type { ProviderId } from './signInUtils';
4
4
 
@@ -58,6 +58,7 @@ export const PROVIDER_ICONS: Record<ProviderId, ComponentType<{ className?: stri
58
58
  github: GithubIcon,
59
59
  microsoft: MicrosoftIcon,
60
60
  oidc: OidcIcon,
61
+ password: KeyRound,
61
62
  };
62
63
 
63
64
  export function providerIcon(provider: ProviderId | string | undefined): ComponentType<{ className?: string }> {
@@ -0,0 +1,103 @@
1
+ import { Spinner } from '@vertesia/ui/core';
2
+ import { useUITranslation } from '@vertesia/ui/i18n';
3
+ import { ArrowRight } from 'lucide-react';
4
+ import { useEffect, useRef, useState } from 'react';
5
+ import {
6
+ SignInEmailRow,
7
+ SignInPasswordField,
8
+ SignInStepButton,
9
+ SignInStepHeader,
10
+ SignInStepLayout,
11
+ } from './SignInPrimitives';
12
+ import { type PasswordSignInFailure, signInWithPassword } from './signInUtils';
13
+
14
+ interface SignInPasswordStepProps {
15
+ email: string;
16
+ tenantName?: string;
17
+ onBack: () => void;
18
+ }
19
+
20
+ /** Password sign-in for an address whose tenant issues passwords instead of federating an IdP. */
21
+ export default function SignInPasswordStep({ email, tenantName, onBack }: SignInPasswordStepProps) {
22
+ const { t } = useUITranslation();
23
+ const [password, setPassword] = useState('');
24
+ const [failure, setFailure] = useState<PasswordSignInFailure | null>(null);
25
+ const [emptyError, setEmptyError] = useState(false);
26
+ const [loading, setLoading] = useState(false);
27
+ const inputRef = useRef<HTMLInputElement>(null);
28
+
29
+ useEffect(() => {
30
+ inputRef.current?.focus();
31
+ }, []);
32
+
33
+ const submit = async (e: React.FormEvent) => {
34
+ e.preventDefault();
35
+ if (loading) return;
36
+ if (!password) {
37
+ setEmptyError(true);
38
+ return;
39
+ }
40
+ setEmptyError(false);
41
+ setFailure(null);
42
+ setLoading(true);
43
+ const result = await signInWithPassword(email, password);
44
+ if (!result.ok) {
45
+ setFailure(result.reason);
46
+ setLoading(false);
47
+ }
48
+ // On success the spinner stays up: the session's auth-state listener finishes the login and
49
+ // unmounts this screen.
50
+ };
51
+
52
+ // Literal keys, one per failure, so i18next-cli extraction sees every message.
53
+ const failureMessage = (reason: PasswordSignInFailure) => {
54
+ switch (reason) {
55
+ case 'invalid-credentials':
56
+ return t('auth.password.invalidCredentials');
57
+ case 'too-many-attempts':
58
+ return t('auth.password.tooManyAttempts');
59
+ case 'not-password-tenant':
60
+ return t('auth.password.notAvailable');
61
+ case 'failed':
62
+ return t('auth.password.failed');
63
+ }
64
+ };
65
+ const error = emptyError ? t('auth.password.required') : failure ? failureMessage(failure) : undefined;
66
+
67
+ return (
68
+ <SignInStepLayout>
69
+ <SignInStepHeader
70
+ eyebrow={tenantName ?? t('auth.password.eyebrow')}
71
+ title={t('auth.password.title')}
72
+ body={t('auth.password.body')}
73
+ />
74
+
75
+ <SignInEmailRow email={email} actionLabel={t('auth.change')} onAction={onBack} />
76
+
77
+ <form onSubmit={submit} noValidate className={`flex flex-col ${error ? 'gap-2' : 'gap-6'}`}>
78
+ <SignInPasswordField
79
+ inputRef={inputRef}
80
+ label={t('auth.password.label')}
81
+ value={password}
82
+ onChange={(value) => {
83
+ setPassword(value);
84
+ if (emptyError) setEmptyError(false);
85
+ }}
86
+ invalid={!!error}
87
+ error={error}
88
+ />
89
+
90
+ <SignInStepButton type="submit" disabled={loading}>
91
+ {loading ? (
92
+ <Spinner />
93
+ ) : (
94
+ <>
95
+ <span>{t('auth.password.submit')}</span>
96
+ <ArrowRight className="!size-3.5" />
97
+ </>
98
+ )}
99
+ </SignInStepButton>
100
+ </form>
101
+ </SignInStepLayout>
102
+ );
103
+ }
@@ -366,6 +366,11 @@ export function SignInOrDivider({ children }: { children: ReactNode }) {
366
366
  );
367
367
  }
368
368
 
369
+ const SIGNIN_INPUT_CLASS =
370
+ 'h-[42px] px-3.5 rounded-md border border-border bg-background text-sm text-foreground placeholder:text-muted ' +
371
+ 'outline-none transition focus:border-info focus:ring-4 focus:ring-info/15 ' +
372
+ 'aria-[invalid=true]:border-destructive aria-[invalid=true]:ring-destructive/15';
373
+
369
374
  interface SignInEmailFieldProps {
370
375
  inputRef?: Ref<HTMLInputElement>;
371
376
  label: ReactNode;
@@ -396,7 +401,7 @@ export function SignInEmailField({
396
401
  id="vt-login-email"
397
402
  name="vt-login-email"
398
403
  type="email"
399
- className="h-[42px] px-3.5 rounded-md border border-border bg-background text-sm text-foreground placeholder:text-muted outline-none transition focus:border-info focus:ring-4 focus:ring-info/15 aria-[invalid=true]:border-destructive aria-[invalid=true]:ring-destructive/15"
404
+ className={SIGNIN_INPUT_CLASS}
400
405
  placeholder={placeholder}
401
406
  value={value}
402
407
  onChange={(e) => onChange(e.target.value)}
@@ -417,3 +422,50 @@ export function SignInEmailField({
417
422
  </div>
418
423
  );
419
424
  }
425
+
426
+ interface SignInPasswordFieldProps {
427
+ inputRef?: Ref<HTMLInputElement>;
428
+ label: ReactNode;
429
+ value: string;
430
+ onChange: (value: string) => void;
431
+ invalid?: boolean;
432
+ error?: ReactNode;
433
+ }
434
+
435
+ /**
436
+ * Labeled password input with an inline error. Unlike the email field it leaves password managers
437
+ * enabled: a password account's credential is issued once and is expected to be stored in one.
438
+ */
439
+ export function SignInPasswordField({
440
+ inputRef,
441
+ label,
442
+ value,
443
+ onChange,
444
+ invalid = false,
445
+ error,
446
+ }: SignInPasswordFieldProps) {
447
+ return (
448
+ <div className="flex flex-col gap-1.5">
449
+ <label htmlFor="vt-login-password" className="text-xs font-medium text-foreground/80">
450
+ {label}
451
+ </label>
452
+ <input
453
+ ref={inputRef}
454
+ id="vt-login-password"
455
+ name="vt-login-password"
456
+ type="password"
457
+ className={SIGNIN_INPUT_CLASS}
458
+ value={value}
459
+ onChange={(e) => onChange(e.target.value)}
460
+ aria-invalid={invalid}
461
+ aria-describedby={error ? 'vt-login-password-error' : undefined}
462
+ autoComplete="current-password"
463
+ />
464
+ {error && (
465
+ <div id="vt-login-password-error" role="alert" className="text-xs text-destructive">
466
+ {error}
467
+ </div>
468
+ )}
469
+ </div>
470
+ );
471
+ }
@@ -1,27 +1,31 @@
1
1
  import { useUITranslation } from '@vertesia/ui/i18n';
2
2
  import { SignInEmailRow, SignInProviderButton, SignInStepHeader, SignInStepLayout } from './SignInPrimitives';
3
- import { type ProviderId, providerLabel, startSignIn } from './signInUtils';
3
+ import { type ProviderId, providerLabel, type RedirectProviderId, startSignIn } from './signInUtils';
4
4
 
5
5
  interface SignInProvidersStepProps {
6
6
  email: string;
7
7
  onBack: () => void;
8
8
  onProviderClicked: (provider: ProviderId) => void;
9
+ /** The address resolved to a password tenant on retry, so the parent shows the password step. */
10
+ onPasswordRequired: (email: string) => void;
9
11
  redirectTo?: string;
10
12
  }
11
13
 
12
- const PROVIDERS: ProviderId[] = ['google', 'github', 'microsoft'];
14
+ const PROVIDERS: RedirectProviderId[] = ['google', 'github', 'microsoft'];
13
15
 
14
16
  export default function SignInProvidersStep({
15
17
  email,
16
18
  onBack,
17
19
  onProviderClicked,
20
+ onPasswordRequired,
18
21
  redirectTo,
19
22
  }: SignInProvidersStepProps) {
20
23
  const { t } = useUITranslation();
21
24
 
22
- const pick = async (provider: ProviderId) => {
25
+ const pick = async (provider: RedirectProviderId) => {
23
26
  onProviderClicked(provider);
24
- await startSignIn(provider, email, redirectTo);
27
+ const result = await startSignIn(provider, email, redirectTo);
28
+ if (!result.ok && result.reason === 'password-required') onPasswordRequired(email);
25
29
  };
26
30
 
27
31
  return (