@stapel/auth-react 1.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.
Files changed (191) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/LICENSE +21 -0
  3. package/MODULE.md +147 -0
  4. package/README.md +116 -0
  5. package/dist/api/authApi.d.ts +68 -0
  6. package/dist/api/authApi.d.ts.map +1 -0
  7. package/dist/api/authApi.js +90 -0
  8. package/dist/api/authApi.js.map +1 -0
  9. package/dist/api/types.d.ts +238 -0
  10. package/dist/api/types.d.ts.map +1 -0
  11. package/dist/api/types.js +14 -0
  12. package/dist/api/types.js.map +1 -0
  13. package/dist/api/urls.d.ts +41 -0
  14. package/dist/api/urls.d.ts.map +1 -0
  15. package/dist/api/urls.js +86 -0
  16. package/dist/api/urls.js.map +1 -0
  17. package/dist/flows/anonymousFlow.d.ts +33 -0
  18. package/dist/flows/anonymousFlow.d.ts.map +1 -0
  19. package/dist/flows/anonymousFlow.js +26 -0
  20. package/dist/flows/anonymousFlow.js.map +1 -0
  21. package/dist/flows/authenticatorChangeFlow.d.ts +67 -0
  22. package/dist/flows/authenticatorChangeFlow.d.ts.map +1 -0
  23. package/dist/flows/authenticatorChangeFlow.js +79 -0
  24. package/dist/flows/authenticatorChangeFlow.js.map +1 -0
  25. package/dist/flows/createFlowMachine.d.ts +55 -0
  26. package/dist/flows/createFlowMachine.d.ts.map +1 -0
  27. package/dist/flows/createFlowMachine.js +56 -0
  28. package/dist/flows/createFlowMachine.js.map +1 -0
  29. package/dist/flows/errors.d.ts +15 -0
  30. package/dist/flows/errors.d.ts.map +1 -0
  31. package/dist/flows/errors.js +17 -0
  32. package/dist/flows/errors.js.map +1 -0
  33. package/dist/flows/magicLinkFlow.d.ts +41 -0
  34. package/dist/flows/magicLinkFlow.d.ts.map +1 -0
  35. package/dist/flows/magicLinkFlow.js +29 -0
  36. package/dist/flows/magicLinkFlow.js.map +1 -0
  37. package/dist/flows/oauthFlow.d.ts +58 -0
  38. package/dist/flows/oauthFlow.d.ts.map +1 -0
  39. package/dist/flows/oauthFlow.js +53 -0
  40. package/dist/flows/oauthFlow.js.map +1 -0
  41. package/dist/flows/otpFlow.d.ts +74 -0
  42. package/dist/flows/otpFlow.d.ts.map +1 -0
  43. package/dist/flows/otpFlow.js +68 -0
  44. package/dist/flows/otpFlow.js.map +1 -0
  45. package/dist/flows/passkeyFlow.d.ts +75 -0
  46. package/dist/flows/passkeyFlow.d.ts.map +1 -0
  47. package/dist/flows/passkeyFlow.js +100 -0
  48. package/dist/flows/passkeyFlow.js.map +1 -0
  49. package/dist/flows/passwordChangeFlow.d.ts +53 -0
  50. package/dist/flows/passwordChangeFlow.d.ts.map +1 -0
  51. package/dist/flows/passwordChangeFlow.js +51 -0
  52. package/dist/flows/passwordChangeFlow.js.map +1 -0
  53. package/dist/flows/passwordLoginFlow.d.ts +62 -0
  54. package/dist/flows/passwordLoginFlow.d.ts.map +1 -0
  55. package/dist/flows/passwordLoginFlow.js +55 -0
  56. package/dist/flows/passwordLoginFlow.js.map +1 -0
  57. package/dist/flows/passwordResetFlow.d.ts +56 -0
  58. package/dist/flows/passwordResetFlow.d.ts.map +1 -0
  59. package/dist/flows/passwordResetFlow.js +57 -0
  60. package/dist/flows/passwordResetFlow.js.map +1 -0
  61. package/dist/flows/qrLoginFlow.d.ts +55 -0
  62. package/dist/flows/qrLoginFlow.d.ts.map +1 -0
  63. package/dist/flows/qrLoginFlow.js +91 -0
  64. package/dist/flows/qrLoginFlow.js.map +1 -0
  65. package/dist/flows/ssoFlow.d.ts +46 -0
  66. package/dist/flows/ssoFlow.d.ts.map +1 -0
  67. package/dist/flows/ssoFlow.js +34 -0
  68. package/dist/flows/ssoFlow.js.map +1 -0
  69. package/dist/flows/totpSetupFlow.d.ts +49 -0
  70. package/dist/flows/totpSetupFlow.d.ts.map +1 -0
  71. package/dist/flows/totpSetupFlow.js +47 -0
  72. package/dist/flows/totpSetupFlow.js.map +1 -0
  73. package/dist/flows/useFlow.d.ts +9 -0
  74. package/dist/flows/useFlow.d.ts.map +1 -0
  75. package/dist/flows/useFlow.js +11 -0
  76. package/dist/flows/useFlow.js.map +1 -0
  77. package/dist/flows/verificationFlow.d.ts +108 -0
  78. package/dist/flows/verificationFlow.d.ts.map +1 -0
  79. package/dist/flows/verificationFlow.js +195 -0
  80. package/dist/flows/verificationFlow.js.map +1 -0
  81. package/dist/headless/AuthProvider.d.ts +18 -0
  82. package/dist/headless/AuthProvider.d.ts.map +1 -0
  83. package/dist/headless/AuthProvider.js +22 -0
  84. package/dist/headless/AuthProvider.js.map +1 -0
  85. package/dist/headless/Passkey.d.ts +31 -0
  86. package/dist/headless/Passkey.d.ts.map +1 -0
  87. package/dist/headless/Passkey.js +51 -0
  88. package/dist/headless/Passkey.js.map +1 -0
  89. package/dist/headless/PasswordChange.d.ts +20 -0
  90. package/dist/headless/PasswordChange.d.ts.map +1 -0
  91. package/dist/headless/PasswordChange.js +30 -0
  92. package/dist/headless/PasswordChange.js.map +1 -0
  93. package/dist/headless/PasswordLogin.d.ts +17 -0
  94. package/dist/headless/PasswordLogin.d.ts.map +1 -0
  95. package/dist/headless/PasswordLogin.js +31 -0
  96. package/dist/headless/PasswordLogin.js.map +1 -0
  97. package/dist/headless/PasswordReset.d.ts +19 -0
  98. package/dist/headless/PasswordReset.d.ts.map +1 -0
  99. package/dist/headless/PasswordReset.js +34 -0
  100. package/dist/headless/PasswordReset.js.map +1 -0
  101. package/dist/headless/PasswordlessLogin.d.ts +28 -0
  102. package/dist/headless/PasswordlessLogin.d.ts.map +1 -0
  103. package/dist/headless/PasswordlessLogin.js +42 -0
  104. package/dist/headless/PasswordlessLogin.js.map +1 -0
  105. package/dist/headless/QrLogin.d.ts +19 -0
  106. package/dist/headless/QrLogin.d.ts.map +1 -0
  107. package/dist/headless/QrLogin.js +32 -0
  108. package/dist/headless/QrLogin.js.map +1 -0
  109. package/dist/headless/TotpSetup.d.ts +17 -0
  110. package/dist/headless/TotpSetup.d.ts.map +1 -0
  111. package/dist/headless/TotpSetup.js +26 -0
  112. package/dist/headless/TotpSetup.js.map +1 -0
  113. package/dist/headless/VerificationChallenge.d.ts +37 -0
  114. package/dist/headless/VerificationChallenge.d.ts.map +1 -0
  115. package/dist/headless/VerificationChallenge.js +40 -0
  116. package/dist/headless/VerificationChallenge.js.map +1 -0
  117. package/dist/headless/misc.d.ts +47 -0
  118. package/dist/headless/misc.d.ts.map +1 -0
  119. package/dist/headless/misc.js +84 -0
  120. package/dist/headless/misc.js.map +1 -0
  121. package/dist/i18n/keys.d.ts +34 -0
  122. package/dist/i18n/keys.d.ts.map +1 -0
  123. package/dist/i18n/keys.js +83 -0
  124. package/dist/i18n/keys.js.map +1 -0
  125. package/dist/index.d.ts +73 -0
  126. package/dist/index.d.ts.map +1 -0
  127. package/dist/index.js +48 -0
  128. package/dist/index.js.map +1 -0
  129. package/dist/model/context.d.ts +22 -0
  130. package/dist/model/context.d.ts.map +1 -0
  131. package/dist/model/context.js +34 -0
  132. package/dist/model/context.js.map +1 -0
  133. package/dist/model/mutations.d.ts +28 -0
  134. package/dist/model/mutations.d.ts.map +1 -0
  135. package/dist/model/mutations.js +108 -0
  136. package/dist/model/mutations.js.map +1 -0
  137. package/dist/model/queries.d.ts +30 -0
  138. package/dist/model/queries.d.ts.map +1 -0
  139. package/dist/model/queries.js +87 -0
  140. package/dist/model/queries.js.map +1 -0
  141. package/dist/model/queryKeys.d.ts +13 -0
  142. package/dist/model/queryKeys.d.ts.map +1 -0
  143. package/dist/model/queryKeys.js +21 -0
  144. package/dist/model/queryKeys.js.map +1 -0
  145. package/dist/model/runtime.d.ts +39 -0
  146. package/dist/model/runtime.d.ts.map +1 -0
  147. package/dist/model/runtime.js +44 -0
  148. package/dist/model/runtime.js.map +1 -0
  149. package/dist/model/session.d.ts +50 -0
  150. package/dist/model/session.d.ts.map +1 -0
  151. package/dist/model/session.js +124 -0
  152. package/dist/model/session.js.map +1 -0
  153. package/package.json +68 -0
  154. package/src/api/authApi.ts +332 -0
  155. package/src/api/types.ts +291 -0
  156. package/src/api/urls.ts +99 -0
  157. package/src/flows/anonymousFlow.ts +57 -0
  158. package/src/flows/authenticatorChangeFlow.ts +160 -0
  159. package/src/flows/createFlowMachine.ts +126 -0
  160. package/src/flows/errors.ts +29 -0
  161. package/src/flows/magicLinkFlow.ts +68 -0
  162. package/src/flows/oauthFlow.ts +114 -0
  163. package/src/flows/otpFlow.ts +156 -0
  164. package/src/flows/passkeyFlow.ts +191 -0
  165. package/src/flows/passwordChangeFlow.ts +114 -0
  166. package/src/flows/passwordLoginFlow.ts +122 -0
  167. package/src/flows/passwordResetFlow.ts +123 -0
  168. package/src/flows/qrLoginFlow.ts +158 -0
  169. package/src/flows/ssoFlow.ts +84 -0
  170. package/src/flows/totpSetupFlow.ts +96 -0
  171. package/src/flows/useFlow.ts +16 -0
  172. package/src/flows/verificationFlow.ts +341 -0
  173. package/src/headless/AuthProvider.tsx +30 -0
  174. package/src/headless/Passkey.tsx +97 -0
  175. package/src/headless/PasswordChange.tsx +46 -0
  176. package/src/headless/PasswordLogin.tsx +49 -0
  177. package/src/headless/PasswordReset.tsx +51 -0
  178. package/src/headless/PasswordlessLogin.tsx +60 -0
  179. package/src/headless/QrLogin.tsx +52 -0
  180. package/src/headless/TotpSetup.tsx +40 -0
  181. package/src/headless/VerificationChallenge.tsx +54 -0
  182. package/src/headless/misc.tsx +151 -0
  183. package/src/i18n/keys.ts +94 -0
  184. package/src/index.ts +229 -0
  185. package/src/model/context.tsx +51 -0
  186. package/src/model/mutations.ts +152 -0
  187. package/src/model/queries.ts +130 -0
  188. package/src/model/queryKeys.ts +32 -0
  189. package/src/model/runtime.ts +93 -0
  190. package/src/model/session.ts +188 -0
  191. package/tsconfig.json +26 -0
