@secmia/openui-flow 3.0.0
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 +804 -0
- package/dist/index.d.mts +178 -0
- package/dist/index.d.ts +178 -0
- package/dist/index.js +1118 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1073 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +39 -0
package/README.md
ADDED
|
@@ -0,0 +1,804 @@
|
|
|
1
|
+
# @secmia/openui-flow
|
|
2
|
+
|
|
3
|
+
Production-ready adaptive workflow and onboarding flow engine for React.
|
|
4
|
+
|
|
5
|
+
If you are evaluating quickly, you can get first success in under 3 minutes.
|
|
6
|
+
|
|
7
|
+
## 3-minute onboarding path
|
|
8
|
+
|
|
9
|
+
1. Install package.
|
|
10
|
+
2. Paste the starter component.
|
|
11
|
+
3. Confirm `onComplete` fires.
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install @secmia/openui-flow
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
```tsx
|
|
18
|
+
'use client';
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
AdaptiveFlow,
|
|
22
|
+
DefaultAppRequirements,
|
|
23
|
+
type AdaptiveFlowAdapter,
|
|
24
|
+
} from '@secmia/openui-flow';
|
|
25
|
+
|
|
26
|
+
type AppRequirement =
|
|
27
|
+
| (typeof DefaultAppRequirements)[keyof typeof DefaultAppRequirements]
|
|
28
|
+
| 'selected_plan';
|
|
29
|
+
|
|
30
|
+
const requirements: AppRequirement[] = [
|
|
31
|
+
DefaultAppRequirements.HAS_EMAIL,
|
|
32
|
+
DefaultAppRequirements.EMAIL_VERIFIED,
|
|
33
|
+
DefaultAppRequirements.HAS_PASSWORD,
|
|
34
|
+
DefaultAppRequirements.HAS_FIRST_NAME,
|
|
35
|
+
DefaultAppRequirements.ACCEPTED_TOS,
|
|
36
|
+
'selected_plan',
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const adapter: AdaptiveFlowAdapter = {
|
|
40
|
+
async lookupByEmail(email) {
|
|
41
|
+
const exists = email.endsWith('@company.com');
|
|
42
|
+
return {
|
|
43
|
+
accountExists: exists,
|
|
44
|
+
hasPassword: exists,
|
|
45
|
+
isVerified: false,
|
|
46
|
+
agreedToTos: false,
|
|
47
|
+
profile: { firstName: null, lastName: null, jobTitle: null },
|
|
48
|
+
};
|
|
49
|
+
},
|
|
50
|
+
async requestOtp(email) {
|
|
51
|
+
console.log('request otp', email);
|
|
52
|
+
},
|
|
53
|
+
async verifyOtp(email, code) {
|
|
54
|
+
console.log('verify otp', email, code);
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export default function FlowPage() {
|
|
59
|
+
return (
|
|
60
|
+
<AdaptiveFlow
|
|
61
|
+
requirements={requirements}
|
|
62
|
+
adapter={adapter}
|
|
63
|
+
persistence={{ key: 'flow-state:v1', storage: 'session' }}
|
|
64
|
+
onComplete={(context) => {
|
|
65
|
+
console.log('flow complete', context);
|
|
66
|
+
}}
|
|
67
|
+
/>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Expected result:
|
|
73
|
+
- User progresses through required steps.
|
|
74
|
+
- Flow ends at complete state.
|
|
75
|
+
- `onComplete` receives final context.
|
|
76
|
+
|
|
77
|
+
Peer dependencies:
|
|
78
|
+
- `react` `^18 || ^19`
|
|
79
|
+
- `react-dom` `^18 || ^19`
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## Quick answers
|
|
84
|
+
|
|
85
|
+
Can users add their own flows?
|
|
86
|
+
- Yes. This is first-class.
|
|
87
|
+
- Define your own requirements, graph, step keys, resolver logic, and UI rendering.
|
|
88
|
+
|
|
89
|
+
When should I use requirements vs requirementGraph?
|
|
90
|
+
- Use `requirements` for the fastest start.
|
|
91
|
+
- Use `requirementGraph` when you need priorities, conditions, or dependencies.
|
|
92
|
+
|
|
93
|
+
How do I keep onboarding logic local to my app?
|
|
94
|
+
- Create an app-specific requirement union near your flow module.
|
|
95
|
+
- Include defaults and your custom requirements in one list.
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## First customization: local requirement contract
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
import { DefaultAppRequirements } from '@secmia/openui-flow';
|
|
103
|
+
|
|
104
|
+
type DefaultRequirement =
|
|
105
|
+
(typeof DefaultAppRequirements)[keyof typeof DefaultAppRequirements];
|
|
106
|
+
|
|
107
|
+
type ProductRequirement =
|
|
108
|
+
| DefaultRequirement
|
|
109
|
+
| 'selected_plan'
|
|
110
|
+
| 'connected_slack'
|
|
111
|
+
| 'has_workspace_name';
|
|
112
|
+
|
|
113
|
+
export const requirements: ProductRequirement[] = [
|
|
114
|
+
DefaultAppRequirements.HAS_EMAIL,
|
|
115
|
+
DefaultAppRequirements.EMAIL_VERIFIED,
|
|
116
|
+
DefaultAppRequirements.HAS_PASSWORD,
|
|
117
|
+
DefaultAppRequirements.HAS_FIRST_NAME,
|
|
118
|
+
DefaultAppRequirements.ACCEPTED_TOS,
|
|
119
|
+
'selected_plan',
|
|
120
|
+
'connected_slack',
|
|
121
|
+
];
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## Next level: graph mode
|
|
127
|
+
|
|
128
|
+
Use this when you need priority routing, conditional activation, dependency gates, or async resolvers.
|
|
129
|
+
|
|
130
|
+
```tsx
|
|
131
|
+
import {
|
|
132
|
+
AdaptiveFlow,
|
|
133
|
+
DefaultAdaptiveSteps,
|
|
134
|
+
createRequirementGraph,
|
|
135
|
+
type AdaptiveContextBase,
|
|
136
|
+
type RequirementResolver,
|
|
137
|
+
} from '@secmia/openui-flow';
|
|
138
|
+
|
|
139
|
+
type AppContext = AdaptiveContextBase & {
|
|
140
|
+
riskLevel: 'low' | 'high';
|
|
141
|
+
useSso: boolean;
|
|
142
|
+
selectedPlan: 'starter' | 'pro' | null;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
type Req =
|
|
146
|
+
| 'has_email'
|
|
147
|
+
| 'email_verified'
|
|
148
|
+
| 'has_password'
|
|
149
|
+
| 'accepted_tos'
|
|
150
|
+
| 'selected_plan';
|
|
151
|
+
|
|
152
|
+
type Step =
|
|
153
|
+
| 'COLLECT_EMAIL'
|
|
154
|
+
| 'VERIFY_OTP'
|
|
155
|
+
| 'COLLECT_PASSWORD'
|
|
156
|
+
| 'COLLECT_TOS'
|
|
157
|
+
| 'SELECT_PLAN'
|
|
158
|
+
| 'COMPLETE';
|
|
159
|
+
|
|
160
|
+
const requirements: Req[] = [
|
|
161
|
+
'has_email',
|
|
162
|
+
'email_verified',
|
|
163
|
+
'has_password',
|
|
164
|
+
'accepted_tos',
|
|
165
|
+
'selected_plan',
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
const resolvers: Partial<Record<Req, RequirementResolver<AppContext, Step>>> = {
|
|
169
|
+
has_email: { step: 'COLLECT_EMAIL', isMet: (ctx) => Boolean(ctx.email) },
|
|
170
|
+
email_verified: { step: 'VERIFY_OTP', isMet: async (ctx) => ctx.isVerified },
|
|
171
|
+
has_password: { step: 'COLLECT_PASSWORD', isMet: (ctx) => ctx.hasPassword },
|
|
172
|
+
accepted_tos: { step: 'COLLECT_TOS', isMet: (ctx) => ctx.agreedToTos },
|
|
173
|
+
selected_plan: { step: 'SELECT_PLAN', isMet: (ctx) => Boolean(ctx.selectedPlan) },
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const graph = createRequirementGraph(requirements, resolvers, {
|
|
177
|
+
priorities: {
|
|
178
|
+
email_verified: 20,
|
|
179
|
+
has_password: 10,
|
|
180
|
+
selected_plan: 5,
|
|
181
|
+
},
|
|
182
|
+
conditions: {
|
|
183
|
+
has_password: (ctx) => !ctx.useSso,
|
|
184
|
+
email_verified: (ctx) => ctx.riskLevel === 'high',
|
|
185
|
+
},
|
|
186
|
+
dependencies: {
|
|
187
|
+
selected_plan: ['accepted_tos'],
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
<AdaptiveFlow<AppContext, Req, Step>
|
|
192
|
+
requirementGraph={graph}
|
|
193
|
+
completeStep={DefaultAdaptiveSteps.COMPLETE}
|
|
194
|
+
/>;
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
## Advanced: maximum configuration
|
|
200
|
+
|
|
201
|
+
This demonstrates almost every major capability in one setup.
|
|
202
|
+
|
|
203
|
+
```tsx
|
|
204
|
+
'use client';
|
|
205
|
+
|
|
206
|
+
import {
|
|
207
|
+
AdaptiveFlow,
|
|
208
|
+
DefaultAdaptiveSteps,
|
|
209
|
+
createDefaultRequirementGraph,
|
|
210
|
+
type AdaptiveContextBase,
|
|
211
|
+
type AdaptiveFlowAdapter,
|
|
212
|
+
type AdaptiveFlowValidators,
|
|
213
|
+
type RequirementResolver,
|
|
214
|
+
} from '@secmia/openui-flow';
|
|
215
|
+
|
|
216
|
+
type ProductContext = AdaptiveContextBase & {
|
|
217
|
+
selectedPlan: 'starter' | 'pro' | null;
|
|
218
|
+
workspaceName: string | null;
|
|
219
|
+
riskScore: number;
|
|
220
|
+
useSso: boolean;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
type ProductRequirement =
|
|
224
|
+
| 'has_email'
|
|
225
|
+
| 'email_verified'
|
|
226
|
+
| 'has_password'
|
|
227
|
+
| 'has_first_name'
|
|
228
|
+
| 'accepted_tos'
|
|
229
|
+
| 'selected_plan'
|
|
230
|
+
| 'has_workspace_name';
|
|
231
|
+
|
|
232
|
+
type ProductStep =
|
|
233
|
+
| 'COLLECT_EMAIL'
|
|
234
|
+
| 'VERIFY_OTP'
|
|
235
|
+
| 'COLLECT_PASSWORD'
|
|
236
|
+
| 'COLLECT_PROFILE'
|
|
237
|
+
| 'COLLECT_TOS'
|
|
238
|
+
| 'SELECT_PLAN'
|
|
239
|
+
| 'COLLECT_WORKSPACE'
|
|
240
|
+
| 'COMPLETE';
|
|
241
|
+
|
|
242
|
+
const resolvers: Partial<Record<ProductRequirement, RequirementResolver<ProductContext, ProductStep>>> = {
|
|
243
|
+
selected_plan: {
|
|
244
|
+
step: 'SELECT_PLAN',
|
|
245
|
+
isMet: (ctx) => Boolean(ctx.selectedPlan),
|
|
246
|
+
},
|
|
247
|
+
has_workspace_name: {
|
|
248
|
+
step: 'COLLECT_WORKSPACE',
|
|
249
|
+
isMet: (ctx) => Boolean(ctx.workspaceName),
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const graph = createDefaultRequirementGraph<ProductContext, ProductRequirement, ProductStep>({
|
|
254
|
+
requirements: [
|
|
255
|
+
'has_email',
|
|
256
|
+
'email_verified',
|
|
257
|
+
'has_password',
|
|
258
|
+
'has_first_name',
|
|
259
|
+
'accepted_tos',
|
|
260
|
+
'selected_plan',
|
|
261
|
+
'has_workspace_name',
|
|
262
|
+
],
|
|
263
|
+
resolvers,
|
|
264
|
+
priorities: {
|
|
265
|
+
email_verified: 50,
|
|
266
|
+
has_password: 40,
|
|
267
|
+
selected_plan: 30,
|
|
268
|
+
},
|
|
269
|
+
conditions: {
|
|
270
|
+
has_password: (ctx) => !ctx.useSso,
|
|
271
|
+
email_verified: (ctx) => ctx.riskScore >= 70,
|
|
272
|
+
},
|
|
273
|
+
dependencies: {
|
|
274
|
+
has_workspace_name: ['selected_plan'],
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const adapter: AdaptiveFlowAdapter<ProductContext> = {
|
|
279
|
+
async lookupByEmail(email) {
|
|
280
|
+
const response = await fetch('/api/identity/lookup', {
|
|
281
|
+
method: 'POST',
|
|
282
|
+
headers: { 'content-type': 'application/json' },
|
|
283
|
+
body: JSON.stringify({ email }),
|
|
284
|
+
});
|
|
285
|
+
if (!response.ok) {
|
|
286
|
+
throw new Error('Lookup failed.');
|
|
287
|
+
}
|
|
288
|
+
return response.json();
|
|
289
|
+
},
|
|
290
|
+
async requestOtp(email) {
|
|
291
|
+
await fetch('/api/identity/request-otp', {
|
|
292
|
+
method: 'POST',
|
|
293
|
+
headers: { 'content-type': 'application/json' },
|
|
294
|
+
body: JSON.stringify({ email }),
|
|
295
|
+
});
|
|
296
|
+
},
|
|
297
|
+
async verifyOtp(email, code) {
|
|
298
|
+
await fetch('/api/identity/verify-otp', {
|
|
299
|
+
method: 'POST',
|
|
300
|
+
headers: { 'content-type': 'application/json' },
|
|
301
|
+
body: JSON.stringify({ email, code }),
|
|
302
|
+
});
|
|
303
|
+
},
|
|
304
|
+
async saveProfile(context) {
|
|
305
|
+
await fetch('/api/profile', {
|
|
306
|
+
method: 'PATCH',
|
|
307
|
+
headers: { 'content-type': 'application/json' },
|
|
308
|
+
body: JSON.stringify(context.profile),
|
|
309
|
+
});
|
|
310
|
+
},
|
|
311
|
+
async acceptTos() {
|
|
312
|
+
await fetch('/api/legal/accept', { method: 'POST' });
|
|
313
|
+
},
|
|
314
|
+
async startOAuth(provider) {
|
|
315
|
+
window.location.href = `/api/identity/${provider}`;
|
|
316
|
+
},
|
|
317
|
+
async completeOAuth() {
|
|
318
|
+
return {
|
|
319
|
+
isVerified: true,
|
|
320
|
+
hasPassword: true,
|
|
321
|
+
};
|
|
322
|
+
},
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const validators: AdaptiveFlowValidators<ProductContext> = {
|
|
326
|
+
email: (value) => {
|
|
327
|
+
if (!value.endsWith('@company.com')) {
|
|
328
|
+
return 'Company email is required.';
|
|
329
|
+
}
|
|
330
|
+
},
|
|
331
|
+
password: (value, { hasPassword }) => {
|
|
332
|
+
if (!hasPassword && value.length < 12) {
|
|
333
|
+
return 'Password must be at least 12 characters.';
|
|
334
|
+
}
|
|
335
|
+
},
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
export default function EnterpriseFlow() {
|
|
339
|
+
return (
|
|
340
|
+
<AdaptiveFlow<ProductContext, ProductRequirement, ProductStep>
|
|
341
|
+
requirementGraph={graph}
|
|
342
|
+
completeStep={DefaultAdaptiveSteps.COMPLETE}
|
|
343
|
+
adapter={adapter}
|
|
344
|
+
validators={validators}
|
|
345
|
+
persistence={{
|
|
346
|
+
key: 'enterprise-flow-state:v1',
|
|
347
|
+
storage: 'session',
|
|
348
|
+
clearOnComplete: true,
|
|
349
|
+
}}
|
|
350
|
+
stepTitles={{
|
|
351
|
+
SELECT_PLAN: 'Choose your plan',
|
|
352
|
+
COLLECT_WORKSPACE: 'Name your workspace',
|
|
353
|
+
}}
|
|
354
|
+
stepRegistry={{
|
|
355
|
+
SELECT_PLAN: ({ setContextPatch }) => (
|
|
356
|
+
<button onClick={() => setContextPatch({ selectedPlan: 'pro' })}>Select Pro Plan</button>
|
|
357
|
+
),
|
|
358
|
+
COLLECT_WORKSPACE: ({ setContextPatch }) => (
|
|
359
|
+
<button onClick={() => setContextPatch({ workspaceName: 'Acme HQ' })}>Set Workspace</button>
|
|
360
|
+
),
|
|
361
|
+
}}
|
|
362
|
+
renderStep={({ defaultView, transitions }) => (
|
|
363
|
+
<div>
|
|
364
|
+
<p>Transitions so far: {transitions.length}</p>
|
|
365
|
+
{defaultView}
|
|
366
|
+
</div>
|
|
367
|
+
)}
|
|
368
|
+
onStepTransition={(transition) => {
|
|
369
|
+
console.log('transition', transition);
|
|
370
|
+
}}
|
|
371
|
+
onError={(error) => {
|
|
372
|
+
console.error('flow error', error);
|
|
373
|
+
}}
|
|
374
|
+
onComplete={(context) => {
|
|
375
|
+
console.log('complete context', context);
|
|
376
|
+
}}
|
|
377
|
+
classNames={{
|
|
378
|
+
shell: 'rounded-xl border p-6 bg-white',
|
|
379
|
+
title: 'text-xl font-semibold',
|
|
380
|
+
}}
|
|
381
|
+
styles={{
|
|
382
|
+
shell: { maxWidth: 720 },
|
|
383
|
+
}}
|
|
384
|
+
unstyled={false}
|
|
385
|
+
initialValue={{
|
|
386
|
+
selectedPlan: null,
|
|
387
|
+
workspaceName: null,
|
|
388
|
+
riskScore: 80,
|
|
389
|
+
useSso: false,
|
|
390
|
+
}}
|
|
391
|
+
/>
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
---
|
|
397
|
+
|
|
398
|
+
## Custom UI: full control options
|
|
399
|
+
|
|
400
|
+
You have 4 UI customization levels, from quick theming to complete headless control.
|
|
401
|
+
|
|
402
|
+
### Level 1: style and class overrides
|
|
403
|
+
|
|
404
|
+
Keep built-in UI structure, but override styles/classes for all slots.
|
|
405
|
+
|
|
406
|
+
```tsx
|
|
407
|
+
<AdaptiveFlow
|
|
408
|
+
classNames={{
|
|
409
|
+
shell: 'rounded-2xl border border-zinc-200 p-8',
|
|
410
|
+
title: 'text-2xl font-bold tracking-tight',
|
|
411
|
+
oauthButton: 'h-11 rounded-lg border px-4',
|
|
412
|
+
error: 'text-sm text-red-700 bg-red-50 rounded p-3',
|
|
413
|
+
}}
|
|
414
|
+
styles={{
|
|
415
|
+
shell: { maxWidth: 680, margin: '0 auto' },
|
|
416
|
+
title: { letterSpacing: '-0.01em' },
|
|
417
|
+
}}
|
|
418
|
+
/>
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
Use `unstyled` to disable built-in inline defaults.
|
|
422
|
+
|
|
423
|
+
```tsx
|
|
424
|
+
<AdaptiveFlow unstyled classNames={{ shell: 'my-own-layout-root' }} />
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
### Level 2: custom UI for specific steps
|
|
428
|
+
|
|
429
|
+
Use `stepRegistry` to replace selected steps with your own components while leaving other steps on defaults.
|
|
430
|
+
|
|
431
|
+
```tsx
|
|
432
|
+
<AdaptiveFlow
|
|
433
|
+
stepRegistry={{
|
|
434
|
+
SELECT_PLAN: ({ setContextPatch, run }) => (
|
|
435
|
+
<div>
|
|
436
|
+
<h3>Choose a plan</h3>
|
|
437
|
+
<button
|
|
438
|
+
onClick={() => {
|
|
439
|
+
void run(async () => {
|
|
440
|
+
setContextPatch({ selectedPlan: 'starter' });
|
|
441
|
+
});
|
|
442
|
+
}}
|
|
443
|
+
>
|
|
444
|
+
Starter
|
|
445
|
+
</button>
|
|
446
|
+
<button
|
|
447
|
+
onClick={() => {
|
|
448
|
+
void run(async () => {
|
|
449
|
+
setContextPatch({ selectedPlan: 'pro' });
|
|
450
|
+
});
|
|
451
|
+
}}
|
|
452
|
+
>
|
|
453
|
+
Pro
|
|
454
|
+
</button>
|
|
455
|
+
</div>
|
|
456
|
+
),
|
|
457
|
+
}}
|
|
458
|
+
/>
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
### Level 3: custom renderer for all steps
|
|
462
|
+
|
|
463
|
+
Use `renderStep` for full step-body ownership. You can still reuse `defaultView` when useful.
|
|
464
|
+
|
|
465
|
+
```tsx
|
|
466
|
+
<AdaptiveFlow
|
|
467
|
+
renderStep={({ step, defaultView, transitions, context, setContextPatch }) => (
|
|
468
|
+
<section>
|
|
469
|
+
<header>
|
|
470
|
+
<h2>Current step: {step}</h2>
|
|
471
|
+
<p>Transitions: {transitions.length}</p>
|
|
472
|
+
</header>
|
|
473
|
+
|
|
474
|
+
<aside>
|
|
475
|
+
<button onClick={() => setContextPatch({ locale: 'en-US' } as Partial<typeof context>)}>
|
|
476
|
+
Set locale
|
|
477
|
+
</button>
|
|
478
|
+
</aside>
|
|
479
|
+
|
|
480
|
+
<main>{defaultView}</main>
|
|
481
|
+
</section>
|
|
482
|
+
)}
|
|
483
|
+
/>
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
### Level 4: fully custom UI for everything (headless mode)
|
|
487
|
+
|
|
488
|
+
If you want total control over shell, header, footer, button layout, and every interaction, use engine APIs directly.
|
|
489
|
+
|
|
490
|
+
```tsx
|
|
491
|
+
'use client';
|
|
492
|
+
|
|
493
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
494
|
+
import {
|
|
495
|
+
createRequirementGraph,
|
|
496
|
+
evaluateNextStep,
|
|
497
|
+
getMissingRequirements,
|
|
498
|
+
DefaultAdaptiveSteps,
|
|
499
|
+
type AdaptiveContextBase,
|
|
500
|
+
type RequirementResolver,
|
|
501
|
+
} from '@secmia/openui-flow';
|
|
502
|
+
|
|
503
|
+
type Ctx = AdaptiveContextBase & {
|
|
504
|
+
selectedPlan: 'starter' | 'pro' | null;
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
type Req = 'has_email' | 'email_verified' | 'selected_plan';
|
|
508
|
+
type Step = 'COLLECT_EMAIL' | 'VERIFY_OTP' | 'SELECT_PLAN' | 'COMPLETE';
|
|
509
|
+
|
|
510
|
+
const resolvers: Partial<Record<Req, RequirementResolver<Ctx, Step>>> = {
|
|
511
|
+
has_email: { step: 'COLLECT_EMAIL', isMet: (ctx) => Boolean(ctx.email) },
|
|
512
|
+
email_verified: { step: 'VERIFY_OTP', isMet: (ctx) => ctx.isVerified },
|
|
513
|
+
selected_plan: { step: 'SELECT_PLAN', isMet: (ctx) => Boolean(ctx.selectedPlan) },
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
export default function FullyCustomAuth() {
|
|
517
|
+
const [context, setContext] = useState<Ctx>({
|
|
518
|
+
email: null,
|
|
519
|
+
isVerified: false,
|
|
520
|
+
hasPassword: false,
|
|
521
|
+
agreedToTos: false,
|
|
522
|
+
profile: null,
|
|
523
|
+
selectedPlan: null,
|
|
524
|
+
});
|
|
525
|
+
const [step, setStep] = useState<Step>('COLLECT_EMAIL');
|
|
526
|
+
const [missing, setMissing] = useState<Req[]>([]);
|
|
527
|
+
|
|
528
|
+
const graph = useMemo(
|
|
529
|
+
() => createRequirementGraph<Ctx, Req, Step>(['has_email', 'email_verified', 'selected_plan'], resolvers),
|
|
530
|
+
[],
|
|
531
|
+
);
|
|
532
|
+
|
|
533
|
+
useEffect(() => {
|
|
534
|
+
void (async () => {
|
|
535
|
+
const [nextStep, missingReqs] = await Promise.all([
|
|
536
|
+
evaluateNextStep(context, graph, DefaultAdaptiveSteps.COMPLETE as Step),
|
|
537
|
+
getMissingRequirements(context, graph),
|
|
538
|
+
]);
|
|
539
|
+
setStep(nextStep);
|
|
540
|
+
setMissing(missingReqs);
|
|
541
|
+
})();
|
|
542
|
+
}, [context, graph]);
|
|
543
|
+
|
|
544
|
+
return (
|
|
545
|
+
<div>
|
|
546
|
+
<header>
|
|
547
|
+
<h1>My Brand Auth</h1>
|
|
548
|
+
<p>
|
|
549
|
+
Progress: {3 - missing.length}/3
|
|
550
|
+
</p>
|
|
551
|
+
</header>
|
|
552
|
+
|
|
553
|
+
{step === 'COLLECT_EMAIL' ? (
|
|
554
|
+
<button onClick={() => setContext((prev) => ({ ...prev, email: 'user@company.com' }))}>
|
|
555
|
+
Mock email submit
|
|
556
|
+
</button>
|
|
557
|
+
) : null}
|
|
558
|
+
|
|
559
|
+
{step === 'VERIFY_OTP' ? (
|
|
560
|
+
<button onClick={() => setContext((prev) => ({ ...prev, isVerified: true }))}>
|
|
561
|
+
Mock verify
|
|
562
|
+
</button>
|
|
563
|
+
) : null}
|
|
564
|
+
|
|
565
|
+
{step === 'SELECT_PLAN' ? (
|
|
566
|
+
<button onClick={() => setContext((prev) => ({ ...prev, selectedPlan: 'pro' }))}>
|
|
567
|
+
Choose Pro
|
|
568
|
+
</button>
|
|
569
|
+
) : null}
|
|
570
|
+
|
|
571
|
+
{step === 'COMPLETE' ? <p>All done.</p> : null}
|
|
572
|
+
</div>
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
Precedence rule inside `AdaptiveFlow`:
|
|
578
|
+
- `renderStep` wins first.
|
|
579
|
+
- If `renderStep` is not provided, `stepRegistry[step]` is used.
|
|
580
|
+
- If neither is provided, built-in default step UI is rendered.
|
|
581
|
+
|
|
582
|
+
This gives you a clean path from quick defaults to complete custom UI ownership.
|
|
583
|
+
|
|
584
|
+
---
|
|
585
|
+
|
|
586
|
+
## Integration recipes
|
|
587
|
+
|
|
588
|
+
### Next.js App Router
|
|
589
|
+
|
|
590
|
+
```tsx
|
|
591
|
+
'use client';
|
|
592
|
+
|
|
593
|
+
import { useRouter } from 'next/navigation';
|
|
594
|
+
import {
|
|
595
|
+
AdaptiveFlow,
|
|
596
|
+
DefaultAppRequirements,
|
|
597
|
+
type AdaptiveFlowAdapter,
|
|
598
|
+
} from '@secmia/openui-flow';
|
|
599
|
+
|
|
600
|
+
type AppRequirement =
|
|
601
|
+
| (typeof DefaultAppRequirements)[keyof typeof DefaultAppRequirements]
|
|
602
|
+
| 'has_workspace_name';
|
|
603
|
+
|
|
604
|
+
const requirements: AppRequirement[] = [
|
|
605
|
+
DefaultAppRequirements.HAS_EMAIL,
|
|
606
|
+
DefaultAppRequirements.EMAIL_VERIFIED,
|
|
607
|
+
DefaultAppRequirements.HAS_PASSWORD,
|
|
608
|
+
DefaultAppRequirements.HAS_FIRST_NAME,
|
|
609
|
+
DefaultAppRequirements.ACCEPTED_TOS,
|
|
610
|
+
];
|
|
611
|
+
|
|
612
|
+
const adapter: AdaptiveFlowAdapter = {
|
|
613
|
+
async lookupByEmail(email) {
|
|
614
|
+
const response = await fetch('/api/identity/lookup', {
|
|
615
|
+
method: 'POST',
|
|
616
|
+
headers: { 'content-type': 'application/json' },
|
|
617
|
+
body: JSON.stringify({ email }),
|
|
618
|
+
});
|
|
619
|
+
if (!response.ok) {
|
|
620
|
+
throw new Error('Lookup failed.');
|
|
621
|
+
}
|
|
622
|
+
return response.json();
|
|
623
|
+
},
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
export default function SignInClient() {
|
|
627
|
+
const router = useRouter();
|
|
628
|
+
|
|
629
|
+
return (
|
|
630
|
+
<AdaptiveFlow
|
|
631
|
+
adapter={adapter}
|
|
632
|
+
requirements={requirements}
|
|
633
|
+
persistence={{ key: 'next-flow-state:v1', storage: 'session' }}
|
|
634
|
+
onComplete={() => router.replace('/dashboard')}
|
|
635
|
+
/>
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
### Supabase
|
|
641
|
+
|
|
642
|
+
```tsx
|
|
643
|
+
'use client';
|
|
644
|
+
|
|
645
|
+
import { createClient } from '@supabase/supabase-js';
|
|
646
|
+
import { AdaptiveFlow, type AdaptiveFlowAdapter } from '@secmia/openui-flow';
|
|
647
|
+
|
|
648
|
+
const supabase = createClient(
|
|
649
|
+
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
650
|
+
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
|
651
|
+
);
|
|
652
|
+
|
|
653
|
+
const adapter: AdaptiveFlowAdapter = {
|
|
654
|
+
async requestOtp(email) {
|
|
655
|
+
const { error } = await supabase.auth.signInWithOtp({ email });
|
|
656
|
+
if (error) {
|
|
657
|
+
throw new Error(error.message);
|
|
658
|
+
}
|
|
659
|
+
},
|
|
660
|
+
async verifyOtp(email, code) {
|
|
661
|
+
const { error } = await supabase.auth.verifyOtp({ email, token: code, type: 'email' });
|
|
662
|
+
if (error) {
|
|
663
|
+
throw new Error(error.message);
|
|
664
|
+
}
|
|
665
|
+
},
|
|
666
|
+
async startOAuth(provider) {
|
|
667
|
+
const { error } = await supabase.auth.signInWithOAuth({
|
|
668
|
+
provider,
|
|
669
|
+
options: { redirectTo: `${window.location.origin}/auth/callback` },
|
|
670
|
+
});
|
|
671
|
+
if (error) {
|
|
672
|
+
throw new Error(error.message);
|
|
673
|
+
}
|
|
674
|
+
},
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
export default function SupabaseAuth() {
|
|
678
|
+
return <AdaptiveFlow adapter={adapter} persistence={{ key: 'supabase-auth:v1' }} />;
|
|
679
|
+
}
|
|
680
|
+
```
|
|
681
|
+
|
|
682
|
+
### Firebase
|
|
683
|
+
|
|
684
|
+
```tsx
|
|
685
|
+
'use client';
|
|
686
|
+
|
|
687
|
+
import { getAuth, GoogleAuthProvider, OAuthProvider, signInWithPopup } from 'firebase/auth';
|
|
688
|
+
import { AdaptiveFlow, type AdaptiveFlowAdapter } from '@secmia/openui-flow';
|
|
689
|
+
import { app } from './firebase';
|
|
690
|
+
|
|
691
|
+
const auth = getAuth(app);
|
|
692
|
+
|
|
693
|
+
const adapter: AdaptiveFlowAdapter = {
|
|
694
|
+
async startOAuth(provider) {
|
|
695
|
+
if (provider === 'google') {
|
|
696
|
+
await signInWithPopup(auth, new GoogleAuthProvider());
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
await signInWithPopup(auth, new OAuthProvider('apple.com'));
|
|
700
|
+
},
|
|
701
|
+
};
|
|
702
|
+
|
|
703
|
+
export default function FirebaseAuth() {
|
|
704
|
+
return <AdaptiveFlow adapter={adapter} persistence={{ key: 'firebase-auth:v1' }} />;
|
|
705
|
+
}
|
|
706
|
+
```
|
|
707
|
+
|
|
708
|
+
---
|
|
709
|
+
|
|
710
|
+
## Engine-only usage
|
|
711
|
+
|
|
712
|
+
```ts
|
|
713
|
+
import {
|
|
714
|
+
createRequirementGraph,
|
|
715
|
+
evaluateNextStep,
|
|
716
|
+
getMissingRequirements,
|
|
717
|
+
DefaultAdaptiveSteps,
|
|
718
|
+
} from '@secmia/openui-flow';
|
|
719
|
+
|
|
720
|
+
const nextStep = await evaluateNextStep(context, graph, DefaultAdaptiveSteps.COMPLETE);
|
|
721
|
+
const missing = await getMissingRequirements(context, graph);
|
|
722
|
+
```
|
|
723
|
+
|
|
724
|
+
---
|
|
725
|
+
|
|
726
|
+
## Core concepts
|
|
727
|
+
|
|
728
|
+
- Context: current auth and profile state.
|
|
729
|
+
- Requirement: business condition that must be satisfied.
|
|
730
|
+
- Resolver: checker function for one requirement.
|
|
731
|
+
- Step: current UI state for unmet requirement.
|
|
732
|
+
- RequirementGraph: graph nodes with optional priority, condition, and dependency metadata.
|
|
733
|
+
|
|
734
|
+
Evaluation loop:
|
|
735
|
+
1. Build graph.
|
|
736
|
+
2. Evaluate active nodes.
|
|
737
|
+
3. Select highest-priority unmet node.
|
|
738
|
+
4. Render mapped step.
|
|
739
|
+
5. Update context.
|
|
740
|
+
6. Repeat until complete step.
|
|
741
|
+
|
|
742
|
+
---
|
|
743
|
+
|
|
744
|
+
## API reference
|
|
745
|
+
|
|
746
|
+
Primary exports:
|
|
747
|
+
- `AdaptiveFlow`
|
|
748
|
+
- `defaultRequirements`
|
|
749
|
+
- `initialContext`
|
|
750
|
+
- `defaultRequirementResolvers`
|
|
751
|
+
- `createDefaultRequirementGraph`
|
|
752
|
+
- `createRequirementGraph`
|
|
753
|
+
- `evaluateNextStep`
|
|
754
|
+
- `getMissingRequirements`
|
|
755
|
+
- `DefaultAdaptiveSteps`
|
|
756
|
+
- `DefaultAppRequirements`
|
|
757
|
+
|
|
758
|
+
Important types:
|
|
759
|
+
- `AdaptiveFlowProps`
|
|
760
|
+
- `AdaptiveFlowAdapter`
|
|
761
|
+
- `AdaptiveFlowValidators`
|
|
762
|
+
- `AdaptiveFlowPersistence`
|
|
763
|
+
- `AdaptiveStepRegistry`
|
|
764
|
+
- `AdaptiveStepRendererArgs`
|
|
765
|
+
- `AdaptiveStepRenderArgs`
|
|
766
|
+
- `AdaptiveStepTransition`
|
|
767
|
+
- `RequirementResolver`
|
|
768
|
+
- `RequirementGraph`
|
|
769
|
+
- `RequirementGraphNode`
|
|
770
|
+
- `AdaptiveContext`
|
|
771
|
+
- `AdaptiveContextBase`
|
|
772
|
+
|
|
773
|
+
---
|
|
774
|
+
|
|
775
|
+
## Release checklist
|
|
776
|
+
|
|
777
|
+
- Ensure adapter methods return clear, user-safe errors.
|
|
778
|
+
- Version persistence keys (example: `flow-state:v1`).
|
|
779
|
+
- Track analytics with `onStepTransition`.
|
|
780
|
+
- Keep requirements and graph definitions local to each product module.
|
|
781
|
+
- Add integration tests for all flow branches.
|
|
782
|
+
- Run `npm run typecheck` and `npm run build` before every publish.
|
|
783
|
+
|
|
784
|
+
---
|
|
785
|
+
|
|
786
|
+
## Troubleshooting
|
|
787
|
+
|
|
788
|
+
Step not appearing:
|
|
789
|
+
- Cause: priority, condition, or dependency suppresses node activation.
|
|
790
|
+
- Fix: inspect graph `priorities`, `conditions`, and `dependencies`.
|
|
791
|
+
|
|
792
|
+
State resets on refresh:
|
|
793
|
+
- Cause: persistence missing or wrong key.
|
|
794
|
+
- Fix: set `persistence.key` and chosen storage.
|
|
795
|
+
|
|
796
|
+
OAuth return does not resume:
|
|
797
|
+
- Cause: missing `completeOAuth` or persistence disabled.
|
|
798
|
+
- Fix: implement `completeOAuth` and enable persistence.
|
|
799
|
+
|
|
800
|
+
---
|
|
801
|
+
|
|
802
|
+
## License
|
|
803
|
+
|
|
804
|
+
MIT
|