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

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 (183) hide show
  1. package/lib/boot/vite.d.ts +3 -0
  2. package/lib/boot/vite.d.ts.map +1 -1
  3. package/lib/boot/vite.js +12 -0
  4. package/lib/boot/vite.js.map +1 -1
  5. package/lib/core/components/shadcn/tabs.d.ts +2 -1
  6. package/lib/core/components/shadcn/tabs.d.ts.map +1 -1
  7. package/lib/core/components/shadcn/tabs.js +6 -4
  8. package/lib/core/components/shadcn/tabs.js.map +1 -1
  9. package/lib/env/index.d.ts +8 -0
  10. package/lib/env/index.d.ts.map +1 -1
  11. package/lib/env/index.js.map +1 -1
  12. package/lib/features/agent/chat/AgentRequestInputOverlay.d.ts +1 -1
  13. package/lib/features/agent/chat/AgentRequestInputOverlay.d.ts.map +1 -1
  14. package/lib/features/agent/chat/AgentRequestInputOverlay.js +16 -10
  15. package/lib/features/agent/chat/AgentRequestInputOverlay.js.map +1 -1
  16. package/lib/features/agent/chat/AskUserWidget.d.ts +7 -1
  17. package/lib/features/agent/chat/AskUserWidget.d.ts.map +1 -1
  18. package/lib/features/agent/chat/AskUserWidget.js +15 -15
  19. package/lib/features/agent/chat/AskUserWidget.js.map +1 -1
  20. package/lib/features/media-viewer/ImagePanel.d.ts +1 -1
  21. package/lib/features/media-viewer/ImagePanel.d.ts.map +1 -1
  22. package/lib/features/media-viewer/ImagePanel.js +40 -10
  23. package/lib/features/media-viewer/ImagePanel.js.map +1 -1
  24. package/lib/features/store/types/IntakePolicyEditor.d.ts.map +1 -1
  25. package/lib/features/store/types/IntakePolicyEditor.js +1 -1
  26. package/lib/features/store/types/IntakePolicyEditor.js.map +1 -1
  27. package/lib/i18n/locales/ar.json +14 -0
  28. package/lib/i18n/locales/de.json +14 -0
  29. package/lib/i18n/locales/en.json +14 -0
  30. package/lib/i18n/locales/es.json +14 -0
  31. package/lib/i18n/locales/fr.json +14 -0
  32. package/lib/i18n/locales/it.json +14 -0
  33. package/lib/i18n/locales/ja.json +14 -0
  34. package/lib/i18n/locales/ko.json +14 -0
  35. package/lib/i18n/locales/pt.json +14 -0
  36. package/lib/i18n/locales/ru.json +14 -0
  37. package/lib/i18n/locales/tr.json +14 -0
  38. package/lib/i18n/locales/zh-TW.json +14 -0
  39. package/lib/i18n/locales/zh.json +14 -0
  40. package/lib/session/auth/oauth.d.ts.map +1 -1
  41. package/lib/session/auth/oauth.js +5 -3
  42. package/lib/session/auth/oauth.js.map +1 -1
  43. package/lib/shell/AuthScreenPreview.d.ts.map +1 -1
  44. package/lib/shell/AuthScreenPreview.js +1 -0
  45. package/lib/shell/AuthScreenPreview.js.map +1 -1
  46. package/lib/shell/login/SignInFlow.d.ts +3 -1
  47. package/lib/shell/login/SignInFlow.d.ts.map +1 -1
  48. package/lib/shell/login/SignInFlow.js +13 -4
  49. package/lib/shell/login/SignInFlow.js.map +1 -1
  50. package/lib/shell/login/SignInIcons.d.ts.map +1 -1
  51. package/lib/shell/login/SignInIcons.js +2 -1
  52. package/lib/shell/login/SignInIcons.js.map +1 -1
  53. package/lib/shell/login/SignInPasswordStep.d.ts +9 -0
  54. package/lib/shell/login/SignInPasswordStep.d.ts.map +1 -0
  55. package/lib/shell/login/SignInPasswordStep.js +58 -0
  56. package/lib/shell/login/SignInPasswordStep.js.map +1 -0
  57. package/lib/shell/login/SignInPrimitives.d.ts +13 -0
  58. package/lib/shell/login/SignInPrimitives.d.ts.map +1 -1
  59. package/lib/shell/login/SignInPrimitives.js +11 -1
  60. package/lib/shell/login/SignInPrimitives.js.map +1 -1
  61. package/lib/shell/login/SignInProvidersStep.d.ts +3 -1
  62. package/lib/shell/login/SignInProvidersStep.d.ts.map +1 -1
  63. package/lib/shell/login/SignInProvidersStep.js +4 -2
  64. package/lib/shell/login/SignInProvidersStep.js.map +1 -1
  65. package/lib/shell/login/SignInReturningStep.d.ts +3 -1
  66. package/lib/shell/login/SignInReturningStep.d.ts.map +1 -1
  67. package/lib/shell/login/SignInReturningStep.js +18 -8
  68. package/lib/shell/login/SignInReturningStep.js.map +1 -1
  69. package/lib/shell/login/SignInTenantStep.d.ts +3 -1
  70. package/lib/shell/login/SignInTenantStep.d.ts.map +1 -1
  71. package/lib/shell/login/SignInTenantStep.js +2 -2
  72. package/lib/shell/login/SignInTenantStep.js.map +1 -1
  73. package/lib/shell/login/SignInWithProviderButton.d.ts +5 -3
  74. package/lib/shell/login/SignInWithProviderButton.d.ts.map +1 -1
  75. package/lib/shell/login/SignInWithProviderButton.js +8 -7
  76. package/lib/shell/login/SignInWithProviderButton.js.map +1 -1
  77. package/lib/shell/login/SigninScreen.d.ts.map +1 -1
  78. package/lib/shell/login/SigninScreen.js +9 -3
  79. package/lib/shell/login/SigninScreen.js.map +1 -1
  80. package/lib/shell/login/signInUtils.d.ts +24 -4
  81. package/lib/shell/login/signInUtils.d.ts.map +1 -1
  82. package/lib/shell/login/signInUtils.js +53 -1
  83. package/lib/shell/login/signInUtils.js.map +1 -1
  84. package/lib/vertesia-ui-boot.js.map +1 -1
  85. package/lib/vertesia-ui-core.js +42 -42
  86. package/lib/vertesia-ui-core.js.map +1 -1
  87. package/lib/vertesia-ui-env.js.map +1 -1
  88. package/lib/vertesia-ui-features.js +339 -339
  89. package/lib/vertesia-ui-features.js.map +1 -1
  90. package/lib/vertesia-ui-i18n.B0orbJb6.chunk.js +5 -0
  91. package/lib/vertesia-ui-i18n.B0orbJb6.chunk.js.map +1 -0
  92. package/lib/vertesia-ui-i18n.BChwIQVA.chunk.js +5 -0
  93. package/lib/vertesia-ui-i18n.BChwIQVA.chunk.js.map +1 -0
  94. package/lib/vertesia-ui-i18n.BTEcAVQm.chunk.js +5 -0
  95. package/lib/vertesia-ui-i18n.BTEcAVQm.chunk.js.map +1 -0
  96. package/lib/vertesia-ui-i18n.BeJQKbv7.chunk.js +5 -0
  97. package/lib/vertesia-ui-i18n.BeJQKbv7.chunk.js.map +1 -0
  98. package/lib/vertesia-ui-i18n.BpGr0-5g.chunk.js +5 -0
  99. package/lib/vertesia-ui-i18n.BpGr0-5g.chunk.js.map +1 -0
  100. package/lib/vertesia-ui-i18n.Bs8x-1wI.chunk.js +5 -0
  101. package/lib/vertesia-ui-i18n.Bs8x-1wI.chunk.js.map +1 -0
  102. package/lib/vertesia-ui-i18n.BymQoOm0.chunk.js +5 -0
  103. package/lib/vertesia-ui-i18n.BymQoOm0.chunk.js.map +1 -0
  104. package/lib/vertesia-ui-i18n.CNT_wabV.chunk.js +5 -0
  105. package/lib/vertesia-ui-i18n.CNT_wabV.chunk.js.map +1 -0
  106. package/lib/vertesia-ui-i18n.Cxh1iVk0.chunk.js +5 -0
  107. package/lib/vertesia-ui-i18n.Cxh1iVk0.chunk.js.map +1 -0
  108. package/lib/vertesia-ui-i18n.DLYCM9fV.chunk.js +5 -0
  109. package/lib/vertesia-ui-i18n.DLYCM9fV.chunk.js.map +1 -0
  110. package/lib/vertesia-ui-i18n.QvAVguDs.chunk.js +5 -0
  111. package/lib/vertesia-ui-i18n.QvAVguDs.chunk.js.map +1 -0
  112. package/lib/vertesia-ui-i18n.js +2 -2
  113. package/lib/vertesia-ui-i18n.js.map +1 -1
  114. package/lib/vertesia-ui-i18n.smozeSmI.chunk.js +5 -0
  115. package/lib/vertesia-ui-i18n.smozeSmI.chunk.js.map +1 -0
  116. package/lib/vertesia-ui-layout.js +15 -15
  117. package/lib/vertesia-ui-layout.js.map +1 -1
  118. package/lib/vertesia-ui-rich-text.js +40 -40
  119. package/lib/vertesia-ui-rich-text.js.map +1 -1
  120. package/lib/vertesia-ui-router.js.map +1 -1
  121. package/lib/vertesia-ui-session.js +1 -1
  122. package/lib/vertesia-ui-session.js.map +1 -1
  123. package/lib/vertesia-ui-shell.js +54 -47
  124. package/lib/vertesia-ui-shell.js.map +1 -1
  125. package/lib/vertesia-ui-widgets.js +47 -47
  126. package/lib/vertesia-ui-widgets.js.map +1 -1
  127. package/package.json +20 -20
  128. package/src/boot/vite.ts +15 -0
  129. package/src/core/components/shadcn/tabs.tsx +9 -3
  130. package/src/env/index.ts +10 -1
  131. package/src/features/agent/chat/AgentRequestInputOverlay.tsx +73 -34
  132. package/src/features/agent/chat/AskUserWidget.tsx +222 -205
  133. package/src/features/media-viewer/ImagePanel.tsx +51 -10
  134. package/src/features/store/types/IntakePolicyEditor.tsx +2 -1
  135. package/src/i18n/locales/ar.json +14 -0
  136. package/src/i18n/locales/de.json +14 -0
  137. package/src/i18n/locales/en.json +14 -0
  138. package/src/i18n/locales/es.json +14 -0
  139. package/src/i18n/locales/fr.json +14 -0
  140. package/src/i18n/locales/it.json +14 -0
  141. package/src/i18n/locales/ja.json +14 -0
  142. package/src/i18n/locales/ko.json +14 -0
  143. package/src/i18n/locales/pt.json +14 -0
  144. package/src/i18n/locales/ru.json +14 -0
  145. package/src/i18n/locales/tr.json +14 -0
  146. package/src/i18n/locales/zh-TW.json +14 -0
  147. package/src/i18n/locales/zh.json +14 -0
  148. package/src/session/auth/oauth.ts +8 -3
  149. package/src/shell/AuthScreenPreview.tsx +1 -0
  150. package/src/shell/login/SignInFlow.tsx +23 -2
  151. package/src/shell/login/SignInIcons.tsx +2 -1
  152. package/src/shell/login/SignInPasswordStep.tsx +103 -0
  153. package/src/shell/login/SignInPrimitives.tsx +53 -1
  154. package/src/shell/login/SignInProvidersStep.tsx +8 -4
  155. package/src/shell/login/SignInReturningStep.tsx +20 -8
  156. package/src/shell/login/SignInTenantStep.tsx +6 -2
  157. package/src/shell/login/SignInWithProviderButton.tsx +11 -7
  158. package/src/shell/login/SigninScreen.tsx +8 -3
  159. package/src/shell/login/signInUtils.ts +68 -8
  160. package/lib/vertesia-ui-i18n.BCPusS2_.chunk.js +0 -5
  161. package/lib/vertesia-ui-i18n.BCPusS2_.chunk.js.map +0 -1
  162. package/lib/vertesia-ui-i18n.BHWlHchf.chunk.js +0 -5
  163. package/lib/vertesia-ui-i18n.BHWlHchf.chunk.js.map +0 -1
  164. package/lib/vertesia-ui-i18n.BkNx8SZu.chunk.js +0 -5
  165. package/lib/vertesia-ui-i18n.BkNx8SZu.chunk.js.map +0 -1
  166. package/lib/vertesia-ui-i18n.C1LasLrO.chunk.js +0 -5
  167. package/lib/vertesia-ui-i18n.C1LasLrO.chunk.js.map +0 -1
  168. package/lib/vertesia-ui-i18n.D8PQ5yKU.chunk.js +0 -5
  169. package/lib/vertesia-ui-i18n.D8PQ5yKU.chunk.js.map +0 -1
  170. package/lib/vertesia-ui-i18n.DUtVV1Bq.chunk.js +0 -5
  171. package/lib/vertesia-ui-i18n.DUtVV1Bq.chunk.js.map +0 -1
  172. package/lib/vertesia-ui-i18n.DgA36_k5.chunk.js +0 -5
  173. package/lib/vertesia-ui-i18n.DgA36_k5.chunk.js.map +0 -1
  174. package/lib/vertesia-ui-i18n.DmG-fjjZ.chunk.js +0 -5
  175. package/lib/vertesia-ui-i18n.DmG-fjjZ.chunk.js.map +0 -1
  176. package/lib/vertesia-ui-i18n.DreWvQIk.chunk.js +0 -5
  177. package/lib/vertesia-ui-i18n.DreWvQIk.chunk.js.map +0 -1
  178. package/lib/vertesia-ui-i18n.Pp8Tj48d.chunk.js +0 -5
  179. package/lib/vertesia-ui-i18n.Pp8Tj48d.chunk.js.map +0 -1
  180. package/lib/vertesia-ui-i18n.gais--x5.chunk.js +0 -5
  181. package/lib/vertesia-ui-i18n.gais--x5.chunk.js.map +0 -1
  182. package/lib/vertesia-ui-i18n.xkqaKpY_.chunk.js +0 -5
  183. package/lib/vertesia-ui-i18n.xkqaKpY_.chunk.js.map +0 -1
