@flamingo-stack/openframe-frontend-core 0.0.749 → 0.0.751

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 (68) hide show
  1. package/dist/{chunk-45YRDD7W.cjs → chunk-4WNBVNGP.cjs} +13 -13
  2. package/dist/{chunk-SOPVUD2Y.cjs → chunk-53I4YHRT.cjs} +3 -3
  3. package/dist/{chunk-HOEVD6EN.cjs → chunk-5P7WBA2U.cjs} +4 -4
  4. package/dist/{chunk-KRAQEZPB.cjs → chunk-5WC535NJ.cjs} +17 -17
  5. package/dist/{chunk-SGL47BS2.cjs → chunk-77IKUBSH.cjs} +6 -6
  6. package/dist/{chunk-E3KFEOFA.cjs → chunk-CVBHB4OZ.cjs} +4 -4
  7. package/dist/{chunk-CXV7VBSI.cjs → chunk-ESRA7NUI.cjs} +41 -41
  8. package/dist/{chunk-2J2OBP4N.cjs → chunk-H52GKJ36.cjs} +9 -9
  9. package/dist/{chunk-G7SBHMRR.cjs → chunk-KHKPLOU4.cjs} +69 -39
  10. package/dist/{chunk-SECT4H34.cjs → chunk-LIGDPFDM.cjs} +227 -172
  11. package/dist/{chunk-LQT2ITMG.cjs → chunk-MJFTWHV6.cjs} +4 -4
  12. package/dist/{chunk-ZLF55RDR.cjs → chunk-MU7FFN6M.cjs} +34 -34
  13. package/dist/{chunk-OZLIKBKC.cjs → chunk-NOUROZYH.cjs} +13 -13
  14. package/dist/{chunk-GOFEOCQN.cjs → chunk-QOONZMSQ.cjs} +9 -9
  15. package/dist/{chunk-VEVIMDNK.cjs → chunk-RVLQHSID.cjs} +70 -70
  16. package/dist/{chunk-BN6NA5RM.cjs → chunk-SDOCZTST.cjs} +32 -32
  17. package/dist/{chunk-V3UJ6NDR.cjs → chunk-TPH4SRR2.cjs} +6 -6
  18. package/dist/{chunk-4FWURR3W.cjs → chunk-VUWPLQBY.cjs} +2 -2
  19. package/dist/{chunk-WZX26ODJ.cjs → chunk-X4LAMJHD.cjs} +54 -35
  20. package/dist/{cloud-tenants-2MYFHEYY.cjs → cloud-tenants-JDPC2DYF.cjs} +6 -6
  21. package/dist/components/case-studies/index.cjs +16 -16
  22. package/dist/components/chat/hooks/use-jetstream-dialog-subscription.d.ts +7 -5
  23. package/dist/components/chat/hooks/use-jetstream-dialog-subscription.d.ts.map +1 -1
  24. package/dist/components/chat/hooks/use-jetstream-dialog-subscription.js +33 -14
  25. package/dist/components/chat/index.cjs +4 -4
  26. package/dist/components/chat/types/api.types.d.ts +27 -8
  27. package/dist/components/chat/types/api.types.d.ts.map +1 -1
  28. package/dist/components/chat/types/network.types.d.ts +2 -1
  29. package/dist/components/chat/types/network.types.d.ts.map +1 -1
  30. package/dist/components/contact/index.cjs +7 -7
  31. package/dist/components/docs/index.cjs +10 -10
  32. package/dist/components/embeds/index.cjs +7 -7
  33. package/dist/components/faq/index.cjs +8 -8
  34. package/dist/components/features/auth/login-form.d.ts +34 -21
  35. package/dist/components/features/auth/login-form.d.ts.map +1 -1
  36. package/dist/components/features/auth/login-form.js +77 -39
  37. package/dist/components/features/auth/sso-providers.d.ts +7 -1
  38. package/dist/components/features/auth/sso-providers.d.ts.map +1 -1
  39. package/dist/components/features/auth/sso-providers.js +22 -4
  40. package/dist/components/features/index.cjs +6 -6
  41. package/dist/components/help-center-pages/index.cjs +67 -67
  42. package/dist/components/index.cjs +39 -39
  43. package/dist/components/meeting-scheduler/index.cjs +29 -29
  44. package/dist/components/navigation/index.cjs +9 -9
  45. package/dist/components/onboarding-guides/index.cjs +8 -8
  46. package/dist/components/product-demo/index.cjs +7 -7
  47. package/dist/components/tickets/index.cjs +11 -11
  48. package/dist/components/ui/index.cjs +6 -6
  49. package/dist/{devices-FVXAGKVX.cjs → devices-ADQWD37J.cjs} +7 -7
  50. package/dist/{embeddable-chat-SNYQSMS7.cjs → embeddable-chat-7YF2JMPS.cjs} +65 -65
  51. package/dist/hooks/index.cjs +3 -3
  52. package/dist/index.cjs +10 -10
  53. package/dist/{knowledge-ZL3ZHBSL.cjs → knowledge-TNS64EGK.cjs} +7 -7
  54. package/dist/{logs-KQCWDIOB.cjs → logs-754MDDE4.cjs} +14 -14
  55. package/dist/nats/index.cjs +2 -2
  56. package/dist/nats/nats.d.ts +33 -7
  57. package/dist/nats/nats.d.ts.map +1 -1
  58. package/dist/nats/nats.js +41 -11
  59. package/dist/{policies-ARDCZ3CT.cjs → policies-Q23DPIH6.cjs} +10 -10
  60. package/dist/{scripts-ZAMVWCY4.cjs → scripts-DCYLK2RZ.cjs} +11 -11
  61. package/dist/{software-update-6F7BIYS3.cjs → software-update-XCMGUIER.cjs} +9 -9
  62. package/package.json +1 -1
  63. package/src/components/chat/hooks/use-jetstream-dialog-subscription.ts +59 -23
  64. package/src/components/chat/types/api.types.ts +27 -8
  65. package/src/components/chat/types/network.types.ts +2 -1
  66. package/src/components/features/auth/login-form.tsx +121 -64
  67. package/src/components/features/auth/sso-providers.tsx +21 -0
  68. package/src/nats/nats.ts +91 -20
