@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.
- package/dist/{chunk-45YRDD7W.cjs → chunk-4WNBVNGP.cjs} +13 -13
- package/dist/{chunk-SOPVUD2Y.cjs → chunk-53I4YHRT.cjs} +3 -3
- package/dist/{chunk-HOEVD6EN.cjs → chunk-5P7WBA2U.cjs} +4 -4
- package/dist/{chunk-KRAQEZPB.cjs → chunk-5WC535NJ.cjs} +17 -17
- package/dist/{chunk-SGL47BS2.cjs → chunk-77IKUBSH.cjs} +6 -6
- package/dist/{chunk-E3KFEOFA.cjs → chunk-CVBHB4OZ.cjs} +4 -4
- package/dist/{chunk-CXV7VBSI.cjs → chunk-ESRA7NUI.cjs} +41 -41
- package/dist/{chunk-2J2OBP4N.cjs → chunk-H52GKJ36.cjs} +9 -9
- package/dist/{chunk-G7SBHMRR.cjs → chunk-KHKPLOU4.cjs} +69 -39
- package/dist/{chunk-SECT4H34.cjs → chunk-LIGDPFDM.cjs} +227 -172
- package/dist/{chunk-LQT2ITMG.cjs → chunk-MJFTWHV6.cjs} +4 -4
- package/dist/{chunk-ZLF55RDR.cjs → chunk-MU7FFN6M.cjs} +34 -34
- package/dist/{chunk-OZLIKBKC.cjs → chunk-NOUROZYH.cjs} +13 -13
- package/dist/{chunk-GOFEOCQN.cjs → chunk-QOONZMSQ.cjs} +9 -9
- package/dist/{chunk-VEVIMDNK.cjs → chunk-RVLQHSID.cjs} +70 -70
- package/dist/{chunk-BN6NA5RM.cjs → chunk-SDOCZTST.cjs} +32 -32
- package/dist/{chunk-V3UJ6NDR.cjs → chunk-TPH4SRR2.cjs} +6 -6
- package/dist/{chunk-4FWURR3W.cjs → chunk-VUWPLQBY.cjs} +2 -2
- package/dist/{chunk-WZX26ODJ.cjs → chunk-X4LAMJHD.cjs} +54 -35
- package/dist/{cloud-tenants-2MYFHEYY.cjs → cloud-tenants-JDPC2DYF.cjs} +6 -6
- package/dist/components/case-studies/index.cjs +16 -16
- package/dist/components/chat/hooks/use-jetstream-dialog-subscription.d.ts +7 -5
- package/dist/components/chat/hooks/use-jetstream-dialog-subscription.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-jetstream-dialog-subscription.js +33 -14
- package/dist/components/chat/index.cjs +4 -4
- package/dist/components/chat/types/api.types.d.ts +27 -8
- package/dist/components/chat/types/api.types.d.ts.map +1 -1
- package/dist/components/chat/types/network.types.d.ts +2 -1
- package/dist/components/chat/types/network.types.d.ts.map +1 -1
- package/dist/components/contact/index.cjs +7 -7
- package/dist/components/docs/index.cjs +10 -10
- package/dist/components/embeds/index.cjs +7 -7
- package/dist/components/faq/index.cjs +8 -8
- package/dist/components/features/auth/login-form.d.ts +34 -21
- package/dist/components/features/auth/login-form.d.ts.map +1 -1
- package/dist/components/features/auth/login-form.js +77 -39
- package/dist/components/features/auth/sso-providers.d.ts +7 -1
- package/dist/components/features/auth/sso-providers.d.ts.map +1 -1
- package/dist/components/features/auth/sso-providers.js +22 -4
- package/dist/components/features/index.cjs +6 -6
- package/dist/components/help-center-pages/index.cjs +67 -67
- package/dist/components/index.cjs +39 -39
- package/dist/components/meeting-scheduler/index.cjs +29 -29
- package/dist/components/navigation/index.cjs +9 -9
- package/dist/components/onboarding-guides/index.cjs +8 -8
- package/dist/components/product-demo/index.cjs +7 -7
- package/dist/components/tickets/index.cjs +11 -11
- package/dist/components/ui/index.cjs +6 -6
- package/dist/{devices-FVXAGKVX.cjs → devices-ADQWD37J.cjs} +7 -7
- package/dist/{embeddable-chat-SNYQSMS7.cjs → embeddable-chat-7YF2JMPS.cjs} +65 -65
- package/dist/hooks/index.cjs +3 -3
- package/dist/index.cjs +10 -10
- package/dist/{knowledge-ZL3ZHBSL.cjs → knowledge-TNS64EGK.cjs} +7 -7
- package/dist/{logs-KQCWDIOB.cjs → logs-754MDDE4.cjs} +14 -14
- package/dist/nats/index.cjs +2 -2
- package/dist/nats/nats.d.ts +33 -7
- package/dist/nats/nats.d.ts.map +1 -1
- package/dist/nats/nats.js +41 -11
- package/dist/{policies-ARDCZ3CT.cjs → policies-Q23DPIH6.cjs} +10 -10
- package/dist/{scripts-ZAMVWCY4.cjs → scripts-DCYLK2RZ.cjs} +11 -11
- package/dist/{software-update-6F7BIYS3.cjs → software-update-XCMGUIER.cjs} +9 -9
- package/package.json +1 -1
- package/src/components/chat/hooks/use-jetstream-dialog-subscription.ts +59 -23
- package/src/components/chat/types/api.types.ts +27 -8
- package/src/components/chat/types/network.types.ts +2 -1
- package/src/components/features/auth/login-form.tsx +121 -64
- package/src/components/features/auth/sso-providers.tsx +21 -0
- 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
|
|
56
|
-
* inactivity threshold (
|
|
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.
|
|
59
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
}
|
|
438
|
-
return;
|
|
467
|
+
if (cancelled) {
|
|
468
|
+
try {
|
|
469
|
+
handle.unsubscribe();
|
|
470
|
+
} catch {
|
|
471
|
+
// ignore
|
|
439
472
|
}
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
440
475
|
|
|
441
|
-
|
|
442
|
-
|
|
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
|
-
|
|
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
|
|
468
|
-
//
|
|
469
|
-
//
|
|
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
|
|
107
|
-
* DeliverPolicy.New
|
|
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
|
|
131
|
-
*
|
|
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,
|
|
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
|
|
160
|
-
*
|
|
161
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
29
|
-
*
|
|
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.
|
|
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
|
|
61
|
+
/** Label on the divider above the generic providers. */
|
|
40
62
|
dividerLabel?: string;
|
|
41
63
|
/**
|
|
42
|
-
* The
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
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
|
|
93
|
-
|
|
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
|
|
110
|
-
//
|
|
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' ||
|
|
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
|
-
{
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
})}
|