@@ -0,0 +1,123 @@
1
+ import type { Analytics } from "@stapel/core";
2
+ import type { AuthApi } from "../api/authApi.js";
3
+ import type { AuthResponse, OtpChannel } from "../api/types.js";
4
+ import { createFlowMachine } from "./createFlowMachine.js";
5
+ import type { FlowMachine } from "./createFlowMachine.js";
6
+ import { toFlowError } from "./errors.js";
7
+ import type { FlowError } from "./errors.js";
8
+
9
+ /**
10
+ * Unauthenticated password reset (auth-sa.md §5). User proves ownership of an
11
+ * email/phone via OTP and receives a fresh session with the new password. The
12
+ * verify step carries the new password, so this is a two-input human-wait
13
+ * (`codeSent`): code + new password together.
14
+ */
15
+ export type PasswordResetState =
16
+ | { readonly step: "idle" }
17
+ | { readonly step: "requesting"; readonly channel: OtpChannel; readonly value: string }
18
+ | {
19
+ readonly step: "codeSent";
20
+ readonly channel: OtpChannel;
21
+ readonly value: string;
22
+ readonly target: string;
23
+ }
24
+ | { readonly step: "verifying"; readonly channel: OtpChannel; readonly value: string; readonly target: string }
25
+ | { readonly step: "authenticated"; readonly result: AuthResponse }
26
+ | {
27
+ readonly step: "requestError";
28
+ readonly channel: OtpChannel;
29
+ readonly value: string;
30
+ readonly error: FlowError;
31
+ }
32
+ | {
33
+ readonly step: "codeError";
34
+ readonly channel: OtpChannel;
35
+ readonly value: string;
36
+ readonly target: string;
37
+ readonly error: FlowError;
38
+ };
39
+
40
+ export interface PasswordResetFlow {
41
+ readonly machine: FlowMachine<PasswordResetState>;
42
+ request(channel: OtpChannel, value: string): Promise<void>;
43
+ resend(): Promise<void>;
44
+ submit(code: string, newPassword: string): Promise<void>;
45
+ reset(): void;
46
+ }
47
+
48
+ export interface PasswordResetFlowDeps {
49
+ readonly api: AuthApi;
50
+ readonly analytics?: Analytics | null;
51
+ readonly onAuthenticated?: (result: AuthResponse) => void;
52
+ }
53
+
54
+ export function createPasswordResetFlow(
55
+ deps: PasswordResetFlowDeps
56
+ ): PasswordResetFlow {
57
+ const machine = createFlowMachine<PasswordResetState>({
58
+ id: "auth.password_reset",
59
+ initial: { step: "idle" },
60
+ analytics: deps.analytics ?? null,
61
+ });
62
+
63
+ async function request(channel: OtpChannel, value: string): Promise<void> {
64
+ await machine.run(
65
+ { step: "requesting", channel, value },
66
+ () => deps.api.passwordResetRequest(channel, value),
67
+ {
68
+ resolve: (r): PasswordResetState => ({
69
+ step: "codeSent",
70
+ channel,
71
+ value,
72
+ target: r.target,
73
+ }),
74
+ reject: (error): PasswordResetState => ({
75
+ step: "requestError",
76
+ channel,
77
+ value,
78
+ error: toFlowError(error),
79
+ }),
80
+ }
81
+ );
82
+ }
83
+
84
+ async function resend(): Promise<void> {
85
+ const s = machine.getState();
86
+ if (
87
+ s.step === "codeSent" ||
88
+ s.step === "codeError" ||
89
+ s.step === "requestError"
90
+ ) {
91
+ await request(s.channel, s.value);
92
+ }
93
+ }
94
+
95
+ async function submit(code: string, newPassword: string): Promise<void> {
96
+ const s = machine.getState();
97
+ if (s.step !== "codeSent" && s.step !== "codeError") return;
98
+ const { channel, value, target } = s;
99
+ await machine.run(
100
+ { step: "verifying", channel, value, target },
101
+ () => deps.api.passwordResetVerify(channel, value, code, newPassword),
102
+ {
103
+ resolve: (result): PasswordResetState => {
104
+ deps.onAuthenticated?.(result);
105
+ return { step: "authenticated", result };
106
+ },
107
+ reject: (error): PasswordResetState => ({
108
+ step: "codeError",
109
+ channel,
110
+ value,
111
+ target,
112
+ error: toFlowError(error),
113
+ }),
114
+ }
115
+ );
116
+ }
117
+
118
+ function reset(): void {
119
+ machine.to({ step: "idle" });
120
+ }
121
+
122
+ return { machine, request, resend, submit, reset };
123
+ }
@@ -0,0 +1,158 @@
1
+ import type { Analytics } from "@stapel/core";
2
+ import type { AuthApi } from "../api/authApi.js";
3
+ import type { AuthTokens, QrType } from "../api/types.js";
4
+ import { createFlowMachine } from "./createFlowMachine.js";
5
+ import type { FlowMachine } from "./createFlowMachine.js";
6
+ import { toFlowError } from "./errors.js";
7
+ import type { FlowError } from "./errors.js";
8
+
9
+ /**
10
+ * QR authentication with background polling (auth-sa.md §8). Serves both the
11
+ * `login_request` sign-in tab (polling device receives `access_token` /
12
+ * `refresh_token` on fulfilment) and the `session_share` profile modal (the
13
+ * *scanning* device gets the session; the generator just watches for
14
+ * `fulfilled`).
15
+ *
16
+ * Device binding matters: generate and poll from the same browser context so
17
+ * the httponly `stapel_qr_<key>` cookie is present (the client must send
18
+ * credentials). On `expired` the flow auto-regenerates and resets the poll
19
+ * loop, per spec.
20
+ */
21
+ export type QrLoginState =
22
+ | { readonly step: "idle" }
23
+ | { readonly step: "generating"; readonly type: QrType }
24
+ | {
25
+ readonly step: "awaitingScan";
26
+ readonly type: QrType;
27
+ readonly key: string;
28
+ readonly scanUrl: string;
29
+ readonly expiresIn: number;
30
+ }
31
+ | { readonly step: "fulfilled"; readonly tokens: AuthTokens | null }
32
+ | { readonly step: "rejected"; readonly key: string }
33
+ | { readonly step: "error"; readonly error: FlowError };
34
+
35
+ export interface QrLoginFlow {
36
+ readonly machine: FlowMachine<QrLoginState>;
37
+ /** Generate a QR and begin the poll loop. */
38
+ start(
39
+ type: QrType,
40
+ redirectUrl: string,
41
+ allowUnauthenticatedScanner?: boolean
42
+ ): Promise<void>;
43
+ /** Stop polling and reset to idle (call on modal close / unmount). */
44
+ dispose(): void;
45
+ }
46
+
47
+ export interface QrLoginFlowDeps {
48
+ readonly api: AuthApi;
49
+ readonly analytics?: Analytics | null;
50
+ /** For `login_request` fulfilment — receives the delivered tokens. */
51
+ readonly onAuthenticated?: (tokens: AuthTokens) => void;
52
+ /** Poll cadence; default 5000 ms (auth-sa.md §8). */
53
+ readonly pollIntervalMs?: number;
54
+ }
55
+
56
+ export function createQrLoginFlow(deps: QrLoginFlowDeps): QrLoginFlow {
57
+ const machine = createFlowMachine<QrLoginState>({
58
+ id: "auth.qr_login",
59
+ initial: { step: "idle" },
60
+ analytics: deps.analytics ?? null,
61
+ });
62
+ const interval = deps.pollIntervalMs ?? 5000;
63
+
64
+ let timer: ReturnType<typeof setTimeout> | null = null;
65
+ let params: {
66
+ type: QrType;
67
+ redirectUrl: string;
68
+ allowUnauth: boolean | undefined;
69
+ } | null = null;
70
+
71
+ function clearTimer(): void {
72
+ if (timer !== null) {
73
+ clearTimeout(timer);
74
+ timer = null;
75
+ }
76
+ }
77
+
78
+ function schedulePoll(key: string): void {
79
+ clearTimer();
80
+ timer = setTimeout(() => {
81
+ void poll(key);
82
+ }, interval);
83
+ }
84
+
85
+ async function poll(key: string): Promise<void> {
86
+ // Bail if the machine moved on (disposed / regenerated).
87
+ const s = machine.getState();
88
+ if (s.step !== "awaitingScan" || s.key !== key) return;
89
+ try {
90
+ const status = await deps.api.qrStatus(key);
91
+ if (machine.getState().step !== "awaitingScan") return;
92
+ switch (status.status) {
93
+ case "pending":
94
+ schedulePoll(key);
95
+ break;
96
+ case "fulfilled": {
97
+ clearTimer();
98
+ const tokens: AuthTokens | null =
99
+ status.access_token !== undefined && status.refresh_token !== undefined
100
+ ? { access: status.access_token, refresh: status.refresh_token }
101
+ : null;
102
+ if (tokens) deps.onAuthenticated?.(tokens);
103
+ machine.to({ step: "fulfilled", tokens });
104
+ break;
105
+ }
106
+ case "expired":
107
+ clearTimer();
108
+ if (params) {
109
+ void start(params.type, params.redirectUrl, params.allowUnauth);
110
+ }
111
+ break;
112
+ case "rejected":
113
+ clearTimer();
114
+ machine.to({ step: "rejected", key });
115
+ break;
116
+ }
117
+ } catch (error) {
118
+ clearTimer();
119
+ machine.to({ step: "error", error: toFlowError(error) });
120
+ }
121
+ }
122
+
123
+ async function start(
124
+ type: QrType,
125
+ redirectUrl: string,
126
+ allowUnauthenticatedScanner?: boolean
127
+ ): Promise<void> {
128
+ clearTimer();
129
+ params = { type, redirectUrl, allowUnauth: allowUnauthenticatedScanner };
130
+ await machine.run(
131
+ { step: "generating", type },
132
+ () => deps.api.qrGenerate(type, redirectUrl, allowUnauthenticatedScanner),
133
+ {
134
+ resolve: (r): QrLoginState => ({
135
+ step: "awaitingScan",
136
+ type,
137
+ key: r.key,
138
+ scanUrl: r.scan_url,
139
+ expiresIn: r.expires_in,
140
+ }),
141
+ reject: (error): QrLoginState => ({
142
+ step: "error",
143
+ error: toFlowError(error),
144
+ }),
145
+ }
146
+ );
147
+ const after = machine.getState();
148
+ if (after.step === "awaitingScan") schedulePoll(after.key);
149
+ }
150
+
151
+ function dispose(): void {
152
+ clearTimer();
153
+ params = null;
154
+ machine.to({ step: "idle" });
155
+ }
156
+
157
+ return { machine, start, dispose };
158
+ }
@@ -0,0 +1,84 @@
1
+ import type { Analytics } from "@stapel/core";
2
+ import type { AuthApi } from "../api/authApi.js";
3
+ import type { SsoLookupResponse } from "../api/types.js";
4
+ import { authUrls } from "../api/urls.js";
5
+ import { createFlowMachine } from "./createFlowMachine.js";
6
+ import type { FlowMachine } from "./createFlowMachine.js";
7
+ import { toFlowError } from "./errors.js";
8
+ import type { FlowError } from "./errors.js";
9
+
10
+ /**
11
+ * Enterprise SSO discovery (auth-sa.md §18). The flow covers the *frontend*
12
+ * part: look up a domain after the user types their email, then decide what to
13
+ * render. Actual login is a full-page browser navigation to
14
+ * `authUrls(base).ssoLogin(orgSlug)` — the backend handles SAML/OIDC opaquely
15
+ * and drops the user back on `FRONTEND_URL/` with cookies set. `beginLogin`
16
+ * performs that navigation when a redirector is available.
17
+ */
18
+ export type SsoState =
19
+ | { readonly step: "idle" }
20
+ | { readonly step: "looking"; readonly domain: string }
21
+ | {
22
+ readonly step: "resolved";
23
+ readonly domain: string;
24
+ readonly result: SsoLookupResponse;
25
+ }
26
+ | { readonly step: "error"; readonly domain: string; readonly error: FlowError };
27
+
28
+ export interface SsoFlow {
29
+ readonly machine: FlowMachine<SsoState>;
30
+ /** Look up SSO for an email domain (`sso_required` / optional / none). */
31
+ lookup(domain: string): Promise<void>;
32
+ /** Navigate the browser to the SSO login endpoint for an org slug. */
33
+ beginLogin(orgSlug: string): void;
34
+ reset(): void;
35
+ }
36
+
37
+ export interface SsoFlowDeps {
38
+ readonly api: AuthApi;
39
+ readonly analytics?: Analytics | null;
40
+ /**
41
+ * Full-page redirect seam (default `window.location.assign`). Injectable for
42
+ * tests / custom routers.
43
+ */
44
+ readonly redirect?: (url: string) => void;
45
+ }
46
+
47
+ export function createSsoFlow(deps: SsoFlowDeps): SsoFlow {
48
+ const machine = createFlowMachine<SsoState>({
49
+ id: "auth.sso",
50
+ initial: { step: "idle" },
51
+ analytics: deps.analytics ?? null,
52
+ });
53
+
54
+ async function lookup(domain: string): Promise<void> {
55
+ await machine.run(
56
+ { step: "looking", domain },
57
+ () => deps.api.ssoLookup(domain),
58
+ {
59
+ resolve: (result): SsoState => ({ step: "resolved", domain, result }),
60
+ reject: (error): SsoState => ({
61
+ step: "error",
62
+ domain,
63
+ error: toFlowError(error),
64
+ }),
65
+ }
66
+ );
67
+ }
68
+
69
+ function beginLogin(orgSlug: string): void {
70
+ const url = authUrls(deps.api.client.baseUrl).ssoLogin(orgSlug);
71
+ const go =
72
+ deps.redirect ??
73
+ ((target: string) => {
74
+ if (typeof window !== "undefined") window.location.assign(target);
75
+ });
76
+ go(url);
77
+ }
78
+
79
+ function reset(): void {
80
+ machine.to({ step: "idle" });
81
+ }
82
+
83
+ return { machine, lookup, beginLogin, reset };
84
+ }
@@ -0,0 +1,96 @@
1
+ import type { Analytics } from "@stapel/core";
2
+ import type { AuthApi } from "../api/authApi.js";
3
+ import { createFlowMachine } from "./createFlowMachine.js";
4
+ import type { FlowMachine } from "./createFlowMachine.js";
5
+ import { toFlowError } from "./errors.js";
6
+ import type { FlowError } from "./errors.js";
7
+
8
+ /**
9
+ * TOTP enrollment on the security-settings screen (auth-sa.md §11 "TOTP
10
+ * setup"). `start()` mints a pending secret + `otpauth://` URI (render as a
11
+ * QR); `confirm(code)` proves the authenticator and returns the one-time
12
+ * backup codes (shown ONCE — host must surface a copy/warn affordance).
13
+ */
14
+ export type TotpSetupState =
15
+ | { readonly step: "idle" }
16
+ | { readonly step: "starting" }
17
+ | {
18
+ readonly step: "enrolling";
19
+ readonly secret: string;
20
+ readonly qrUri: string;
21
+ readonly expiresIn: number;
22
+ }
23
+ | { readonly step: "confirming"; readonly secret: string; readonly qrUri: string; readonly expiresIn: number }
24
+ | { readonly step: "done"; readonly backupCodes: readonly string[] }
25
+ | { readonly step: "startError"; readonly error: FlowError }
26
+ | {
27
+ readonly step: "confirmError";
28
+ readonly secret: string;
29
+ readonly qrUri: string;
30
+ readonly expiresIn: number;
31
+ readonly error: FlowError;
32
+ };
33
+
34
+ export interface TotpSetupFlow {
35
+ readonly machine: FlowMachine<TotpSetupState>;
36
+ start(): Promise<void>;
37
+ confirm(code: string): Promise<void>;
38
+ reset(): void;
39
+ }
40
+
41
+ export interface TotpSetupFlowDeps {
42
+ readonly api: AuthApi;
43
+ readonly analytics?: Analytics | null;
44
+ }
45
+
46
+ export function createTotpSetupFlow(deps: TotpSetupFlowDeps): TotpSetupFlow {
47
+ const machine = createFlowMachine<TotpSetupState>({
48
+ id: "auth.totp_setup",
49
+ initial: { step: "idle" },
50
+ analytics: deps.analytics ?? null,
51
+ });
52
+
53
+ async function start(): Promise<void> {
54
+ await machine.run({ step: "starting" }, () => deps.api.totpSetup(), {
55
+ resolve: (r): TotpSetupState => ({
56
+ step: "enrolling",
57
+ secret: r.secret,
58
+ qrUri: r.qr_uri,
59
+ expiresIn: r.expires_in,
60
+ }),
61
+ reject: (error): TotpSetupState => ({
62
+ step: "startError",
63
+ error: toFlowError(error),
64
+ }),
65
+ });
66
+ }
67
+
68
+ async function confirm(code: string): Promise<void> {
69
+ const s = machine.getState();
70
+ if (s.step !== "enrolling" && s.step !== "confirmError") return;
71
+ const { secret, qrUri, expiresIn } = s;
72
+ await machine.run(
73
+ { step: "confirming", secret, qrUri, expiresIn },
74
+ () => deps.api.totpSetupConfirm(code),
75
+ {
76
+ resolve: (r): TotpSetupState => ({
77
+ step: "done",
78
+ backupCodes: r.backup_codes,
79
+ }),
80
+ reject: (error): TotpSetupState => ({
81
+ step: "confirmError",
82
+ secret,
83
+ qrUri,
84
+ expiresIn,
85
+ error: toFlowError(error),
86
+ }),
87
+ }
88
+ );
89
+ }
90
+
91
+ function reset(): void {
92
+ machine.to({ step: "idle" });
93
+ }
94
+
95
+ return { machine, start, confirm, reset };
96
+ }
@@ -0,0 +1,16 @@
1
+ import { useSyncExternalStore } from "react";
2
+ import type { FlowMachine, FlowStateBase } from "./createFlowMachine.js";
3
+
4
+ /**
5
+ * React binding for a {@link FlowMachine}: subscribes to transitions and
6
+ * returns the current state. Because the machine stores immutable state
7
+ * snapshots, `getState` is a stable reference read — no tearing under
8
+ * concurrent React.
9
+ */
10
+ export function useFlow<S extends FlowStateBase>(machine: FlowMachine<S>): S {
11
+ return useSyncExternalStore(
12
+ machine.subscribe,
13
+ machine.getState,
14
+ machine.getState
15
+ );
16
+ }