@secmia/openui-flow 4.2.1 → 4.2.5
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/README.md +338 -995
- package/dist/index.js +0 -4
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +0 -4
- package/dist/index.mjs.map +1 -1
- package/package.json +11 -2
package/README.md
CHANGED
|
@@ -1,995 +1,338 @@
|
|
|
1
|
-
# @secmia/openui-flow
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
DefaultAppRequirements
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
]
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
'
|
|
262
|
-
'
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
},
|
|
340
|
-
};
|
|
341
|
-
|
|
342
|
-
export default function EnterpriseFlow() {
|
|
343
|
-
return (
|
|
344
|
-
<AdaptiveFlow<ProductContext, ProductRequirement, ProductStep>
|
|
345
|
-
requirementGraph={graph}
|
|
346
|
-
completeStep={DefaultAdaptiveSteps.COMPLETE}
|
|
347
|
-
adapter={adapter}
|
|
348
|
-
validators={validators}
|
|
349
|
-
persistence={{
|
|
350
|
-
key: 'enterprise-flow-state:v1',
|
|
351
|
-
storage: 'session',
|
|
352
|
-
clearOnComplete: true,
|
|
353
|
-
}}
|
|
354
|
-
stepTitles={{
|
|
355
|
-
SELECT_PLAN: 'Choose your plan',
|
|
356
|
-
COLLECT_WORKSPACE: 'Name your workspace',
|
|
357
|
-
}}
|
|
358
|
-
stepRegistry={{
|
|
359
|
-
SELECT_PLAN: ({ setContextPatch }) => (
|
|
360
|
-
<button onClick={() => setContextPatch({ selectedPlan: 'pro' })}>Select Pro Plan</button>
|
|
361
|
-
),
|
|
362
|
-
COLLECT_WORKSPACE: ({ setContextPatch }) => (
|
|
363
|
-
<button onClick={() => setContextPatch({ workspaceName: 'Acme HQ' })}>Set Workspace</button>
|
|
364
|
-
),
|
|
365
|
-
}}
|
|
366
|
-
renderStep={({ defaultView, transitions }) => (
|
|
367
|
-
<div>
|
|
368
|
-
<p>Transitions so far: {transitions.length}</p>
|
|
369
|
-
{defaultView}
|
|
370
|
-
</div>
|
|
371
|
-
)}
|
|
372
|
-
onStepTransition={(transition) => {
|
|
373
|
-
console.log('transition', transition);
|
|
374
|
-
}}
|
|
375
|
-
onError={(error) => {
|
|
376
|
-
console.error('flow error', error);
|
|
377
|
-
}}
|
|
378
|
-
onComplete={(context) => {
|
|
379
|
-
console.log('complete context', context);
|
|
380
|
-
}}
|
|
381
|
-
classNames={{
|
|
382
|
-
shell: 'rounded-xl border p-6 bg-white',
|
|
383
|
-
title: 'text-xl font-semibold',
|
|
384
|
-
}}
|
|
385
|
-
styles={{
|
|
386
|
-
shell: { maxWidth: 720 },
|
|
387
|
-
}}
|
|
388
|
-
unstyled={false}
|
|
389
|
-
initialValue={{
|
|
390
|
-
selectedPlan: null,
|
|
391
|
-
workspaceName: null,
|
|
392
|
-
riskScore: 80,
|
|
393
|
-
useSso: false,
|
|
394
|
-
}}
|
|
395
|
-
/>
|
|
396
|
-
);
|
|
397
|
-
}
|
|
398
|
-
```
|
|
399
|
-
|
|
400
|
-
---
|
|
401
|
-
|
|
402
|
-
## Custom UI: full control options
|
|
403
|
-
|
|
404
|
-
You have 4 UI customization levels, from quick theming to complete headless control.
|
|
405
|
-
|
|
406
|
-
### Level 1: style and class overrides
|
|
407
|
-
|
|
408
|
-
Keep built-in UI structure, but override styles/classes for all slots.
|
|
409
|
-
|
|
410
|
-
```tsx
|
|
411
|
-
<AdaptiveFlow
|
|
412
|
-
classNames={{
|
|
413
|
-
shell: 'rounded-2xl border border-zinc-200 p-8',
|
|
414
|
-
title: 'text-2xl font-bold tracking-tight',
|
|
415
|
-
oauthButton: 'h-11 rounded-lg border px-4',
|
|
416
|
-
error: 'text-sm text-red-700 bg-red-50 rounded p-3',
|
|
417
|
-
}}
|
|
418
|
-
styles={{
|
|
419
|
-
shell: { maxWidth: 680, margin: '0 auto' },
|
|
420
|
-
title: { letterSpacing: '-0.01em' },
|
|
421
|
-
}}
|
|
422
|
-
/>
|
|
423
|
-
```
|
|
424
|
-
|
|
425
|
-
Configure OAuth button list in default footer:
|
|
426
|
-
|
|
427
|
-
```tsx
|
|
428
|
-
<AdaptiveFlow
|
|
429
|
-
oauthProviders={[
|
|
430
|
-
{ id: 'google', label: 'Continue with Google' },
|
|
431
|
-
{ id: 'github', label: 'Continue with GitHub' },
|
|
432
|
-
{ id: 'microsoft', label: 'Continue with Microsoft' },
|
|
433
|
-
]}
|
|
434
|
-
/>
|
|
435
|
-
```
|
|
436
|
-
|
|
437
|
-
Use `unstyled` to disable built-in inline defaults.
|
|
438
|
-
|
|
439
|
-
```tsx
|
|
440
|
-
<AdaptiveFlow unstyled classNames={{ shell: 'my-own-layout-root' }} />
|
|
441
|
-
```
|
|
442
|
-
|
|
443
|
-
### Level 2: custom UI for specific steps
|
|
444
|
-
|
|
445
|
-
Use `stepRegistry` to replace selected steps with your own components while leaving other steps on defaults.
|
|
446
|
-
|
|
447
|
-
```tsx
|
|
448
|
-
<AdaptiveFlow
|
|
449
|
-
stepRegistry={{
|
|
450
|
-
SELECT_PLAN: ({ setContextPatch, run }) => (
|
|
451
|
-
<div>
|
|
452
|
-
<h3>Choose a plan</h3>
|
|
453
|
-
<button
|
|
454
|
-
onClick={() => {
|
|
455
|
-
void run(async () => {
|
|
456
|
-
setContextPatch({ selectedPlan: 'starter' });
|
|
457
|
-
});
|
|
458
|
-
}}
|
|
459
|
-
>
|
|
460
|
-
Starter
|
|
461
|
-
</button>
|
|
462
|
-
<button
|
|
463
|
-
onClick={() => {
|
|
464
|
-
void run(async () => {
|
|
465
|
-
setContextPatch({ selectedPlan: 'pro' });
|
|
466
|
-
});
|
|
467
|
-
}}
|
|
468
|
-
>
|
|
469
|
-
Pro
|
|
470
|
-
</button>
|
|
471
|
-
</div>
|
|
472
|
-
),
|
|
473
|
-
}}
|
|
474
|
-
/>
|
|
475
|
-
```
|
|
476
|
-
|
|
477
|
-
`run(job)` semantics in custom renderers:
|
|
478
|
-
- sets `busy=true` during the async job
|
|
479
|
-
- clears previous global/field errors before execution
|
|
480
|
-
- catches and normalizes thrown errors into flow error state
|
|
481
|
-
- reverts `busy=false` on completion
|
|
482
|
-
|
|
483
|
-
Use it for custom step actions to keep consistent loading/error behavior.
|
|
484
|
-
|
|
485
|
-
### Level 3: custom renderer for all steps
|
|
486
|
-
|
|
487
|
-
Use `renderStep` for full step-body ownership. You can still reuse `defaultView` when useful.
|
|
488
|
-
|
|
489
|
-
```tsx
|
|
490
|
-
<AdaptiveFlow
|
|
491
|
-
renderStep={({ step, defaultView, transitions, context, setContextPatch }) => (
|
|
492
|
-
<section>
|
|
493
|
-
<header>
|
|
494
|
-
<h2>Current step: {step}</h2>
|
|
495
|
-
<p>Transitions: {transitions.length}</p>
|
|
496
|
-
</header>
|
|
497
|
-
|
|
498
|
-
<aside>
|
|
499
|
-
<button onClick={() => setContextPatch({ locale: 'en-US' } as Partial<typeof context>)}>
|
|
500
|
-
Set locale
|
|
501
|
-
</button>
|
|
502
|
-
</aside>
|
|
503
|
-
|
|
504
|
-
<main>{defaultView}</main>
|
|
505
|
-
</section>
|
|
506
|
-
)}
|
|
507
|
-
/>
|
|
508
|
-
```
|
|
509
|
-
|
|
510
|
-
### Level 4: fully custom UI for everything (headless mode)
|
|
511
|
-
|
|
512
|
-
If you want total control over shell, header, footer, button layout, and every interaction, use `useAdaptiveFlow` (or engine APIs directly).
|
|
513
|
-
|
|
514
|
-
### Headless hook (`useAdaptiveFlow`)
|
|
515
|
-
|
|
516
|
-
`useAdaptiveFlow` exposes the flow lifecycle without rendering any UI.
|
|
517
|
-
|
|
518
|
-
```tsx
|
|
519
|
-
'use client';
|
|
520
|
-
|
|
521
|
-
import { useAdaptiveFlow, type AdaptiveContextBase } from '@secmia/openui-flow';
|
|
522
|
-
|
|
523
|
-
type Ctx = AdaptiveContextBase & {
|
|
524
|
-
selectedPlan: 'starter' | 'pro' | null;
|
|
525
|
-
};
|
|
526
|
-
|
|
527
|
-
export default function FullyCustomFlow() {
|
|
528
|
-
const {
|
|
529
|
-
step,
|
|
530
|
-
context,
|
|
531
|
-
missingRequirements,
|
|
532
|
-
busy,
|
|
533
|
-
fieldErrors,
|
|
534
|
-
handleEmail,
|
|
535
|
-
handleOtp,
|
|
536
|
-
handlePassword,
|
|
537
|
-
handleProfile,
|
|
538
|
-
handleTos,
|
|
539
|
-
setContextPatch,
|
|
540
|
-
} = useAdaptiveFlow<Ctx>({
|
|
541
|
-
requirements: ['has_email', 'email_verified', 'has_password', 'has_first_name', 'accepted_tos', 'selected_plan'],
|
|
542
|
-
completeStep: 'COMPLETE',
|
|
543
|
-
initialValue: { selectedPlan: null },
|
|
544
|
-
});
|
|
545
|
-
|
|
546
|
-
return (
|
|
547
|
-
<div>
|
|
548
|
-
<h2>Step: {step}</h2>
|
|
549
|
-
<p>Pending: {missingRequirements.length}</p>
|
|
550
|
-
{fieldErrors.email ? <p>{fieldErrors.email}</p> : null}
|
|
551
|
-
|
|
552
|
-
<button disabled={busy} onClick={() => handleEmail('user@company.com')}>Submit Email</button>
|
|
553
|
-
<button disabled={busy} onClick={() => handleOtp('123456')}>Submit OTP</button>
|
|
554
|
-
<button disabled={busy} onClick={() => handlePassword('secure-password')}>Submit Password</button>
|
|
555
|
-
<button disabled={busy} onClick={() => handleProfile({ firstName: 'A', lastName: 'B', jobTitle: null })}>Save Profile</button>
|
|
556
|
-
<button disabled={busy} onClick={() => handleTos()}>Accept TOS</button>
|
|
557
|
-
<button onClick={() => setContextPatch({ selectedPlan: 'pro' })}>Select Plan</button>
|
|
558
|
-
<pre>{JSON.stringify(context, null, 2)}</pre>
|
|
559
|
-
</div>
|
|
560
|
-
);
|
|
561
|
-
}
|
|
562
|
-
```
|
|
563
|
-
|
|
564
|
-
You can still bypass the hook and use pure engine APIs if you need lower-level control.
|
|
565
|
-
|
|
566
|
-
### Optional schema validation (Zod-friendly)
|
|
567
|
-
|
|
568
|
-
`AdaptiveFlow` and `useAdaptiveFlow` accept a `schemas` object. Any schema with `safeParse` or `parse` is supported (including Zod schemas).
|
|
569
|
-
|
|
570
|
-
```tsx
|
|
571
|
-
import { z } from 'zod';
|
|
572
|
-
|
|
573
|
-
const emailSchema = z.string().email();
|
|
574
|
-
const profileSchema = z.object({
|
|
575
|
-
firstName: z.string().min(1),
|
|
576
|
-
lastName: z.string().min(1),
|
|
577
|
-
jobTitle: z.string().nullable(),
|
|
578
|
-
});
|
|
579
|
-
|
|
580
|
-
<AdaptiveFlow
|
|
581
|
-
schemas={{
|
|
582
|
-
email: emailSchema,
|
|
583
|
-
profile: profileSchema,
|
|
584
|
-
}}
|
|
585
|
-
/>
|
|
586
|
-
```
|
|
587
|
-
|
|
588
|
-
### Field-level validation errors
|
|
589
|
-
|
|
590
|
-
Validators can return either a string or `{ field, message }`.
|
|
591
|
-
|
|
592
|
-
```ts
|
|
593
|
-
const validators = {
|
|
594
|
-
password: (value: string) => {
|
|
595
|
-
if (value.length < 12) {
|
|
596
|
-
return { field: 'password', message: 'Password must be at least 12 characters.' };
|
|
597
|
-
}
|
|
598
|
-
},
|
|
599
|
-
};
|
|
600
|
-
```
|
|
601
|
-
|
|
602
|
-
Default UI now binds field errors to the matching input and sets `aria-invalid` automatically.
|
|
603
|
-
|
|
604
|
-
```tsx
|
|
605
|
-
'use client';
|
|
606
|
-
|
|
607
|
-
import { useEffect, useMemo, useState } from 'react';
|
|
608
|
-
import {
|
|
609
|
-
createRequirementGraph,
|
|
610
|
-
evaluateNextStep,
|
|
611
|
-
getMissingRequirements,
|
|
612
|
-
DefaultAdaptiveSteps,
|
|
613
|
-
type AdaptiveContextBase,
|
|
614
|
-
type RequirementResolver,
|
|
615
|
-
} from '@secmia/openui-flow';
|
|
616
|
-
|
|
617
|
-
type Ctx = AdaptiveContextBase & {
|
|
618
|
-
selectedPlan: 'starter' | 'pro' | null;
|
|
619
|
-
};
|
|
620
|
-
|
|
621
|
-
type Req = 'has_email' | 'email_verified' | 'selected_plan';
|
|
622
|
-
type Step = 'COLLECT_EMAIL' | 'VERIFY_OTP' | 'SELECT_PLAN' | 'COMPLETE';
|
|
623
|
-
|
|
624
|
-
const resolvers: Partial<Record<Req, RequirementResolver<Ctx, Step>>> = {
|
|
625
|
-
has_email: { step: 'COLLECT_EMAIL', isMet: (ctx) => Boolean(ctx.email) },
|
|
626
|
-
email_verified: { step: 'VERIFY_OTP', isMet: (ctx) => ctx.isVerified },
|
|
627
|
-
selected_plan: { step: 'SELECT_PLAN', isMet: (ctx) => Boolean(ctx.selectedPlan) },
|
|
628
|
-
};
|
|
629
|
-
|
|
630
|
-
export default function FullyCustomAuth() {
|
|
631
|
-
const [context, setContext] = useState<Ctx>({
|
|
632
|
-
email: null,
|
|
633
|
-
isVerified: false,
|
|
634
|
-
hasPassword: false,
|
|
635
|
-
agreedToTos: false,
|
|
636
|
-
profile: null,
|
|
637
|
-
selectedPlan: null,
|
|
638
|
-
});
|
|
639
|
-
const [step, setStep] = useState<Step>('COLLECT_EMAIL');
|
|
640
|
-
const [missing, setMissing] = useState<Req[]>([]);
|
|
641
|
-
|
|
642
|
-
const graph = useMemo(
|
|
643
|
-
() => createRequirementGraph<Ctx, Req, Step>(['has_email', 'email_verified', 'selected_plan'], resolvers),
|
|
644
|
-
[],
|
|
645
|
-
);
|
|
646
|
-
|
|
647
|
-
useEffect(() => {
|
|
648
|
-
void (async () => {
|
|
649
|
-
const [nextStep, missingReqs] = await Promise.all([
|
|
650
|
-
evaluateNextStep(context, graph, DefaultAdaptiveSteps.COMPLETE as Step),
|
|
651
|
-
getMissingRequirements(context, graph),
|
|
652
|
-
]);
|
|
653
|
-
setStep(nextStep);
|
|
654
|
-
setMissing(missingReqs);
|
|
655
|
-
})();
|
|
656
|
-
}, [context, graph]);
|
|
657
|
-
|
|
658
|
-
return (
|
|
659
|
-
<div>
|
|
660
|
-
<header>
|
|
661
|
-
<h1>My Brand Auth</h1>
|
|
662
|
-
<p>
|
|
663
|
-
Progress: {3 - missing.length}/3
|
|
664
|
-
</p>
|
|
665
|
-
</header>
|
|
666
|
-
|
|
667
|
-
{step === 'COLLECT_EMAIL' ? (
|
|
668
|
-
<button onClick={() => setContext((prev) => ({ ...prev, email: 'user@company.com' }))}>
|
|
669
|
-
Mock email submit
|
|
670
|
-
</button>
|
|
671
|
-
) : null}
|
|
672
|
-
|
|
673
|
-
{step === 'VERIFY_OTP' ? (
|
|
674
|
-
<button onClick={() => setContext((prev) => ({ ...prev, isVerified: true }))}>
|
|
675
|
-
Mock verify
|
|
676
|
-
</button>
|
|
677
|
-
) : null}
|
|
678
|
-
|
|
679
|
-
{step === 'SELECT_PLAN' ? (
|
|
680
|
-
<button onClick={() => setContext((prev) => ({ ...prev, selectedPlan: 'pro' }))}>
|
|
681
|
-
Choose Pro
|
|
682
|
-
</button>
|
|
683
|
-
) : null}
|
|
684
|
-
|
|
685
|
-
{step === 'COMPLETE' ? <p>All done.</p> : null}
|
|
686
|
-
</div>
|
|
687
|
-
);
|
|
688
|
-
}
|
|
689
|
-
```
|
|
690
|
-
|
|
691
|
-
Precedence rule inside `AdaptiveFlow`:
|
|
692
|
-
- `renderStep` wins first.
|
|
693
|
-
- If `renderStep` is not provided, `stepRegistry[step]` is used.
|
|
694
|
-
- If neither is provided, built-in default step UI is rendered.
|
|
695
|
-
|
|
696
|
-
This gives you a clean path from quick defaults to complete custom UI ownership.
|
|
697
|
-
|
|
698
|
-
---
|
|
699
|
-
|
|
700
|
-
## Integration recipes
|
|
701
|
-
|
|
702
|
-
### Next.js App Router
|
|
703
|
-
|
|
704
|
-
```tsx
|
|
705
|
-
'use client';
|
|
706
|
-
|
|
707
|
-
import { useRouter } from 'next/navigation';
|
|
708
|
-
import {
|
|
709
|
-
AdaptiveFlow,
|
|
710
|
-
DefaultAppRequirements,
|
|
711
|
-
type AdaptiveFlowAdapter,
|
|
712
|
-
} from '@secmia/openui-flow';
|
|
713
|
-
|
|
714
|
-
type AppRequirement =
|
|
715
|
-
| (typeof DefaultAppRequirements)[keyof typeof DefaultAppRequirements]
|
|
716
|
-
| 'has_workspace_name';
|
|
717
|
-
|
|
718
|
-
const requirements: AppRequirement[] = [
|
|
719
|
-
DefaultAppRequirements.HAS_EMAIL,
|
|
720
|
-
DefaultAppRequirements.EMAIL_VERIFIED,
|
|
721
|
-
DefaultAppRequirements.HAS_PASSWORD,
|
|
722
|
-
DefaultAppRequirements.HAS_FIRST_NAME,
|
|
723
|
-
DefaultAppRequirements.ACCEPTED_TOS,
|
|
724
|
-
];
|
|
725
|
-
|
|
726
|
-
const adapter: AdaptiveFlowAdapter = {
|
|
727
|
-
async lookupByEmail(email) {
|
|
728
|
-
const response = await fetch('/api/identity/lookup', {
|
|
729
|
-
method: 'POST',
|
|
730
|
-
headers: { 'content-type': 'application/json' },
|
|
731
|
-
body: JSON.stringify({ email }),
|
|
732
|
-
});
|
|
733
|
-
if (!response.ok) {
|
|
734
|
-
throw new Error('Lookup failed.');
|
|
735
|
-
}
|
|
736
|
-
return response.json();
|
|
737
|
-
},
|
|
738
|
-
};
|
|
739
|
-
|
|
740
|
-
export default function SignInClient() {
|
|
741
|
-
const router = useRouter();
|
|
742
|
-
|
|
743
|
-
return (
|
|
744
|
-
<AdaptiveFlow
|
|
745
|
-
adapter={adapter}
|
|
746
|
-
requirements={requirements}
|
|
747
|
-
persistence={{ key: 'next-flow-state:v1', storage: 'session' }}
|
|
748
|
-
onComplete={() => router.replace('/dashboard')}
|
|
749
|
-
/>
|
|
750
|
-
);
|
|
751
|
-
}
|
|
752
|
-
```
|
|
753
|
-
|
|
754
|
-
### Supabase
|
|
755
|
-
|
|
756
|
-
```tsx
|
|
757
|
-
'use client';
|
|
758
|
-
|
|
759
|
-
import { createClient } from '@supabase/supabase-js';
|
|
760
|
-
import { AdaptiveFlow, type AdaptiveFlowAdapter } from '@secmia/openui-flow';
|
|
761
|
-
|
|
762
|
-
const supabase = createClient(
|
|
763
|
-
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
764
|
-
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
|
765
|
-
);
|
|
766
|
-
|
|
767
|
-
const adapter: AdaptiveFlowAdapter = {
|
|
768
|
-
async requestOtp(email) {
|
|
769
|
-
const { error } = await supabase.auth.signInWithOtp({ email });
|
|
770
|
-
if (error) {
|
|
771
|
-
throw new Error(error.message);
|
|
772
|
-
}
|
|
773
|
-
},
|
|
774
|
-
async verifyOtp(email, code) {
|
|
775
|
-
const { error } = await supabase.auth.verifyOtp({ email, token: code, type: 'email' });
|
|
776
|
-
if (error) {
|
|
777
|
-
throw new Error(error.message);
|
|
778
|
-
}
|
|
779
|
-
},
|
|
780
|
-
async startOAuth(provider) {
|
|
781
|
-
const { error } = await supabase.auth.signInWithOAuth({
|
|
782
|
-
provider,
|
|
783
|
-
options: { redirectTo: `${window.location.origin}/auth/callback` },
|
|
784
|
-
});
|
|
785
|
-
if (error) {
|
|
786
|
-
throw new Error(error.message);
|
|
787
|
-
}
|
|
788
|
-
},
|
|
789
|
-
};
|
|
790
|
-
|
|
791
|
-
export default function SupabaseAuth() {
|
|
792
|
-
return <AdaptiveFlow adapter={adapter} persistence={{ key: 'supabase-auth:v1' }} />;
|
|
793
|
-
}
|
|
794
|
-
```
|
|
795
|
-
|
|
796
|
-
### Firebase
|
|
797
|
-
|
|
798
|
-
```tsx
|
|
799
|
-
'use client';
|
|
800
|
-
|
|
801
|
-
import { getAuth, GoogleAuthProvider, OAuthProvider, signInWithPopup } from 'firebase/auth';
|
|
802
|
-
import { AdaptiveFlow, type AdaptiveFlowAdapter } from '@secmia/openui-flow';
|
|
803
|
-
import { app } from './firebase';
|
|
804
|
-
|
|
805
|
-
const auth = getAuth(app);
|
|
806
|
-
|
|
807
|
-
const adapter: AdaptiveFlowAdapter = {
|
|
808
|
-
async startOAuth(provider) {
|
|
809
|
-
if (provider === 'google') {
|
|
810
|
-
await signInWithPopup(auth, new GoogleAuthProvider());
|
|
811
|
-
return;
|
|
812
|
-
}
|
|
813
|
-
await signInWithPopup(auth, new OAuthProvider('apple.com'));
|
|
814
|
-
},
|
|
815
|
-
};
|
|
816
|
-
|
|
817
|
-
export default function FirebaseAuth() {
|
|
818
|
-
return <AdaptiveFlow adapter={adapter} persistence={{ key: 'firebase-auth:v1' }} />;
|
|
819
|
-
}
|
|
820
|
-
```
|
|
821
|
-
|
|
822
|
-
---
|
|
823
|
-
|
|
824
|
-
## Engine-only usage
|
|
825
|
-
|
|
826
|
-
```ts
|
|
827
|
-
import {
|
|
828
|
-
createRequirementGraph,
|
|
829
|
-
evaluateNextStep,
|
|
830
|
-
getMissingRequirements,
|
|
831
|
-
DefaultAdaptiveSteps,
|
|
832
|
-
} from '@secmia/openui-flow';
|
|
833
|
-
|
|
834
|
-
const nextStep = await evaluateNextStep(context, graph, DefaultAdaptiveSteps.COMPLETE);
|
|
835
|
-
const missing = await getMissingRequirements(context, graph);
|
|
836
|
-
```
|
|
837
|
-
|
|
838
|
-
---
|
|
839
|
-
|
|
840
|
-
## Core concepts
|
|
841
|
-
|
|
842
|
-
- Context: current auth and profile state.
|
|
843
|
-
- Requirement: business condition that must be satisfied.
|
|
844
|
-
- Resolver: checker function for one requirement.
|
|
845
|
-
- Step: current UI state for unmet requirement.
|
|
846
|
-
- RequirementGraph: graph nodes with optional priority, condition, and dependency metadata.
|
|
847
|
-
|
|
848
|
-
Graph behavior:
|
|
849
|
-
- Dependency-aware ordering uses topological sorting.
|
|
850
|
-
- Priority is used as tie-breaker among currently available nodes.
|
|
851
|
-
- Circular dependencies and unknown dependencies are rejected during graph creation.
|
|
852
|
-
|
|
853
|
-
Evaluation loop:
|
|
854
|
-
1. Build graph.
|
|
855
|
-
2. Evaluate active nodes.
|
|
856
|
-
3. Select highest-priority unmet node.
|
|
857
|
-
4. Render mapped step.
|
|
858
|
-
5. Update context.
|
|
859
|
-
6. Repeat until complete step.
|
|
860
|
-
|
|
861
|
-
---
|
|
862
|
-
|
|
863
|
-
## API reference
|
|
864
|
-
|
|
865
|
-
Primary exports:
|
|
866
|
-
- `AdaptiveFlow`
|
|
867
|
-
- `useAdaptiveFlow`
|
|
868
|
-
- `defaultRequirements`
|
|
869
|
-
- `initialContext`
|
|
870
|
-
- `defaultRequirementResolvers`
|
|
871
|
-
- `createDefaultRequirementGraph`
|
|
872
|
-
- `createRequirementGraph`
|
|
873
|
-
- `evaluateNextStep`
|
|
874
|
-
- `getMissingRequirements`
|
|
875
|
-
- `DefaultAdaptiveSteps`
|
|
876
|
-
- `DefaultAppRequirements`
|
|
877
|
-
|
|
878
|
-
Important types:
|
|
879
|
-
- `AdaptiveFlowProps`
|
|
880
|
-
- `AdaptiveFlowAdapter`
|
|
881
|
-
- `UseAdaptiveFlowOptions`
|
|
882
|
-
- `UseAdaptiveFlowResult`
|
|
883
|
-
- `AdaptiveFlowValidators`
|
|
884
|
-
- `AdaptiveFlowValidationResult`
|
|
885
|
-
- `AdaptiveFlowValidationIssue`
|
|
886
|
-
- `AdaptiveFlowFieldErrors`
|
|
887
|
-
- `AdaptiveFlowSchemas`
|
|
888
|
-
- `AdaptiveFlowSchema`
|
|
889
|
-
- `AdaptiveFlowPersistence`
|
|
890
|
-
- `AdaptiveFlowOAuthProvider`
|
|
891
|
-
- `AdaptiveStepRegistry`
|
|
892
|
-
- `AdaptiveStepRendererArgs`
|
|
893
|
-
- `AdaptiveStepRenderArgs`
|
|
894
|
-
- `AdaptiveStepTransition`
|
|
895
|
-
- `RequirementResolver`
|
|
896
|
-
- `RequirementGraph`
|
|
897
|
-
- `RequirementGraphNode`
|
|
898
|
-
- `AdaptiveContext`
|
|
899
|
-
- `AdaptiveContextBase`
|
|
900
|
-
|
|
901
|
-
---
|
|
902
|
-
|
|
903
|
-
## SSR and hydration notes
|
|
904
|
-
|
|
905
|
-
- Storage persistence only runs in the browser (`window` guard).
|
|
906
|
-
- On SSR, the flow starts from `initialValue`/defaults and then hydrates persisted browser state on mount.
|
|
907
|
-
- For App Router and other SSR setups, pass server-known session hints in `initialValue` to minimize client-side step flash.
|
|
908
|
-
|
|
909
|
-
Example:
|
|
910
|
-
|
|
911
|
-
```tsx
|
|
912
|
-
<AdaptiveFlow
|
|
913
|
-
initialValue={{
|
|
914
|
-
email: user.email ?? null,
|
|
915
|
-
isVerified: Boolean(user.emailVerifiedAt),
|
|
916
|
-
hasPassword: Boolean(user.hasPassword),
|
|
917
|
-
}}
|
|
918
|
-
persistence={{ key: 'flow-state:v1', storage: 'session' }}
|
|
919
|
-
/>
|
|
920
|
-
```
|
|
921
|
-
|
|
922
|
-
### Retry policy
|
|
923
|
-
|
|
924
|
-
Use `retryPolicy` to retry transient adapter failures with configurable backoff.
|
|
925
|
-
|
|
926
|
-
```tsx
|
|
927
|
-
<AdaptiveFlow
|
|
928
|
-
retryPolicy={{
|
|
929
|
-
maxAttempts: 4,
|
|
930
|
-
initialDelayMs: 200,
|
|
931
|
-
factor: 2,
|
|
932
|
-
maxDelayMs: 2000,
|
|
933
|
-
jitter: true,
|
|
934
|
-
shouldRetry: (error) => error.name !== 'FlowValidationError',
|
|
935
|
-
}}
|
|
936
|
-
/>
|
|
937
|
-
```
|
|
938
|
-
|
|
939
|
-
Default behavior:
|
|
940
|
-
- adapter calls are retried with exponential backoff
|
|
941
|
-
- non-adapter validation errors are not retried unless `shouldRetry` allows them
|
|
942
|
-
- retries use the browser/Node timer API, so no extra dependency is required
|
|
943
|
-
|
|
944
|
-
---
|
|
945
|
-
|
|
946
|
-
## Release checklist
|
|
947
|
-
|
|
948
|
-
- Ensure adapter methods return clear, user-safe errors.
|
|
949
|
-
- Version persistence keys (example: `flow-state:v1`).
|
|
950
|
-
- Track analytics with `onStepTransition`.
|
|
951
|
-
- Keep requirements and graph definitions local to each product module.
|
|
952
|
-
- Add integration tests for all flow branches.
|
|
953
|
-
- Run `npm run typecheck` and `npm run build` before every publish.
|
|
954
|
-
|
|
955
|
-
---
|
|
956
|
-
|
|
957
|
-
## Troubleshooting
|
|
958
|
-
|
|
959
|
-
Step not appearing:
|
|
960
|
-
- Cause: priority, condition, or dependency suppresses node activation.
|
|
961
|
-
- Fix: inspect graph `priorities`, `conditions`, and `dependencies`.
|
|
962
|
-
|
|
963
|
-
State resets on refresh:
|
|
964
|
-
- Cause: persistence missing or wrong key.
|
|
965
|
-
- Fix: set `persistence.key` and chosen storage.
|
|
966
|
-
|
|
967
|
-
OAuth return does not resume:
|
|
968
|
-
- Cause: missing `completeOAuth` or persistence disabled.
|
|
969
|
-
- Fix: implement `completeOAuth` and enable persistence.
|
|
970
|
-
|
|
971
|
-
Persistence write/read failures:
|
|
972
|
-
- Cause: storage quota, blocked storage, malformed persisted payload.
|
|
973
|
-
- Fix: pass `persistence.onError` to capture and report storage failures.
|
|
974
|
-
|
|
975
|
-
Custom nested context fields collapsing after patch:
|
|
976
|
-
- Cause: patching with a shallow object update.
|
|
977
|
-
- Fix: the flow now performs a recursive object merge for plain nested objects. Arrays are replaced, not merged.
|
|
978
|
-
|
|
979
|
-
```tsx
|
|
980
|
-
<AdaptiveFlow
|
|
981
|
-
persistence={{
|
|
982
|
-
key: 'flow-state:v1',
|
|
983
|
-
storage: 'local',
|
|
984
|
-
onError: (error, phase) => {
|
|
985
|
-
console.error('persistence failure', phase, error);
|
|
986
|
-
},
|
|
987
|
-
}}
|
|
988
|
-
/>
|
|
989
|
-
```
|
|
990
|
-
|
|
991
|
-
---
|
|
992
|
-
|
|
993
|
-
## License
|
|
994
|
-
|
|
995
|
-
MIT
|
|
1
|
+
# @secmia/openui-flow
|
|
2
|
+
|
|
3
|
+
Adaptive, graph-driven multi-step flow orchestration for React.
|
|
4
|
+
|
|
5
|
+
Build authentication, onboarding, compliance, setup, and gating flows with one engine:
|
|
6
|
+
- requirement-based routing
|
|
7
|
+
- dependency-aware graph evaluation
|
|
8
|
+
- headless hook + default UI
|
|
9
|
+
- adapter-driven backend integration
|
|
10
|
+
- persistence, validation, retry/backoff
|
|
11
|
+
|
|
12
|
+
## For AI agents
|
|
13
|
+
|
|
14
|
+
Use [AGENTS.md](AGENTS.md) for implementation debrief, architecture notes, and workflow guidance.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install @secmia/openui-flow
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Peer dependencies:
|
|
23
|
+
- `react` `^18 || ^19`
|
|
24
|
+
- `react-dom` `^18 || ^19`
|
|
25
|
+
|
|
26
|
+
## 3-minute quickstart (Next.js App Router safe)
|
|
27
|
+
|
|
28
|
+
> Important: in Next.js App Router, functions cannot be passed from Server Components to Client Components. Keep `AdaptiveFlow`, adapter functions, and handlers inside a Client Component.
|
|
29
|
+
|
|
30
|
+
```tsx
|
|
31
|
+
// app/flow-demo.tsx
|
|
32
|
+
'use client';
|
|
33
|
+
|
|
34
|
+
import {
|
|
35
|
+
AdaptiveFlow,
|
|
36
|
+
DefaultAppRequirements,
|
|
37
|
+
type AdaptiveFlowAdapter,
|
|
38
|
+
} from '@secmia/openui-flow';
|
|
39
|
+
|
|
40
|
+
type AppRequirement =
|
|
41
|
+
| (typeof DefaultAppRequirements)[keyof typeof DefaultAppRequirements]
|
|
42
|
+
| 'selected_plan';
|
|
43
|
+
|
|
44
|
+
const requirements: AppRequirement[] = [
|
|
45
|
+
DefaultAppRequirements.HAS_EMAIL,
|
|
46
|
+
DefaultAppRequirements.EMAIL_VERIFIED,
|
|
47
|
+
DefaultAppRequirements.HAS_PASSWORD,
|
|
48
|
+
DefaultAppRequirements.HAS_FIRST_NAME,
|
|
49
|
+
DefaultAppRequirements.HAS_LAST_NAME,
|
|
50
|
+
DefaultAppRequirements.HAS_JOB_TITLE,
|
|
51
|
+
DefaultAppRequirements.ACCEPTED_TOS,
|
|
52
|
+
'selected_plan',
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
const adapter: AdaptiveFlowAdapter = {
|
|
56
|
+
async lookupByEmail(email) {
|
|
57
|
+
const exists = email.endsWith('@company.com');
|
|
58
|
+
return {
|
|
59
|
+
accountExists: exists,
|
|
60
|
+
hasPassword: exists,
|
|
61
|
+
isVerified: false,
|
|
62
|
+
agreedToTos: false,
|
|
63
|
+
profile: { firstName: null, lastName: null, jobTitle: null },
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
async requestOtp(email) {
|
|
67
|
+
console.log('request otp', email);
|
|
68
|
+
},
|
|
69
|
+
async verifyOtp(email, code) {
|
|
70
|
+
console.log('verify otp', email, code);
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export default function FlowDemo() {
|
|
75
|
+
return (
|
|
76
|
+
<AdaptiveFlow
|
|
77
|
+
requirements={requirements}
|
|
78
|
+
adapter={adapter}
|
|
79
|
+
persistence={{ key: 'flow-state:v1', storage: 'session' }}
|
|
80
|
+
onComplete={(context) => {
|
|
81
|
+
console.log('flow complete', context);
|
|
82
|
+
}}
|
|
83
|
+
/>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
```tsx
|
|
89
|
+
// app/page.tsx (Server Component)
|
|
90
|
+
import FlowDemo from './flow-demo';
|
|
91
|
+
|
|
92
|
+
export default function Page() {
|
|
93
|
+
return <FlowDemo />;
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## What this package is
|
|
98
|
+
|
|
99
|
+
`@secmia/openui-flow` is a generic flow engine with a React-first API.
|
|
100
|
+
|
|
101
|
+
You define:
|
|
102
|
+
- requirements (conditions that must be met)
|
|
103
|
+
- resolvers (how each requirement is evaluated)
|
|
104
|
+
- graph metadata (priority, dependency, conditional activation)
|
|
105
|
+
|
|
106
|
+
The engine decides the next step; UI can be default, partially custom, or fully headless.
|
|
107
|
+
|
|
108
|
+
## Core model
|
|
109
|
+
|
|
110
|
+
- `Context`: current user/product/session state.
|
|
111
|
+
- `Requirement`: business condition (`has_email`, `accepted_tos`, etc.).
|
|
112
|
+
- `Resolver`: maps requirement to `isMet(context)` + fallback step.
|
|
113
|
+
- `RequirementGraph`: ordered nodes with `priority`, `dependsOn`, `when`.
|
|
114
|
+
|
|
115
|
+
Graph guarantees:
|
|
116
|
+
- cycle detection at graph creation
|
|
117
|
+
- dependency validation
|
|
118
|
+
- dependency-aware topological ordering
|
|
119
|
+
- deterministic tie-breaking
|
|
120
|
+
|
|
121
|
+
## API overview
|
|
122
|
+
|
|
123
|
+
Primary exports:
|
|
124
|
+
- `AdaptiveFlow`
|
|
125
|
+
- `useAdaptiveFlow`
|
|
126
|
+
- `createRequirementGraph`
|
|
127
|
+
- `createDefaultRequirementGraph`
|
|
128
|
+
- `evaluateNextStep`
|
|
129
|
+
- `getMissingRequirements`
|
|
130
|
+
- `defaultRequirements`
|
|
131
|
+
- `defaultRequirementResolvers`
|
|
132
|
+
- `initialContext`
|
|
133
|
+
- `DefaultAppRequirements`
|
|
134
|
+
- `DefaultAdaptiveSteps`
|
|
135
|
+
|
|
136
|
+
High-value types:
|
|
137
|
+
- `AdaptiveFlowProps`
|
|
138
|
+
- `AdaptiveFlowAdapter`
|
|
139
|
+
- `UseAdaptiveFlowOptions`
|
|
140
|
+
- `UseAdaptiveFlowResult`
|
|
141
|
+
- `AdaptiveFlowValidators`
|
|
142
|
+
- `AdaptiveFlowSchemas`
|
|
143
|
+
- `AdaptiveFlowPersistence`
|
|
144
|
+
- `AdaptiveFlowRetryPolicy`
|
|
145
|
+
- `AdaptiveFlowOAuthProvider`
|
|
146
|
+
- `RequirementGraph`
|
|
147
|
+
- `RequirementResolver`
|
|
148
|
+
|
|
149
|
+
## UI customization levels
|
|
150
|
+
|
|
151
|
+
### Level 1: style/class overrides
|
|
152
|
+
|
|
153
|
+
```tsx
|
|
154
|
+
<AdaptiveFlow
|
|
155
|
+
classNames={{
|
|
156
|
+
shell: 'rounded-xl border p-6',
|
|
157
|
+
title: 'text-xl font-semibold',
|
|
158
|
+
oauthButton: 'h-10 rounded border px-3',
|
|
159
|
+
}}
|
|
160
|
+
styles={{
|
|
161
|
+
shell: { maxWidth: 720 },
|
|
162
|
+
}}
|
|
163
|
+
/>
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Use `unstyled` to disable default inline styles.
|
|
167
|
+
|
|
168
|
+
### Level 2: per-step overrides (`stepRegistry`)
|
|
169
|
+
|
|
170
|
+
```tsx
|
|
171
|
+
<AdaptiveFlow
|
|
172
|
+
stepRegistry={{
|
|
173
|
+
SELECT_PLAN: ({ setContextPatch }) => (
|
|
174
|
+
<button onClick={() => setContextPatch({ selectedPlan: 'pro' })}>Select Pro</button>
|
|
175
|
+
),
|
|
176
|
+
}}
|
|
177
|
+
/>
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### Level 3: full step renderer (`renderStep`)
|
|
181
|
+
|
|
182
|
+
```tsx
|
|
183
|
+
<AdaptiveFlow
|
|
184
|
+
renderStep={({ step, defaultView, transitions }) => (
|
|
185
|
+
<section>
|
|
186
|
+
<h2>{step}</h2>
|
|
187
|
+
<p>Transitions: {transitions.length}</p>
|
|
188
|
+
{defaultView}
|
|
189
|
+
</section>
|
|
190
|
+
)}
|
|
191
|
+
/>
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### Level 4: fully headless (`useAdaptiveFlow`)
|
|
195
|
+
|
|
196
|
+
```tsx
|
|
197
|
+
'use client';
|
|
198
|
+
|
|
199
|
+
import { useAdaptiveFlow } from '@secmia/openui-flow';
|
|
200
|
+
|
|
201
|
+
export default function HeadlessFlow() {
|
|
202
|
+
const {
|
|
203
|
+
step,
|
|
204
|
+
busy,
|
|
205
|
+
fieldErrors,
|
|
206
|
+
handleEmail,
|
|
207
|
+
handleOtp,
|
|
208
|
+
setContextPatch,
|
|
209
|
+
} = useAdaptiveFlow({
|
|
210
|
+
requirements: ['has_email', 'email_verified', 'accepted_tos'],
|
|
211
|
+
completeStep: 'COMPLETE',
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
return (
|
|
215
|
+
<div>
|
|
216
|
+
<p>Step: {step}</p>
|
|
217
|
+
{fieldErrors.email ? <p>{fieldErrors.email}</p> : null}
|
|
218
|
+
<button disabled={busy} onClick={() => handleEmail('user@company.com')}>Submit Email</button>
|
|
219
|
+
<button disabled={busy} onClick={() => handleOtp('123456')}>Submit OTP</button>
|
|
220
|
+
<button onClick={() => setContextPatch({ agreedToTos: true })}>Accept TOS</button>
|
|
221
|
+
</div>
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
## Validation and schemas
|
|
227
|
+
|
|
228
|
+
Validators can return:
|
|
229
|
+
- `string`
|
|
230
|
+
- `{ field, message }`
|
|
231
|
+
- `void`
|
|
232
|
+
- async variants of the same
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
const validators = {
|
|
236
|
+
password: (value: string) => {
|
|
237
|
+
if (value.length < 12) {
|
|
238
|
+
return { field: 'password', message: 'Password must be at least 12 characters.' };
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Schema adapters are Zod-friendly (`safeParse` or `parse`):
|
|
245
|
+
|
|
246
|
+
```tsx
|
|
247
|
+
import { z } from 'zod';
|
|
248
|
+
|
|
249
|
+
<AdaptiveFlow
|
|
250
|
+
schemas={{
|
|
251
|
+
email: z.string().email(),
|
|
252
|
+
}}
|
|
253
|
+
/>
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
## Persistence
|
|
257
|
+
|
|
258
|
+
```tsx
|
|
259
|
+
<AdaptiveFlow
|
|
260
|
+
persistence={{
|
|
261
|
+
key: 'flow-state:v1',
|
|
262
|
+
storage: 'local',
|
|
263
|
+
clearOnComplete: true,
|
|
264
|
+
onError: (error, phase) => {
|
|
265
|
+
console.error('persistence', phase, error);
|
|
266
|
+
},
|
|
267
|
+
}}
|
|
268
|
+
/>
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
## Retry/backoff (adapter calls)
|
|
272
|
+
|
|
273
|
+
```tsx
|
|
274
|
+
<AdaptiveFlow
|
|
275
|
+
retryPolicy={{
|
|
276
|
+
maxAttempts: 4,
|
|
277
|
+
initialDelayMs: 200,
|
|
278
|
+
factor: 2,
|
|
279
|
+
maxDelayMs: 2000,
|
|
280
|
+
jitter: true,
|
|
281
|
+
shouldRetry: (error) => error.name !== 'FlowValidationError',
|
|
282
|
+
}}
|
|
283
|
+
/>
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
Notes:
|
|
287
|
+
- retries apply to adapter calls (`lookupByEmail`, `requestOtp`, `verifyOtp`, etc.)
|
|
288
|
+
- validation errors are not retried unless your `shouldRetry` allows them
|
|
289
|
+
|
|
290
|
+
## OAuth
|
|
291
|
+
|
|
292
|
+
- Default footer shows Google and Apple.
|
|
293
|
+
- Override provider buttons with `oauthProviders`.
|
|
294
|
+
- Redirect URL is owned by your adapter�s `startOAuth` implementation.
|
|
295
|
+
|
|
296
|
+
```tsx
|
|
297
|
+
<AdaptiveFlow
|
|
298
|
+
oauthProviders={[
|
|
299
|
+
{ id: 'google', label: 'Continue with Google' },
|
|
300
|
+
{ id: 'github', label: 'Continue with GitHub' },
|
|
301
|
+
]}
|
|
302
|
+
/>
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
```ts
|
|
306
|
+
const adapter = {
|
|
307
|
+
async startOAuth(provider: string) {
|
|
308
|
+
window.location.href = `/api/identity/${provider}?redirectTo=${encodeURIComponent(`${window.location.origin}/auth/callback`)}`;
|
|
309
|
+
},
|
|
310
|
+
async completeOAuth() {
|
|
311
|
+
return { isVerified: true };
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
## SSR and hydration
|
|
317
|
+
|
|
318
|
+
- Storage reads/writes run only in the browser.
|
|
319
|
+
- On SSR, flow starts with defaults/`initialValue` and hydrates client state on mount.
|
|
320
|
+
- For minimal UI flash, seed `initialValue` from server-known session hints.
|
|
321
|
+
|
|
322
|
+
## Troubleshooting
|
|
323
|
+
|
|
324
|
+
- Next.js "Functions cannot be passed to Client Components": keep adapter and handlers in a Client Component.
|
|
325
|
+
- Step not appearing: verify `priority`, `dependsOn`, and `when`.
|
|
326
|
+
- State reset: check `persistence.key` and storage mode.
|
|
327
|
+
- OAuth not resuming: implement `completeOAuth` and enable persistence.
|
|
328
|
+
|
|
329
|
+
## Release checklist
|
|
330
|
+
|
|
331
|
+
- `npm run typecheck`
|
|
332
|
+
- `npm run build`
|
|
333
|
+
- Verify adapter errors are user-safe and actionable
|
|
334
|
+
- Use versioned persistence keys (for example `flow-state:v1`)
|
|
335
|
+
|
|
336
|
+
## License
|
|
337
|
+
|
|
338
|
+
MIT
|