@@ -49,14 +49,16 @@ const RECOVERY_REPORT_FLOOR_MS = 5_000;
49
49
  * - Subject: `chat.{dialogId}.{topic}`
50
50
  * - When `optStartSeq` is a number, the consumer resumes at `optStartSeq + 1`
51
51
  * (`DeliverPolicy.ByStartSequence`). When null/undefined, it live-tails
52
- * (`DeliverPolicy.New`).
52
+ * (`DeliverPolicy.New`) from whenever the server creates the consumer.
53
53
  * - On reconnect, the consumer is recreated starting from the highest stream
54
54
  * sequence we've already observed + 1, so no chunk is replayed or skipped.
55
- * - Consumer is ephemeral with `AckPolicy.None` and the client's default
56
- * inactivity threshold (overridable via `inactiveThresholdMs`).
55
+ * - Consumer is ephemeral with `AckPolicy.None` and nats.ws's default
56
+ * inactivity threshold (`inactiveThresholdMs` only reaches a live-tail
57
+ * consumer — see the option).
57
58
  * - `reconnectionCount` also counts consumer recreations and post-absence
58
- * resyncs, not just NATS reconnects — see the counter's docs. Callers refetch
59
- * persisted history whenever it moves.
59
+ * resyncs, not just NATS reconnects — see the counter's docs.
60
+ * - `caughtUpCount` is when callers refetch persisted history after a gap —
61
+ * not `reconnectionCount` (see its docs).
60
62
  */
