@exegia/corpora-ui 0.22.0 → 0.24.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/dist-lib/components/blocks/auth/__tests__/auth-flow-block.test.d.ts +1 -0
- package/dist-lib/components/blocks/auth/auth-flow-block.d.ts +123 -0
- package/dist-lib/components/blocks/scaffold/__tests__/scaffold-atom.test.d.ts +1 -0
- package/dist-lib/components/blocks/scaffold/index.d.ts +2 -0
- package/dist-lib/components/blocks/scaffold/scaffold-atom.d.ts +97 -0
- package/dist-lib/components/blocks/scaffold/scaffold-context.d.ts +3 -1
- package/dist-lib/components/blocks/scaffold/scaffold-root.d.ts +12 -3
- package/dist-lib/components/blocks/scaffold/type.d.ts +58 -18
- package/dist-lib/components/blocks/scaffold/use-scaffold-state.d.ts +26 -0
- package/dist-lib/components/blocks/scaffold/use-scaffold.d.ts +7 -1
- package/dist-lib/components/blocks/scaffold/utils.d.ts +11 -1
- package/dist-lib/components/ui/switch.d.ts +4 -0
- package/dist-lib/index.d.ts +1 -0
- package/dist-lib/index.js +1683 -1495
- package/dist-lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/beste/piece/browser-frame.tsx +1 -1
- package/src/components/beste/piece/utils.ts +4 -4
- package/src/components/blocks/auth/__tests__/auth-flow-block.test.tsx +183 -0
- package/src/components/blocks/auth/auth-flow-block.tsx +296 -0
- package/src/components/blocks/scaffold/__tests__/scaffold-atom.test.tsx +210 -0
- package/src/components/blocks/scaffold/index.ts +15 -0
- package/src/components/blocks/scaffold/scaffold-actions.tsx +5 -1
- package/src/components/blocks/scaffold/scaffold-atom.ts +383 -0
- package/src/components/blocks/scaffold/scaffold-canvas.tsx +12 -4
- package/src/components/blocks/scaffold/scaffold-context.ts +3 -1
- package/src/components/blocks/scaffold/scaffold-inspector.tsx +9 -2
- package/src/components/blocks/scaffold/scaffold-panel.tsx +24 -10
- package/src/components/blocks/scaffold/scaffold-root.tsx +64 -32
- package/src/components/blocks/scaffold/scaffold-sub-panel.tsx +12 -10
- package/src/components/blocks/scaffold/scaffold-tab.tsx +13 -9
- package/src/components/blocks/scaffold/type.ts +64 -18
- package/src/components/blocks/scaffold/use-scaffold-state.ts +58 -0
- package/src/components/blocks/scaffold/use-scaffold.ts +30 -19
- package/src/components/blocks/scaffold/utils.ts +45 -1
- package/src/components/blocks/shell/shell-layout.tsx +8 -3
- package/src/components/docs/component-preview.tsx +7 -2
- package/src/components/docs/demo-controls.tsx +32 -23
- package/src/components/ui/switch.tsx +30 -0
- package/src/index.ts +1 -0
- package/dist-lib/components/blocks/scaffold/use-panel-visibility.d.ts +0 -19
- package/src/components/blocks/scaffold/use-panel-visibility.ts +0 -180
package/package.json
CHANGED
|
@@ -19,7 +19,7 @@ export function BrowserFrame({
|
|
|
19
19
|
className
|
|
20
20
|
)}
|
|
21
21
|
>
|
|
22
|
-
<div className="relative flex
|
|
22
|
+
<div className="relative flex min-h-32 w-full flex-col overflow-hidden rounded-lg border border-border bg-neutral-50 shadow-lg dark:bg-neutral-900">
|
|
23
23
|
<div
|
|
24
24
|
id="title-bar"
|
|
25
25
|
className={cn(
|
|
@@ -6,15 +6,15 @@ export const titleBarStyle = (
|
|
|
6
6
|
component: "title" | "frame"
|
|
7
7
|
): ClassNameValue => {
|
|
8
8
|
const titleBarClassName: Record<TitleStyleType, ClassNameValue> = {
|
|
9
|
-
titlebar: "",
|
|
9
|
+
titlebar: "relative",
|
|
10
10
|
hidden: "top-0 left-0 absolute",
|
|
11
|
-
expanded: "",
|
|
11
|
+
expanded: "relative",
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
const bodyClassName: Record<TitleStyleType, ClassNameValue> = {
|
|
15
|
-
titlebar: "",
|
|
15
|
+
titlebar: "relative",
|
|
16
16
|
hidden: "",
|
|
17
|
-
expanded: "",
|
|
17
|
+
expanded: "relative",
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
return component == "title" ? titleBarClassName[style] : bodyClassName[style]
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test"
|
|
2
|
+
import { render, screen, waitFor } from "@testing-library/react"
|
|
3
|
+
import userEvent from "@testing-library/user-event"
|
|
4
|
+
import { createStore } from "jotai"
|
|
5
|
+
|
|
6
|
+
import { ExegiaProvider } from "@/state"
|
|
7
|
+
import { AuthFlowBlock } from "../auth-flow-block"
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_AUTH_FLOW_ID,
|
|
10
|
+
authFlowStepAtom,
|
|
11
|
+
authUserAtom,
|
|
12
|
+
goToAuthStepAtom,
|
|
13
|
+
isAuthenticatedAtom,
|
|
14
|
+
} from "../auth-state"
|
|
15
|
+
import type { AuthUser } from "../auth-state"
|
|
16
|
+
|
|
17
|
+
const USER: AuthUser = {
|
|
18
|
+
id: "u-1",
|
|
19
|
+
name: "Yona Appletree",
|
|
20
|
+
email: "yo@example.com",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type Store = ReturnType<typeof createStore>
|
|
24
|
+
|
|
25
|
+
function mount(store: Store, ui: React.ReactElement) {
|
|
26
|
+
return render(<ExegiaProvider store={store}>{ui}</ExegiaProvider>)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** MorphStep exits run under AnimatePresence; happy-dom serves stale
|
|
30
|
+
* selector-cache results to `waitFor` queries that start while an exit is
|
|
31
|
+
* still running — let the exit settle on real timers, then query once. */
|
|
32
|
+
const settleExit = () => new Promise((resolve) => setTimeout(resolve, 400))
|
|
33
|
+
|
|
34
|
+
async function submitLogin(email = "yo@example.com", password = "hunter22!") {
|
|
35
|
+
const user = userEvent.setup()
|
|
36
|
+
await user.type(screen.getByLabelText("Email"), email)
|
|
37
|
+
await user.type(screen.getByLabelText("Password"), password)
|
|
38
|
+
await user.click(screen.getByRole("button", { name: "Login" }))
|
|
39
|
+
return user
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("AuthFlowBlock · step switching", () => {
|
|
43
|
+
test("renders the login step by default with pre-wired navigation", async () => {
|
|
44
|
+
const user = userEvent.setup()
|
|
45
|
+
const store = createStore()
|
|
46
|
+
mount(store, <AuthFlowBlock />)
|
|
47
|
+
|
|
48
|
+
expect(screen.getByText("Login to your account")).toBeDefined()
|
|
49
|
+
|
|
50
|
+
await user.click(screen.getByRole("button", { name: "Sign up" }))
|
|
51
|
+
expect(store.get(authFlowStepAtom(DEFAULT_AUTH_FLOW_ID))).toBe("signup")
|
|
52
|
+
await settleExit()
|
|
53
|
+
expect(screen.getByText("Create your account")).toBeDefined()
|
|
54
|
+
|
|
55
|
+
// Signup's footer goes back to login.
|
|
56
|
+
await user.click(screen.getByRole("button", { name: "Login" }))
|
|
57
|
+
expect(store.get(authFlowStepAtom(DEFAULT_AUTH_FLOW_ID))).toBe("login")
|
|
58
|
+
await settleExit()
|
|
59
|
+
|
|
60
|
+
// The forgot-password link sits in the password row, which the login
|
|
61
|
+
// block reveals once the email is valid.
|
|
62
|
+
await user.type(screen.getByLabelText("Email"), "yo@example.com")
|
|
63
|
+
await user.click(
|
|
64
|
+
await screen.findByRole("button", { name: "Forgot password?" })
|
|
65
|
+
)
|
|
66
|
+
expect(store.get(authFlowStepAtom(DEFAULT_AUTH_FLOW_ID))).toBe(
|
|
67
|
+
"forgot-password"
|
|
68
|
+
)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test("follows the store when something else drives the flow", async () => {
|
|
72
|
+
const store = createStore()
|
|
73
|
+
mount(store, <AuthFlowBlock />)
|
|
74
|
+
|
|
75
|
+
store.set(goToAuthStepAtom(DEFAULT_AUTH_FLOW_ID), "update-password")
|
|
76
|
+
expect(await screen.findByText("Update your password")).toBeDefined()
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe("AuthFlowBlock · directives", () => {
|
|
81
|
+
test("{ verify } moves to the code step with the masked destination", async () => {
|
|
82
|
+
const store = createStore()
|
|
83
|
+
const onLogin = mock(() => ({
|
|
84
|
+
verify: { identifier: "yo@example.com" } as const,
|
|
85
|
+
}))
|
|
86
|
+
mount(store, <AuthFlowBlock onLogin={onLogin} />)
|
|
87
|
+
|
|
88
|
+
await submitLogin()
|
|
89
|
+
expect(onLogin).toHaveBeenCalledTimes(1)
|
|
90
|
+
expect(store.get(authFlowStepAtom(DEFAULT_AUTH_FLOW_ID))).toBe(
|
|
91
|
+
"verify-code"
|
|
92
|
+
)
|
|
93
|
+
// The masked identifier feeds CodeAuthBlock's destination copy.
|
|
94
|
+
expect(await screen.findByText(/y•••@example\.com/)).toBeDefined()
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test("{ user } completes the flow and signs the session in", async () => {
|
|
98
|
+
const store = createStore()
|
|
99
|
+
mount(store, <AuthFlowBlock onLogin={() => ({ user: USER })} />)
|
|
100
|
+
|
|
101
|
+
await submitLogin()
|
|
102
|
+
expect(store.get(authFlowStepAtom(DEFAULT_AUTH_FLOW_ID))).toBe("success")
|
|
103
|
+
expect(store.get(isAuthenticatedAtom)).toBe(true)
|
|
104
|
+
expect(store.get(authUserAtom)?.id).toBe("u-1")
|
|
105
|
+
await settleExit()
|
|
106
|
+
expect(screen.getByText("Welcome")).toBeDefined()
|
|
107
|
+
expect(screen.getByText("You're signed in")).toBeDefined()
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test("a resolved handler without a directive stays on the step", async () => {
|
|
111
|
+
const store = createStore()
|
|
112
|
+
mount(store, <AuthFlowBlock onLogin={() => undefined} />)
|
|
113
|
+
|
|
114
|
+
await submitLogin()
|
|
115
|
+
expect(store.get(authFlowStepAtom(DEFAULT_AUTH_FLOW_ID))).toBe("login")
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test("a rejected handler stays on the step and shows the block's error", async () => {
|
|
119
|
+
const store = createStore()
|
|
120
|
+
mount(
|
|
121
|
+
store,
|
|
122
|
+
<AuthFlowBlock
|
|
123
|
+
onLogin={() => {
|
|
124
|
+
throw new Error("Wrong password")
|
|
125
|
+
}}
|
|
126
|
+
/>
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
await submitLogin()
|
|
130
|
+
expect(store.get(authFlowStepAtom(DEFAULT_AUTH_FLOW_ID))).toBe("login")
|
|
131
|
+
expect(await screen.findByText("Wrong password")).toBeDefined()
|
|
132
|
+
expect(store.get(isAuthenticatedAtom)).toBe(false)
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
describe("AuthFlowBlock · customization", () => {
|
|
137
|
+
test("steps overrides merge over the wiring", async () => {
|
|
138
|
+
const store = createStore()
|
|
139
|
+
mount(
|
|
140
|
+
store,
|
|
141
|
+
<AuthFlowBlock
|
|
142
|
+
steps={{ login: { title: "Sign in to Corpora", providers: [] } }}
|
|
143
|
+
/>
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
expect(screen.getByText("Sign in to Corpora")).toBeDefined()
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test("renderStep replaces a step; undefined keeps the default", async () => {
|
|
150
|
+
const store = createStore()
|
|
151
|
+
mount(
|
|
152
|
+
store,
|
|
153
|
+
<AuthFlowBlock
|
|
154
|
+
renderStep={(step) =>
|
|
155
|
+
step === "success" ? <p>Custom landing</p> : undefined
|
|
156
|
+
}
|
|
157
|
+
/>
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
expect(screen.getByText("Login to your account")).toBeDefined()
|
|
161
|
+
store.set(goToAuthStepAtom(DEFAULT_AUTH_FLOW_ID), "success")
|
|
162
|
+
expect(await screen.findByText("Custom landing")).toBeDefined()
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
test("two flow ids orchestrate independently", async () => {
|
|
166
|
+
const store = createStore()
|
|
167
|
+
mount(
|
|
168
|
+
store,
|
|
169
|
+
<>
|
|
170
|
+
<AuthFlowBlock flowId="page" />
|
|
171
|
+
<AuthFlowBlock flowId="modal" />
|
|
172
|
+
</>
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
store.set(goToAuthStepAtom("modal"), "forgot-password")
|
|
176
|
+
expect(await screen.findByText("Reset your password")).toBeDefined()
|
|
177
|
+
// The page flow is still on login.
|
|
178
|
+
await waitFor(() =>
|
|
179
|
+
expect(screen.getByText("Login to your account")).toBeDefined()
|
|
180
|
+
)
|
|
181
|
+
expect(store.get(authFlowStepAtom("page"))).toBe("login")
|
|
182
|
+
})
|
|
183
|
+
})
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { MotionConfig } from "motion/react"
|
|
5
|
+
|
|
6
|
+
import type { SocialProvider } from "@/components/composed/social-providers"
|
|
7
|
+
import type { AuthAccent } from "@/lib/auth-accent"
|
|
8
|
+
import { cn } from "@/lib/utils"
|
|
9
|
+
import { AuthCard, AuthSuccess, MorphStep } from "./auth-shell"
|
|
10
|
+
import { CodeAuthBlock, type CodeAuthBlockProps } from "./code-auth-block"
|
|
11
|
+
import {
|
|
12
|
+
ForgotPasswordBlock,
|
|
13
|
+
type ForgotPasswordBlockProps,
|
|
14
|
+
} from "./forgot-password-block"
|
|
15
|
+
import { LoginBlock, type LoginBlockProps } from "./login-block"
|
|
16
|
+
import {
|
|
17
|
+
OnboardingBlock,
|
|
18
|
+
type OnboardingBlockProps,
|
|
19
|
+
type OnboardingStepConfig,
|
|
20
|
+
type OnboardingValue,
|
|
21
|
+
} from "./onboarding-block"
|
|
22
|
+
import { SignupBlock, type SignupBlockProps } from "./signup-block"
|
|
23
|
+
import {
|
|
24
|
+
UpdatePasswordBlock,
|
|
25
|
+
type UpdatePasswordBlockProps,
|
|
26
|
+
} from "./update-password-block"
|
|
27
|
+
import type {
|
|
28
|
+
AuthFlowId,
|
|
29
|
+
AuthFlowState,
|
|
30
|
+
AuthFlowStep,
|
|
31
|
+
AuthUser,
|
|
32
|
+
BeginAuthVerificationOptions,
|
|
33
|
+
} from "./auth-state-type"
|
|
34
|
+
import { useAuthFlow, useAuthFlowActions } from "./use-auth-state"
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Where the flow goes after a step callback resolves. Returned from every
|
|
38
|
+
* `AuthFlowBlock` handler:
|
|
39
|
+
*
|
|
40
|
+
* - `{ user }` — the attempt signed someone in: mark the flow complete AND
|
|
41
|
+
* sign the session in (one atomic write, `completeAuthFlowAtom`).
|
|
42
|
+
* - `{ verify }` — a code went out: record the identifier and move to the
|
|
43
|
+
* verification step (`beginAuthVerificationAtom`).
|
|
44
|
+
* - `{ step }` — plain navigation with a clean slate (`goToAuthStepAtom`).
|
|
45
|
+
* - `void` — stay put; the block renders its own success state.
|
|
46
|
+
*
|
|
47
|
+
* Rejections are NOT handled here — they propagate back into the block,
|
|
48
|
+
* which owns its transient error/shake state (see `react/CLAUDE.md`,
|
|
49
|
+
* "Third implementation: auth").
|
|
50
|
+
*/
|
|
51
|
+
export type AuthFlowDirective =
|
|
52
|
+
| { user: AuthUser }
|
|
53
|
+
| { verify: BeginAuthVerificationOptions }
|
|
54
|
+
| { step: AuthFlowStep }
|
|
55
|
+
| void
|
|
56
|
+
|
|
57
|
+
export type AuthFlowHandler<Data = void> = (
|
|
58
|
+
data: Data
|
|
59
|
+
) => Promise<AuthFlowDirective> | AuthFlowDirective
|
|
60
|
+
|
|
61
|
+
/** Per-step prop overrides, merged over the orchestrator's wiring — spread
|
|
62
|
+
* last, so an app can restyle a block or unhook a default navigation link
|
|
63
|
+
* (`{ login: { onSignup: undefined } }` removes the sign-up hand-off). */
|
|
64
|
+
export interface AuthFlowStepOverrides {
|
|
65
|
+
login?: Partial<LoginBlockProps>
|
|
66
|
+
signup?: Partial<SignupBlockProps>
|
|
67
|
+
"verify-code"?: Partial<CodeAuthBlockProps>
|
|
68
|
+
"forgot-password"?: Partial<ForgotPasswordBlockProps>
|
|
69
|
+
"update-password"?: Partial<UpdatePasswordBlockProps>
|
|
70
|
+
onboarding?: Partial<OnboardingBlockProps>
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface AuthFlowBlockProps {
|
|
74
|
+
/** Which flow instance to orchestrate. The default flow unless a re-auth
|
|
75
|
+
* modal or a second surface needs its own. */
|
|
76
|
+
flowId?: AuthFlowId
|
|
77
|
+
/** Brand mark handed to every step's card. */
|
|
78
|
+
logo?: React.ReactNode
|
|
79
|
+
/** Brand accent handed to every step's card. */
|
|
80
|
+
accent?: AuthAccent
|
|
81
|
+
/** Social providers offered on the login and signup steps. */
|
|
82
|
+
providers?: SocialProvider[]
|
|
83
|
+
/** The login attempt. Resolve with a directive; reject to show the error
|
|
84
|
+
* in the block. */
|
|
85
|
+
onLogin?: AuthFlowHandler<{
|
|
86
|
+
email: string
|
|
87
|
+
password: string
|
|
88
|
+
remember: boolean
|
|
89
|
+
}>
|
|
90
|
+
/** The signup attempt. */
|
|
91
|
+
onSignup?: AuthFlowHandler<{ name: string; email: string; password: string }>
|
|
92
|
+
/** A social provider chosen on the login or signup step. */
|
|
93
|
+
onProviderSelect?: AuthFlowHandler<SocialProvider>
|
|
94
|
+
/** The forgot-password request. Resolving without a directive stays on the
|
|
95
|
+
* step (the block shows its own "link sent" state). */
|
|
96
|
+
onRequestReset?: AuthFlowHandler<{ email: string }>
|
|
97
|
+
/** The code entered on the verification step. */
|
|
98
|
+
onVerifyCode?: AuthFlowHandler<string>
|
|
99
|
+
/** "Resend code" on the verification step. */
|
|
100
|
+
onResendCode?: AuthFlowHandler
|
|
101
|
+
/** The update-password submit. */
|
|
102
|
+
onUpdatePassword?: AuthFlowHandler<{ password: string }>
|
|
103
|
+
/** Onboarding finished, with the merged profile. */
|
|
104
|
+
onOnboardingComplete?: AuthFlowHandler<Record<string, OnboardingValue>>
|
|
105
|
+
/** Declared onboarding steps, handed to `OnboardingBlock`. */
|
|
106
|
+
onboardingSteps?: OnboardingStepConfig[]
|
|
107
|
+
/** Per-step prop overrides, merged over the orchestrator's wiring. */
|
|
108
|
+
steps?: AuthFlowStepOverrides
|
|
109
|
+
/** Replace any step's UI entirely; return `undefined` to keep the default
|
|
110
|
+
* for that step. Receives the flow state for destination copy etc. */
|
|
111
|
+
renderStep?: (
|
|
112
|
+
step: AuthFlowStep,
|
|
113
|
+
flow: AuthFlowState
|
|
114
|
+
) => React.ReactNode | undefined
|
|
115
|
+
/** Replaces the whole default success card. */
|
|
116
|
+
success?: React.ReactNode
|
|
117
|
+
/** Card title of the default success step. */
|
|
118
|
+
successTitle?: string
|
|
119
|
+
/** Body under the default success step's "You're signed in" check. */
|
|
120
|
+
successDescription?: string
|
|
121
|
+
className?: string
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Renders the right auth block for the flow's current step with the store
|
|
126
|
+
* wiring built in — the switchboard every host app was hand-rolling around
|
|
127
|
+
* `useAuthFlow`. Navigation links between steps (login ↔ signup, forgot
|
|
128
|
+
* password, back from verification) are pre-wired to `goToStep`; each
|
|
129
|
+
* submit-shaped prop awaits your handler and applies the returned
|
|
130
|
+
* {@link AuthFlowDirective}.
|
|
131
|
+
*
|
|
132
|
+
* The blocks themselves stay untouched: passwords, codes and field drafts
|
|
133
|
+
* live in their local state, and a rejected handler renders as the block's
|
|
134
|
+
* own error — the orchestrator never mirrors transients into the store.
|
|
135
|
+
* Needs `ExegiaProvider` above it, like every stateful block.
|
|
136
|
+
*
|
|
137
|
+
* ```tsx
|
|
138
|
+
* <AuthFlowBlock
|
|
139
|
+
* onLogin={async ({ email, password }) => {
|
|
140
|
+
* const outcome = await api.signIn(email, password)
|
|
141
|
+
* return outcome.mfa ? { verify: { identifier: email } } : { user: outcome.user }
|
|
142
|
+
* }}
|
|
143
|
+
* onVerifyCode={async (code) => ({ user: await api.verify(code) })}
|
|
144
|
+
* />
|
|
145
|
+
* ```
|
|
146
|
+
*/
|
|
147
|
+
export function AuthFlowBlock({
|
|
148
|
+
flowId,
|
|
149
|
+
logo,
|
|
150
|
+
accent,
|
|
151
|
+
providers,
|
|
152
|
+
onLogin,
|
|
153
|
+
onSignup,
|
|
154
|
+
onProviderSelect,
|
|
155
|
+
onRequestReset,
|
|
156
|
+
onVerifyCode,
|
|
157
|
+
onResendCode,
|
|
158
|
+
onUpdatePassword,
|
|
159
|
+
onOnboardingComplete,
|
|
160
|
+
onboardingSteps,
|
|
161
|
+
steps,
|
|
162
|
+
renderStep,
|
|
163
|
+
success,
|
|
164
|
+
successTitle = "Welcome",
|
|
165
|
+
successDescription,
|
|
166
|
+
className,
|
|
167
|
+
}: AuthFlowBlockProps): React.ReactElement {
|
|
168
|
+
const flow = useAuthFlow(flowId)
|
|
169
|
+
const { goToStep, beginVerification, complete } = useAuthFlowActions(flowId)
|
|
170
|
+
|
|
171
|
+
const apply = React.useCallback(
|
|
172
|
+
(directive: AuthFlowDirective) => {
|
|
173
|
+
if (!directive) return
|
|
174
|
+
if ("user" in directive) complete(directive.user)
|
|
175
|
+
else if ("verify" in directive) beginVerification(directive.verify)
|
|
176
|
+
else goToStep(directive.step)
|
|
177
|
+
},
|
|
178
|
+
[complete, beginVerification, goToStep]
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
/** Wrap a handler so its directive lands in the store. Errors propagate —
|
|
182
|
+
* the block owns the error rendering. Absent handlers stay absent, so a
|
|
183
|
+
* block keeps its "no handler" affordances (hidden buttons etc.). */
|
|
184
|
+
function run<Data>(handler: AuthFlowHandler<Data> | undefined) {
|
|
185
|
+
if (!handler) return undefined
|
|
186
|
+
return async (data: Data) => {
|
|
187
|
+
apply(await handler(data))
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const content = ((): React.ReactNode => {
|
|
192
|
+
const custom = renderStep?.(flow.step, flow)
|
|
193
|
+
if (custom !== undefined) return custom
|
|
194
|
+
|
|
195
|
+
switch (flow.step) {
|
|
196
|
+
case "login":
|
|
197
|
+
return (
|
|
198
|
+
<LoginBlock
|
|
199
|
+
logo={logo}
|
|
200
|
+
accent={accent}
|
|
201
|
+
providers={providers}
|
|
202
|
+
onSubmit={run(onLogin)}
|
|
203
|
+
onProviderSelect={run(onProviderSelect)}
|
|
204
|
+
onForgotPassword={() => goToStep("forgot-password")}
|
|
205
|
+
onSignup={() => goToStep("signup")}
|
|
206
|
+
{...steps?.login}
|
|
207
|
+
/>
|
|
208
|
+
)
|
|
209
|
+
case "signup":
|
|
210
|
+
return (
|
|
211
|
+
<SignupBlock
|
|
212
|
+
logo={logo}
|
|
213
|
+
accent={accent}
|
|
214
|
+
providers={providers}
|
|
215
|
+
onSubmit={run(onSignup)}
|
|
216
|
+
onProviderSelect={run(onProviderSelect)}
|
|
217
|
+
onLogin={() => goToStep("login")}
|
|
218
|
+
{...steps?.signup}
|
|
219
|
+
/>
|
|
220
|
+
)
|
|
221
|
+
case "verify-code":
|
|
222
|
+
return (
|
|
223
|
+
<CodeAuthBlock
|
|
224
|
+
logo={logo}
|
|
225
|
+
accent={accent}
|
|
226
|
+
channel={flow.channel}
|
|
227
|
+
destination={flow.maskedIdentifier ?? undefined}
|
|
228
|
+
onVerify={run(onVerifyCode)}
|
|
229
|
+
onResend={run(onResendCode)}
|
|
230
|
+
onBack={() => goToStep("login")}
|
|
231
|
+
{...steps?.["verify-code"]}
|
|
232
|
+
/>
|
|
233
|
+
)
|
|
234
|
+
case "forgot-password":
|
|
235
|
+
return (
|
|
236
|
+
<ForgotPasswordBlock
|
|
237
|
+
logo={logo}
|
|
238
|
+
accent={accent}
|
|
239
|
+
onSubmit={run(onRequestReset)}
|
|
240
|
+
onBackToLogin={() => goToStep("login")}
|
|
241
|
+
{...steps?.["forgot-password"]}
|
|
242
|
+
/>
|
|
243
|
+
)
|
|
244
|
+
case "update-password":
|
|
245
|
+
return (
|
|
246
|
+
<UpdatePasswordBlock
|
|
247
|
+
logo={logo}
|
|
248
|
+
accent={accent}
|
|
249
|
+
onSubmit={run(onUpdatePassword)}
|
|
250
|
+
onDone={() => goToStep("login")}
|
|
251
|
+
{...steps?.["update-password"]}
|
|
252
|
+
/>
|
|
253
|
+
)
|
|
254
|
+
case "onboarding":
|
|
255
|
+
return (
|
|
256
|
+
<OnboardingBlock
|
|
257
|
+
logo={logo}
|
|
258
|
+
accent={accent}
|
|
259
|
+
steps={onboardingSteps}
|
|
260
|
+
onComplete={run(onOnboardingComplete)}
|
|
261
|
+
// The flow's success step is the completion screen — showing the
|
|
262
|
+
// block's own too would stack two checkmarks.
|
|
263
|
+
showCompleteScreen={false}
|
|
264
|
+
{...steps?.onboarding}
|
|
265
|
+
/>
|
|
266
|
+
)
|
|
267
|
+
case "success":
|
|
268
|
+
return (
|
|
269
|
+
success ?? (
|
|
270
|
+
// Header names the moment, the check screen names the outcome —
|
|
271
|
+
// the same split the onboarding block's completion screen uses.
|
|
272
|
+
<AuthCard title={successTitle} logo={logo} accent={accent}>
|
|
273
|
+
<AuthSuccess
|
|
274
|
+
title="You're signed in"
|
|
275
|
+
description={successDescription}
|
|
276
|
+
/>
|
|
277
|
+
</AuthCard>
|
|
278
|
+
)
|
|
279
|
+
)
|
|
280
|
+
}
|
|
281
|
+
})()
|
|
282
|
+
|
|
283
|
+
return (
|
|
284
|
+
<MotionConfig reducedMotion="user">
|
|
285
|
+
<div
|
|
286
|
+
className={cn("flex w-full justify-center", className)}
|
|
287
|
+
data-slot="auth-flow"
|
|
288
|
+
data-step={flow.step}
|
|
289
|
+
>
|
|
290
|
+
<MorphStep step={flow.step} className="flex w-full justify-center">
|
|
291
|
+
{content}
|
|
292
|
+
</MorphStep>
|
|
293
|
+
</div>
|
|
294
|
+
</MotionConfig>
|
|
295
|
+
)
|
|
296
|
+
}
|