@@ -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 (
@@ -21,6 +21,8 @@ interface SignInReturningStepProps {
21
21
  session: LastSuccessfulLogin;
22
22
  onNotYou: () => void;
23
23
  onProviderClicked: (provider: ProviderId) => void;
24
+ /** The address signs in with a password, so the parent shows the password step instead. */
25
+ onPasswordRequired: (email: string) => void;
24
26
  redirectTo?: string;
25
27
  }
26
28
 
@@ -28,6 +30,7 @@ export default function SignInReturningStep({
28
30
  session,
29
31
  onNotYou,
30
32
  onProviderClicked,
33
+ onPasswordRequired,
31
34
  redirectTo,
32
35
  }: SignInReturningStepProps) {
33
36
  const { t } = useUITranslation();
@@ -36,14 +39,23 @@ export default function SignInReturningStep({
36
39
  const avatar = <SignInInitialsBadge initials={emailInitial(session.email)} />;
37
40
  // A stored tenantName means we resolved the user's organization.
38
41
  const hasTenant = !!session.tenantName;
42
+ const lastProvider = session.lastProvider;
39
43
  const primaryLabel =
40
- session.lastProvider === 'oidc'
41
- ? t('auth.continueWithSignIn')
42
- : t('auth.continueWithProvider', { provider: providerLabel(session.lastProvider) });
44
+ lastProvider === 'password'
45
+ ? t('auth.password.continue')
46
+ : lastProvider === 'oidc'
47
+ ? t('auth.continueWithSignIn')
48
+ : t('auth.continueWithProvider', { provider: providerLabel(lastProvider) });
43
49
 
44
- const continueWith = async (provider: ProviderId) => {
45
- onProviderClicked(provider);
46
- await startSignIn(provider, session.email, redirectTo);
50
+ const continueWithLastProvider = async () => {
51
+ if (lastProvider === 'password') {
52
+ onPasswordRequired(session.email);
53
+ return;
54
+ }
55
+ onProviderClicked(lastProvider);
56
+ // The stored provider can be stale: the address may now resolve to a password tenant.
57
+ const result = await startSignIn(lastProvider, session.email, redirectTo);
58
+ if (!result.ok && result.reason === 'password-required') onPasswordRequired(session.email);
47
59
  };
48
60
 
49
61
  return (
@@ -76,9 +88,9 @@ export default function SignInReturningStep({
76
88
 
77
89
  <div className="flex flex-col gap-2">
78
90
  <SignInProviderButton
79
- provider={session.lastProvider}
91
+ provider={lastProvider}
80
92
  label={primaryLabel}
81
- onClick={() => continueWith(session.lastProvider)}
93
+ onClick={continueWithLastProvider}
82
94
  variant="filled"
83
95
  />
84
96
  <SignInStepButton variant="ghost" onClick={onNotYou}>
@@ -8,13 +8,15 @@ import {
8
8
  SignInStepLayout,
9
9
  } from './SignInPrimitives';
10
10
  import SignInWithProviderButton from './SignInWithProviderButton';
11
- import { type ProviderId, providerLabel } from './signInUtils';
11
+ import { providerLabel, type RedirectProviderId } from './signInUtils';
12
12
 
13
13
  interface SignInTenantStepProps {
14
14
  email: string;
15
15
  tenant: TenantInfo;
16
16
  onBack: () => void;
17
17
  onProviderClicked: () => void;
18
+ /** The tenant re-resolved as a password tenant, so the parent shows the password step. */
19
+ onPasswordRequired: (email: string) => void;
18
20
  redirectTo?: string;
19
21
  }
20
22
 
@@ -32,12 +34,13 @@ export default function SignInTenantStep({
32
34
  tenant,
33
35
  onBack,
34
36
  onProviderClicked,
37
+ onPasswordRequired,
35
38
  redirectTo,
36
39
  }: SignInTenantStepProps) {
37
40
  const { t } = useUITranslation();
38
41
  const tenantName = tenant.label || tenant.name || t('auth.blocked.tenantFallback');
39
42
  // Brands keep their identity; anything else routes through OIDC.
40
- const provider: ProviderId =
43
+ const provider: RedirectProviderId =
41
44
  tenant.provider === 'google' || tenant.provider === 'github' || tenant.provider === 'microsoft'
42
45
  ? tenant.provider
43
46
  : 'oidc';
@@ -68,6 +71,7 @@ export default function SignInTenantStep({
68
71
  redirectTo={redirectTo}
69
72
  variant="filled"
70
73
  onClick={onProviderClicked}
74
+ onPasswordRequired={() => onPasswordRequired(email)}
71
75
  />
72
76
  <SignInStepButton variant="ghost" onClick={onBack}>
73
77
  {t('auth.tenant.notPartOf', { name: tenantName })}
@@ -1,15 +1,17 @@
1
1
  import { useUITranslation } from '@vertesia/ui/i18n';
2
2
  import { SignInProviderButton } from './SignInPrimitives';
3
- import { type ProviderId, providerLabel, startSignIn, startSignInWithoutTenant } from './signInUtils';
3
+ import { providerLabel, type RedirectProviderId, startSignIn, startSignInWithoutTenant } from './signInUtils';
4
4
 
5
5
  interface SignInWithProviderButtonProps {
6
- provider: ProviderId;
6
+ provider: RedirectProviderId;
7
7
  /** Email for the tenant-aware flow. Omit for a no-tenant sign-in (e.g. SignInModal). */
8
8
  email?: string;
9
9
  redirectTo?: string;
10
10
  variant?: 'outline' | 'filled';
11
11
  /** Fired on click, before the redirect. */
12
12
  onClick?: () => void;
13
+ /** Fired instead of a redirect when the email resolves to a password tenant. */
14
+ onPasswordRequired?: () => void;
13
15
  }
14
16
 
15
17
  // "Continue with <provider>" button that owns its sign-in redirect.
@@ -19,6 +21,7 @@ export default function SignInWithProviderButton({
19
21
  redirectTo,
20
22
  variant = 'outline',
21
23
  onClick,
24
+ onPasswordRequired,
22
25
  }: SignInWithProviderButtonProps) {
23
26
  const { t } = useUITranslation();
24
27
  const label =
@@ -26,15 +29,16 @@ export default function SignInWithProviderButton({
26
29
  ? t('auth.continueWithSignIn')
27
30
  : t('auth.continueWithProvider', { provider: providerLabel(provider) });
28
31
 
29
- const signIn = () => {
32
+ const signIn = async () => {
30
33
  onClick?.();
31
- if (email) {
32
- void startSignIn(provider, email, redirectTo);
33
- } else {
34
+ if (!email) {
34
35
  // OIDC needs a resolved tenant; the no-email path is only hit by no-tenant buttons.
35
36
  startSignInWithoutTenant(provider, redirectTo);
37
+ return;
36
38
  }
39
+ const result = await startSignIn(provider, email, redirectTo);
40
+ if (!result.ok && result.reason === 'password-required') onPasswordRequired?.();
37
41
  };
38
42
 
39
- return <SignInProviderButton provider={provider} label={label} onClick={signIn} variant={variant} />;
43
+ return <SignInProviderButton provider={provider} label={label} onClick={() => void signIn()} variant={variant} />;
40
44
  }
@@ -18,7 +18,7 @@ import SignInRecoveryStep, { type SignInRecoveryKind } from './SignInRecoverySte
18
18
  import SignInRestrictedEnvStep from './SignInRestrictedEnvStep';
19
19
  import SignInTenantBlockedStep from './SignInTenantBlockedStep';
20
20
  import SignupForm from './SignupForm';
21
- import { isInviteRequiredError, readPendingSignin, resetSignInState } from './signInUtils';
21
+ import { isInviteRequiredError, isTenantSignIn, readPendingSignin, resetSignInState } from './signInUtils';
22
22
 
23
23
  /** Presentation only: the shared screen still owns session gating and recovery transitions. */
24
24
  export interface SignInScreenViewProps {
@@ -152,7 +152,12 @@ function SigninScreenImpl({
152
152
  useEffect(() => {
153
153
  if (!authError) return;
154
154
  if (authError instanceof UserNotFoundError) {
155
- setMode('signup');
155
+ if (isTenantSignIn()) {
156
+ setEmail(authError.email);
157
+ setMode('blocked');
158
+ } else {
159
+ setMode('signup');
160
+ }
156
161
  } else if (authError instanceof RestrictedEnvironmentError) {
157
162
  setMode('restricted');
158
163
  } else if (authError instanceof RequestedScopeUnavailableError) {
@@ -280,7 +285,7 @@ function SigninScreenImpl({
280
285
  onUseDifferentAccount={useDifferentAccount}
281
286
  />
282
287
  );
283
- } else if (mode === 'signup' && !localStorage.getItem('tenantName')) {
288
+ } else if (mode === 'signup' && !isTenantSignIn()) {
284
289
  content = <SignupForm onSignup={onSignup} goBack={startOver} />;
285
290
  } else {
286
291
  // Every remaining mode is a core one; an unmatched recovery mode falls through to the
@@ -1,17 +1,22 @@
1
1
  import { Env } from '@vertesia/ui/env';
2
2
  import { getFirebaseAuth, setFirebaseTenant } from '@vertesia/ui/session';
3
+ import { FirebaseError } from 'firebase/app';
3
4
  import {
4
5
  type AuthProvider,
5
6
  signOut as firebaseSignOut,
6
7
  GithubAuthProvider,
7
8
  GoogleAuthProvider,
8
9
  OAuthProvider,
10
+ signInWithEmailAndPassword,
9
11
  signInWithRedirect,
10
12
  } from 'firebase/auth';
11
13
 
12
14
  // Matches auth-tenants.json `provider` values. Tenant context is derived from
13
15
  // email resolution at sign-in time, not encoded here.
14
- export type ProviderId = 'google' | 'github' | 'microsoft' | 'oidc';
16
+ export type ProviderId = 'google' | 'github' | 'microsoft' | 'oidc' | 'password';
17
+
18
+ /** Providers that sign in by redirecting to an identity provider. `password` signs in in place. */
19
+ export type RedirectProviderId = Exclude<ProviderId, 'password'>;
15
20
 
16
21
  export interface LastSuccessfulLogin {
17
22
  email: string;
@@ -82,6 +87,13 @@ export function clearPendingSignin(): void {
82
87
  }
83
88
  }
84
89
 
90
+ /**
91
+ * Detects a tenant sign-in by the key's presence, including tenants with an empty label or name.
92
+ */
93
+ export function isTenantSignIn(): boolean {
94
+ return localStorage.getItem('tenantName') !== null;
95
+ }
96
+
85
97
  /**
86
98
  * Promotes the pending sign-in record to the last-successful-login entry, stamping the
87
99
  * authenticated user's display name.
@@ -166,28 +178,28 @@ const buildOidcProvider: ProviderBuilder = (email) => {
166
178
  return p;
167
179
  };
168
180
 
169
- const PROVIDER_BUILDERS: Record<ProviderId, ProviderBuilder> = {
181
+ const PROVIDER_BUILDERS: Record<RedirectProviderId, ProviderBuilder> = {
170
182
  google: buildGoogleProvider,
171
183
  github: buildGithubProvider,
172
184
  microsoft: buildMicrosoftProvider,
173
185
  oidc: buildOidcProvider,
174
186
  };
175
187
 
176
- function buildFirebaseProvider(idp: ProviderId, email?: string, redirectTo?: string): AuthProvider {
188
+ function buildFirebaseProvider(idp: RedirectProviderId, email?: string, redirectTo?: string): AuthProvider {
177
189
  return PROVIDER_BUILDERS[idp](email, redirectTo);
178
190
  }
179
191
 
180
192
  export async function startSignIn(
181
- provider: ProviderId,
193
+ provider: RedirectProviderId,
182
194
  email: string,
183
195
  redirectTo?: string,
184
- ): Promise<{ ok: true } | { ok: false; reason: 'no-email' }> {
196
+ ): Promise<{ ok: true } | { ok: false; reason: 'no-email' | 'password-required' }> {
185
197
  if (!email) return { ok: false, reason: 'no-email' };
186
198
 
187
199
  // A tenant-mapped email uses the tenant's provider (overriding `provider`); otherwise use `provider`.
188
200
  const tenant = await setFirebaseTenant(email);
189
201
  const auth = getFirebaseAuth();
190
- let effectiveIdp = provider;
202
+ let effectiveIdp: ProviderId = provider;
191
203
  let tenantName: string | undefined;
192
204
 
193
205
  if (tenant) {
@@ -201,20 +213,68 @@ export async function startSignIn(
201
213
  auth.tenantId = Env.firebase?.tenantId ?? null;
202
214
  }
203
215
 
216
+ // A password tenant has no identity provider to redirect to; its step collects the password.
217
+ if (effectiveIdp === 'password') return { ok: false, reason: 'password-required' };
218
+
204
219
  writePendingSignin({ email, provider: effectiveIdp, tenantName });
205
220
  void signInWithRedirect(auth, buildFirebaseProvider(effectiveIdp, email, redirectTo));
206
221
  return { ok: true };
207
222
  }
208
223
 
224
+ export type PasswordSignInFailure = 'not-password-tenant' | 'invalid-credentials' | 'too-many-attempts' | 'failed';
225
+
226
+ // Firebase reports an unknown user, a wrong password and a disabled user with distinct codes on
227
+ // some SDK versions; they are folded into one answer so the form does not reveal which it was.
228
+ const INVALID_CREDENTIAL_CODES = new Set([
229
+ 'auth/invalid-credential',
230
+ 'auth/invalid-email',
231
+ 'auth/user-disabled',
232
+ 'auth/user-not-found',
233
+ 'auth/wrong-password',
234
+ ]);
235
+
236
+ export function passwordFailureReason(err: unknown): Exclude<PasswordSignInFailure, 'not-password-tenant'> {
237
+ if (!(err instanceof FirebaseError)) return 'failed';
238
+ if (INVALID_CREDENTIAL_CODES.has(err.code)) return 'invalid-credentials';
239
+ if (err.code === 'auth/too-many-requests') return 'too-many-attempts';
240
+ return 'failed';
241
+ }
242
+
243
+ /**
244
+ * Signs in a user of a password tenant, in place — there is no redirect. Only an address whose
245
+ * domain resolves to a tenant configured for password sign-in may use it; the tenant is resolved
246
+ * again here rather than trusted from the caller, so a returning session cannot carry a stale one.
247
+ * On success the session's auth-state listener completes the login.
248
+ */
249
+ export async function signInWithPassword(
250
+ email: string,
251
+ password: string,
252
+ ): Promise<{ ok: true } | { ok: false; reason: PasswordSignInFailure }> {
253
+ const tenant = await setFirebaseTenant(email);
254
+ if (!tenant) return { ok: false, reason: 'failed' };
255
+ if (tenant.provider !== 'password') return { ok: false, reason: 'not-password-tenant' };
256
+
257
+ const tenantName = tenant.label || tenant.name || undefined;
258
+ localStorage.setItem('tenantName', tenantName ?? '');
259
+ writePendingSignin({ email, provider: 'password', tenantName });
260
+ try {
261
+ await signInWithEmailAndPassword(getFirebaseAuth(), email, password);
262
+ return { ok: true };
263
+ } catch (err: unknown) {
264
+ clearPendingSignin();
265
+ return { ok: false, reason: passwordFailureReason(err) };
266
+ }
267
+ }
268
+
209
269
  /** Starts sign-in without email discovery, preserving an explicitly configured tenant. */
210
- export function startSignInWithoutTenant(provider: ProviderId, redirectTo?: string): void {
270
+ export function startSignInWithoutTenant(provider: RedirectProviderId, redirectTo?: string): void {
211
271
  const auth = getFirebaseAuth();
212
272
  localStorage.removeItem('tenantName');
213
273
  auth.tenantId = Env.firebase?.tenantId ?? null;
214
274
  void signInWithRedirect(
215
275
  auth,
216
276
  buildFirebaseProvider(
217
- Env.firebase?.tenantId ? ((Env.firebase.providerType as ProviderId) ?? 'oidc') : provider,
277
+ Env.firebase?.tenantId ? ((Env.firebase.providerType as RedirectProviderId) ?? 'oidc') : provider,
218
278
  undefined,
219
279
  redirectTo,
220
280
  ),