61
63
  export function useJetStreamDialogSubscription({
62
64
  enabled,
@@ -84,6 +86,11 @@ export function useJetStreamDialogSubscription({
84
86
  // churn — and churn that can feed itself, since each new consumer can report
85
87
  // its own recoveries. Callers see the two summed (see the return).
86
88
  const [recoveryCount, setRecoveryCount] = useState(0);
89
+ const [caughtUpCount, setCaughtUpCount] = useState(0);
90
+ // The dialog this hook last opened a tail for, and the reconnection count it
91
+ // opened it at: a consumer created for the same dialog at a higher count is
92
+ // one coming back after a gap, which owes the caller a history refetch.
93
+ const tailOpenedAtRef = useRef<{ dialogId: string; count: number } | null>(null);
87
94
  // The highest sequence seen, TAGGED with the dialog it belongs to. The tag is
88
95
  // what makes the per-dialog reset implicit — a sequence from another
89
96
  // conversation simply stops matching — so the dialog-change effect below no
@@ -381,11 +388,28 @@ export function useJetStreamDialogSubscription({
381
388
  const initialOptStart = optStartSeqRef.current;
382
389
  const startSeq = resumeSeq != null ? resumeSeq + 1 : initialOptStart != null ? initialOptStart + 1 : undefined;
383
390
 
391
+ // A first consumer reports nothing: the caller has just loaded the history
392
+ // it resumes after. A reconnect or resync recreates it at a higher count,
393
+ // and from then on the caller's history may predate a gap.
394
+ const opened = tailOpenedAtRef.current;
395
+ let owesCatchUp = opened !== null && opened.dialogId === dialogId && reconnectionCount > opened.count;
396
+ if (!owesCatchUp) tailOpenedAtRef.current = { dialogId, count: reconnectionCount };
397
+
384
398
  let cancelled = false;
399
+ // Settles an owed catch-up once the replay is in, or once there can be none
400
+ // (a failed resume, below) — never as the tail comes back: a refetch that
401
+ // lands first merges without the turns the replay is still delivering, and
402
+ // they then render twice.
403
+ const settleCatchUp = () => {
404
+ if (cancelled || !owesCatchUp) return;
405
+ owesCatchUp = false;
406
+ setCaughtUpCount(c => c + 1);
407
+ };
385
408
 
386
409
  void (async () => {
410
+ let handle: JetStreamSubscriptionHandle;
387
411
  try {
388
- const handle = await client.subscribeJetStreamOrdered(
412
+ handle = await client.subscribeJetStreamOrdered(
389
413
  msg => {
390
414
  if (cancelled) return;
391
415
  const streamSeq = msg.info.streamSequence;
@@ -424,27 +448,39 @@ export function useJetStreamDialogSubscription({
424
448
  const now = Date.now();
425
449
  if (now - lastRecoveryReportRef.current < RECOVERY_REPORT_FLOOR_MS) return;
426
450
  lastRecoveryReportRef.current = now;
451
+ owesCatchUp = true;
427
452
  setRecoveryCount(c => c + 1);
428
453
  },
454
+ onCaughtUp: settleCatchUp,
429
455
  },
430
456
  );
457
+ } catch {
458
+ if (cancelled) return;
459
+ setIsSubscribed(false);
460
+ // With no consumer the refetch cannot wait for a replay. The next resume
461
+ // still owes one (`tailOpenedAtRef` did not move), and its own catch-up
462
+ // refetch clears what its replay re-renders meanwhile.
463
+ settleCatchUp();
464
+ return;
465
+ }
431
466
 
432
- if (cancelled) {
433
- try {
434
- handle.unsubscribe();
435
- } catch {
436
- // ignore
437
- }
438
- return;
467
+ if (cancelled) {
468
+ try {
469
+ handle.unsubscribe();
470
+ } catch {
471
+ // ignore
439
472
  }
473
+ return;
474
+ }
440
475
 
441
- subscriptionRef.current = handle;
442
- setIsSubscribed(true);
476
+ subscriptionRef.current = handle;
477
+ setIsSubscribed(true);
478
+ // A host callback, outside the subscribe's catch: a throw there must not
479
+ // read as a failed subscribe and settle the catch-up before the replay.
480
+ try {
443
481
  onSubscribedRef.current?.();
444
- } catch {
445
- if (!cancelled) {
446
- setIsSubscribed(false);
447
- }
482
+ } catch (e) {
483
+ console.warn('[JetStream] onSubscribed threw:', e);
448
484
  }
449
485
  })();
450
486
 
@@ -464,14 +500,14 @@ export function useJetStreamDialogSubscription({
464
500
  }, [enabled, dialogId, isConnected, streamName, topic, reconnectionCount]);
465
501
 
466
502
  // One counter for every way the tail comes back — reconnect, consumer
467
- // recreation, post-absence resync — because callers do the same thing for all
468
- // of them: refetch persisted history, since the gap may predate what the
469
- // stream still holds. The last two are the quiet ones; no connection event
470
- // fires for either.
503
+ // recreation, post-absence resync. The last two are the quiet ones; no
504
+ // connection event fires for either. `caughtUpCount` counts the same events,
505
+ // each once its replay is in or once it could not be resumed (see its docs).
471
506
  return {
472
507
  isConnected,
473
508
  isSubscribed,
474
509
  reconnectionCount: reconnectionCount + recoveryCount,
510
+ caughtUpCount,
475
511
  currentStreamSeq,
476
512
  };
477
513
  }
@@ -103,8 +103,9 @@ export interface UseJetStreamDialogSubscriptionOptions {
103
103
  /** Single topic to subscribe to. */
104
104
  topic: NatsMessageType;
105
105
  /**
106
- * Resume from this JetStream sequence + 1. When null/undefined, the consumer starts with
107
- * DeliverPolicy.New (live tail only).
106
+ * Resume from this JetStream sequence + 1 (0 replays everything the stream retains for the
107
+ * dialog). When null/undefined, the consumer starts with DeliverPolicy.New: live tail only,
108
+ * from whenever the server creates the consumer — after `onSubscribed` has fired.
108
109
  */
109
110
  optStartSeq?: number | null;
110
111
  onEvent?: (payload: unknown, messageType: NatsMessageType) => void;
@@ -127,8 +128,10 @@ export interface UseJetStreamDialogSubscriptionOptions {
127
128
  maxDelayMs?: number;
128
129
  multiplier?: number;
129
130
  };
130
- /** Consumer inactivity threshold in ms before NATS auto-cleans it. Defaults to the
131
- * client's, and applies only to the first consumer — recreations use nats.ws's. */
131
+ /** Consumer inactivity threshold in ms before NATS auto-cleans it, passed to the client
132
+ * (see `JetStreamOrderedSubscribeOptions.inactiveThresholdMs`). It takes effect only
133
+ * while the hook live-tails (`optStartSeq` null, nothing seen yet): every other consumer
134
+ * it opens starts from a sequence ≥ 1, which nats.ws gives its own default. */
132
135
  inactiveThresholdMs?: number;
133
136
  /**
134
137
  * A counter the host bumps when it knows, from outside the page, that the tail
@@ -140,7 +143,8 @@ export interface UseJetStreamDialogSubscriptionOptions {
140
143
  * either kind of knowledge bumps this instead of hoping the page noticed.
141
144
  *
142
145
  * Treated exactly like a reconnect: the consumer is rebuilt from the highest
143
- * sequence already seen, and `reconnectionCount` moves so callers refetch
146
+ * sequence already seen, `reconnectionCount` moves, and `caughtUpCount` moves
147
+ * once the rebuilt consumer has replayed what it missed, so callers refetch
144
148
  * persisted history. Any increase counts; the value itself means nothing, and
145
149
  * an increase arriving alongside a `visibilitychange` costs one rebuild, not
146
150
  * two.
@@ -156,11 +160,26 @@ export interface UseJetStreamDialogSubscriptionReturn {
156
160
  * JetStream ordered-consumer recreation, the page returning to view after
157
161
  * being hidden long enough to have missed something, or a host reporting the
158
162
  * same via `resyncSignal`. All of them mean the same thing to the caller —
159
- * the tail may have skipped messages, so persisted history has to be
160
- * refetched. One absence counts once even when two sources report it.
161
- * Starts at 0.
163
+ * the tail may have skipped messages. One absence counts once even when two
164
+ * sources report it. Starts at 0.
165
+ *
166
+ * Not the moment to refetch persisted history: the replay is still arriving
167
+ * when this moves. See `caughtUpCount`.
162
168
  */
163
169
  reconnectionCount: number;
170
+ /**
171
+ * Incremented once a tail that came back — any of the events
172
+ * `reconnectionCount` counts, recoveries past its report floor — has
173
+ * delivered everything the stream still held for the dialog. Refetch
174
+ * persisted history on this: the gap may predate what the stream retains,
175
+ * and only a refetch that lands after the replay merges against the turns it
176
+ * re-delivered. One that lands first merges without them, and they then
177
+ * render a second time. Also moves when such a tail could not be resumed at
178
+ * all, so history is not left at its pre-gap snapshot: the next resume then
179
+ * replays the gap over that refetch, and its own increment's refetch clears
180
+ * the copies. Never moves for the first consumer of a dialog. Starts at 0.
181
+ */
182
+ caughtUpCount: number;
164
183
  /** Highest JetStream stream sequence observed so far (null before first delivery). */
165
184
  currentStreamSeq: number | null;
166
185
  }
@@ -36,7 +36,8 @@ export interface ChunkData {
36
36
  sequenceId?: number;
37
37
  /** JetStream stream sequence, populated from JsMsg.info.streamSequence when delivered via JetStream. */
38
38
  streamSeq?: number;
39
- /** Authoritative dialog streaming state at the time this chunk was emitted; consumers should mirror it into their dialog cache. */
39
+ /** @deprecated Not set by the ai-agent: its chunks carry no stream state, which exists only
40
+ * as the GraphQL `Dialog.streamState` field (resolved from the dialog lock). Read that instead. */
40
41
  streamState?: 'IDLE' | 'STREAMING';
41
42
  type: string;
42
43
  text?: string;
@@ -12,6 +12,25 @@ import { LegalLinks } from './legal-links';
12
12
  import type { AuthSsoProvider } from './sso-providers';
13
13
  import { SsoProviderButtons } from './sso-providers';
14
14
 
15
+ /**
16
+ * How a block that appears in answer to the email eases in: the prototype's fade from 4px above, short
17
+ * enough to read as the same card changing rather than a new one arriving.
18
+ */
19
+ const ENTER_ANIMATION = 'animate-in fade-in-0 slide-in-from-top-1 duration-200 ease-out motion-reduce:animate-none';
20
+
21
+ /** The sign-in methods of the organization an email resolved to. */
22
+ export interface LoginFormTenantMethods {
23
+ /** Names the block ("Sign-in methods for <name>") and tags the organization's own providers. */
24
+ name?: string;
25
+ /** Its built-in OpenFrame login, offered first. */
26
+ openframe: boolean;
27
+ /**
28
+ * Identity providers it configured with its own credentials, tagged with `name` so they cannot be
29
+ * mistaken for the generic button of the same provider.
30
+ */
31
+ providers: Exclude<AuthSsoProvider, 'openframe'>[];
32
+ }
33
+
15
34
  export interface LoginFormProps {
16
35
  /** Controlled email value */
17
36
  email: string;
@@ -24,37 +43,39 @@ export interface LoginFormProps {
24
43
  };
25
44
  /** Informational status under the email field (e.g. live tenant discovery). `errors.email` wins. */
26
45
  emailStatus?: { message: string; variant: 'error' | 'warning' | 'success' | 'muted' };
46
+ /** A message too long for the field's one-line status, shown in a notice row under the field. */
47
+ notice?: string;
27
48
  /**
28
- * SSO providers, rendered ABOVE the email field with a divider between the two. Always visible;
29
- * gate clickability with `ssoDisabled` / `ssoEnabledProviders`.
49
+ * The generic providers, under the email field behind `dividerLabel`. They need nothing typed: the
50
+ * server resolves the organization from the identity the provider asserts. With `tenantMethods`
51
+ * they follow the organization's own methods; pass `[]` to offer only those.
30
52
  */
31
53
  ssoProviders: AuthSsoProvider[];
32
54
  onSsoClick?: (provider: AuthSsoProvider) => void;
33
- /** Disables every provider button (e.g. until the email passes discovery). */
55
+ /** Disables every provider button (e.g. while the typed email is being looked up). */
34
56
  ssoDisabled?: boolean;
35
- /** When set, only these providers are clickable; the rest stay disabled. */
57
+ /** When set, only these generic providers are clickable; the rest stay disabled. */
36
58
  ssoEnabledProviders?: AuthSsoProvider[];
37
59
  /** Verb prefix for provider buttons, e.g. "Continue with". */
38
60
  ssoActionLabel?: string;
39
- /** Label on the divider between the provider buttons and the email field. */
61
+ /** Label on the divider above the generic providers. */
40
62
  dividerLabel?: string;
41
63
  /**
42
- * The discovered tenant's own SSO options, rendered UNDER the email field — unlike `ssoProviders`
43
- * above, these depend on knowing the tenant, so they cannot be offered before an address is
44
- * typed. Shares `onSsoClick`.
45
- *
46
- * Three states, and the empty array is not the same as nothing: `undefined` means discovery has
47
- * not resolved a tenant yet and the slot stays empty, while `[]` means it resolved and the tenant
48
- * has none — which is an answer worth showing rather than silence.
64
+ * The organization the typed email resolved to. Its methods render above the generic block, under
65
+ * a heading that names it, so the switch from "any account" to "this organization's sign-in" is
66
+ * visible rather than a silent change of what the same buttons do.
49
67
  */
50
- customSsoProviders?: AuthSsoProvider[];
51
- /** Shown in place of the custom SSO buttons when the resolved tenant has none configured. */
52
- noCustomSsoLabel?: string;
68
+ tenantMethods?: LoginFormTenantMethods;
53
69
  /** Primary submit under the email field. Omit the handler to hide the button. */
54
70
  onSubmitClick?: () => void;
55
71
  submitLabel?: string;
56
72
  /** Disables just the primary submit (fields stay editable). */
57
73
  submitDisabled?: boolean;
74
+ /**
75
+ * Enter in the email field when there is no submit button, e.g. to look the address up now
76
+ * instead of after a typing pause. With `onSubmitClick` set, Enter drives that instead.
77
+ */
78
+ onEmailEnter?: () => void;
58
79
  /** Overrides the default heading. */
59
80
  title?: string;
60
81
  /** Overrides the default sub-heading. */
@@ -70,11 +91,10 @@ export interface LoginFormProps {
70
91
  }
71
92
 
72
93
  /**
73
- * Login form (Login tab). Presentational + controlled — the consumer owns
74
- * state, validation and discovery. Single-screen design: the provider buttons come first and need
75
- * nothing typed, then the email field below them. Whether the providers are gated is the
76
- * consumer's call via `ssoDisabled`; the identity-first flows leave them open, because the server
77
- * resolves the tenant from the identity the provider asserts.
94
+ * Login form (Login tab). Presentational + controlled — the consumer owns state, validation and
95
+ * discovery. Email first, sign-in methods under it: everything that changes once the email is known
96
+ * sits below the field, and the block changes as a whole. Before that the generic providers are
97
+ * there and need nothing typed; whether they are gated is the consumer's call via `ssoDisabled`.
78
98
  */
79
99
  export function LoginForm({
80
100
  email,
@@ -84,17 +104,18 @@ export function LoginForm({
84
104
  disabled = false,
85
105
  errors,
86
106
  emailStatus,
107
+ notice,
87
108
  ssoProviders,
88
109
  onSsoClick,
89
110
  ssoDisabled = false,
90
111
  ssoEnabledProviders,
91
112
  ssoActionLabel = 'Continue with',
92
- dividerLabel = 'or continue with OpenFrame SSO',
93
- customSsoProviders,
94
- noCustomSsoLabel = 'No additional SSO configurations for this domain',
113
+ dividerLabel = 'or continue with',
114
+ tenantMethods,
95
115
  onSubmitClick,
96
116
  submitLabel = 'Continue',
97
117
  submitDisabled = false,
118
+ onEmailEnter,
98
119
  title = 'Login to OpenFrame',
99
120
  subtitle = 'Enter your email to access your organization.',
100
121
  termsUrl,
@@ -102,15 +123,20 @@ export function LoginForm({
102
123
  className,
103
124
  }: LoginFormProps) {
104
125
  const fieldDisabled = disabled || loading;
126
+ const providersDisabled = fieldDisabled || ssoDisabled;
105
127
 
106
128
  // Validation messages are deferred while the user is typing (shown on blur or after a pause).
107
129
  const emailErr = useDeferredError(errors?.email, email);
108
130
 
109
- // Enter drives the same action as the visible button, under the same gate. A form with no
110
- // submit (the Login tab, where the providers are the only way on) simply does nothing.
131
+ // Enter drives the same action as the visible button, under the same gate. A held key's repeats
132
+ // are not new presses: each would re-run an action that can fail fast (a lookup while offline).
111
133
  const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
112
- if (event.key !== 'Enter' || fieldDisabled || submitDisabled) return;
113
- onSubmitClick?.();
134
+ if (event.key !== 'Enter' || event.repeat || fieldDisabled) return;
135
+ if (onSubmitClick) {
136
+ if (!submitDisabled) onSubmitClick();
137
+ return;
138
+ }
139
+ onEmailEnter?.();
114
140
  };
115
141
 
116
142
  return (
@@ -126,21 +152,6 @@ export function LoginForm({
126
152
  <p className="text-ods-text-secondary text-h4">{subtitle}</p>
127
153
  </div>
128
154
 
129
- {/* Providers come FIRST. They need nothing typed, and putting the email above them taught
130
- people to fill it in before tapping one — the behaviour that made a provider button look
131
- broken while it waited on a field it never needed. */}
132
- {ssoProviders.length > 0 && (
133
- <SsoProviderButtons
134
- providers={ssoProviders}
135
- onSsoClick={onSsoClick}
136
- actionLabel={ssoActionLabel}
137
- disabled={disabled || loading || ssoDisabled}
138
- enabledProviders={ssoEnabledProviders}
139
- />
140
- )}
141
-
142
- {ssoProviders.length > 0 && <LabeledDivider label={dividerLabel} />}
143
-
144
155
  {/* Email */}
145
156
  <Input
146
157
  type="email"
@@ -155,28 +166,21 @@ export function LoginForm({
155
166
  onKeyDown={handleKeyDown}
156
167
  />
157
168
 
158
- {customSsoProviders &&
159
- (customSsoProviders.length > 0 ? (
160
- <SsoProviderButtons
161
- providers={customSsoProviders}
162
- onSsoClick={onSsoClick}
163
- actionLabel={ssoActionLabel}
164
- disabled={disabled || loading}
165
- />
166
- ) : (
167
- // Same footprint as the button it stands in for, so nothing shifts when a tenant does
168
- // have options. Tokens are the design's: soft-grey surface, grey text and icon, the
169
- // bold-body step, a 24px icon, and 12/16 for the inset and the gap.
170
- <div
171
- role="status"
172
- // A minimum, not a fixed height: a consumer's `noCustomSsoLabel` can run to several lines
173
- // at phone width, and a fixed row clipped it.
174
- className="flex min-h-11 w-full items-center gap-[var(--spacing-system-m)] rounded-md bg-ods-bg-surface px-[var(--spacing-system-s)] py-[var(--spacing-system-s)] text-ods-text-secondary text-h3 md:min-h-12"
175
- >
176
- <AlertTriangleIcon className="h-6 w-6 shrink-0" />
177
- {noCustomSsoLabel}
178
- </div>
179
- ))}
169
+ {notice && (
170
+ // Tokens are the design's: soft-grey surface, grey text and icon, the bold-body step, a 24px
171
+ // icon, and 12/16 for the inset and the gap.
172
+ <div
173
+ role="status"
174
+ // A minimum, not a fixed height: the notice can run to several lines at phone width.
175
+ className={cn(
176
+ 'flex min-h-11 w-full items-center gap-[var(--spacing-system-m)] rounded-md bg-ods-bg-surface px-[var(--spacing-system-s)] py-[var(--spacing-system-s)] text-ods-text-secondary text-h3 md:min-h-12',
177
+ ENTER_ANIMATION,
178
+ )}
179
+ >
180
+ <AlertTriangleIcon className="h-6 w-6 shrink-0" />
181
+ {notice}
182
+ </div>
183
+ )}
180
184
 
181
185
  {onSubmitClick && (
182
186
  // Full width on a phone, half the row and right-aligned from `md` up, matching the design.
@@ -197,6 +201,59 @@ export function LoginForm({
197
201
  </div>
198
202
  )}
199
203
 
204
+ {(tenantMethods || ssoProviders.length > 0) && (
205
+ // One block, so an organization's methods replace the generic set as a whole: the key mounts
206
+ // it anew when they arrive, and every row of it eases in, not only the rows that are new.
207
+ // Going back to the generic set swaps without a transition, as the prototype does.
208
+ <div
209
+ key={tenantMethods ? `tenant:${tenantMethods.name ?? ''}` : 'generic'}
210
+ role="group"
211
+ aria-label="Sign-in methods"
212
+ className={cn('flex flex-col gap-[var(--spacing-system-l)]', tenantMethods && ENTER_ANIMATION)}
213
+ >
214
+ {tenantMethods && (
215
+ <>
216
+ <LabeledDivider label={`Sign-in methods for ${tenantMethods.name || 'your organization'}`} />
217
+ {tenantMethods.openframe && (
218
+ // Not configured with anyone's credentials, so it carries no tag.
219
+ <SsoProviderButtons
220
+ providers={['openframe']}
221
+ onSsoClick={onSsoClick}
222
+ actionLabel={ssoActionLabel}
223
+ disabled={providersDisabled}
224
+ />
225
+ )}
226
+ {tenantMethods.providers.length > 0 && (
227
+ <SsoProviderButtons
228
+ providers={tenantMethods.providers}
229
+ onSsoClick={onSsoClick}
230
+ actionLabel={ssoActionLabel}
231
+ disabled={providersDisabled}
232
+ tag={tenantMethods.name}
233
+ />
234
+ )}
235
+ </>
236
+ )}
237
+
238
+ {ssoProviders.length > 0 && (
239
+ <>
240
+ {/* Under an organization heading with nothing above it, a second divider would only
241
+ separate the heading from its own buttons. */}
242
+ {(!tenantMethods || tenantMethods.openframe || tenantMethods.providers.length > 0) && (
243
+ <LabeledDivider label={dividerLabel} />
244
+ )}
245
+ <SsoProviderButtons
246
+ providers={ssoProviders}
247
+ onSsoClick={onSsoClick}
248
+ actionLabel={ssoActionLabel}
249
+ disabled={providersDisabled}
250
+ enabledProviders={ssoEnabledProviders}
251
+ />
252
+ </>
253
+ )}
254
+ </div>
255
+ )}
256
+
200
257
  {termsUrl && privacyPolicyUrl && <LegalLinks termsUrl={termsUrl} privacyPolicyUrl={privacyPolicyUrl} />}
201
258
  </div>
202
259
  );
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { requireIcon, type IconComponent } from '../../chat/utils/icon-loading';
4
4
  import { Button } from '../../ui/button';
5
+ import { StatusBadge } from '../../ui/status-badge';
5
6
 
6
7
  /** SSO providers offered on the auth forms. */
7
8
  export type AuthSsoProvider = 'openframe' | 'google' | 'microsoft' | 'apple';
@@ -34,6 +35,12 @@ export interface SsoProviderButtonsProps {
34
35
  * `disabled` still wins over this list.
35
36
  */
36
37
  enabledProviders?: AuthSsoProvider[];
38
+ /**
39
+ * A stamp on every button naming who configured these providers — e.g. the organization whose
40
+ * own Google Workspace a "Continue with Google" button signs in through, which is not the same
41
+ * button as the generic one.
42
+ */
43
+ tag?: string;
37
44
  }
38
45
 
39
46
  /** Stacked full-width SSO provider buttons shared by the auth forms. */
@@ -43,6 +50,7 @@ export function SsoProviderButtons({
43
50
  actionLabel = 'Continue with',
44
51
  disabled = false,
45
52
  enabledProviders,
53
+ tag,
46
54
  }: SsoProviderButtonsProps) {
47
55
  return (
48
56
  <div className="flex flex-col gap-[var(--spacing-system-l)]">
@@ -62,6 +70,19 @@ export function SsoProviderButtons({
62
70
  onClick={() => onSsoClick?.(provider)}
63
71
  >
64
72
  {`${actionLabel} ${meta.name}`}
73
+ {tag && (
74
+ // A child, not `rightIcon`: the Button lays its children out through a `contents` span,
75
+ // so the badge is a flex item of the button itself and can give up width (`min-w-0`)
76
+ // where a long organization name would otherwise push the label out of a phone-width
77
+ // button. `block` over its inline-flex, because an ellipsis only renders on a block box.
78
+ <StatusBadge
79
+ text={tag}
80
+ variant="button"
81
+ singleLine
82
+ title={tag}
83
+ className="block min-w-0 max-w-32 truncate"
84
+ />
85
+ )}
65
86
  </Button>
66
87
  );
67
88
  })}