@alepha/ui 0.12.0 → 0.13.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 (164) hide show
  1. package/README.md +2 -30
  2. package/dist/admin/AdminFiles-BM6_7_5A.cjs +4 -0
  3. package/dist/admin/AdminFiles-BaCIMeNt.js +4 -0
  4. package/dist/admin/AdminFiles-CllAxb1B.js +117 -0
  5. package/dist/admin/AdminFiles-CllAxb1B.js.map +1 -0
  6. package/dist/admin/AdminFiles-DC3T8uWZ.cjs +122 -0
  7. package/dist/admin/AdminFiles-DC3T8uWZ.cjs.map +1 -0
  8. package/dist/admin/AdminJobs-BXkFtlVo.js +125 -0
  9. package/dist/admin/AdminJobs-BXkFtlVo.js.map +1 -0
  10. package/dist/admin/AdminJobs-C428qrNQ.cjs +130 -0
  11. package/dist/admin/AdminJobs-C428qrNQ.cjs.map +1 -0
  12. package/dist/admin/AdminJobs-DCPPaJ4i.cjs +4 -0
  13. package/dist/admin/AdminJobs-yC6DarGO.js +4 -0
  14. package/dist/admin/AdminLayout-Bqo4cd33.cjs +4 -0
  15. package/dist/admin/AdminLayout-CQpxfko6.js +4 -0
  16. package/dist/admin/AdminLayout-CiLlywAQ.cjs +93 -0
  17. package/dist/admin/AdminLayout-CiLlywAQ.cjs.map +1 -0
  18. package/dist/admin/AdminLayout-CtkVYk-u.js +88 -0
  19. package/dist/admin/AdminLayout-CtkVYk-u.js.map +1 -0
  20. package/dist/admin/AdminNotifications-DNUeJ-PW.cjs +44 -0
  21. package/dist/admin/AdminNotifications-DNUeJ-PW.cjs.map +1 -0
  22. package/dist/admin/AdminNotifications-DaMu1AQ4.js +4 -0
  23. package/dist/admin/AdminNotifications-DnnulNNV.js +40 -0
  24. package/dist/admin/AdminNotifications-DnnulNNV.js.map +1 -0
  25. package/dist/admin/AdminNotifications-ihgbKVCx.cjs +4 -0
  26. package/dist/admin/AdminParameters-B3hvpLpu.js +40 -0
  27. package/dist/admin/AdminParameters-B3hvpLpu.js.map +1 -0
  28. package/dist/admin/AdminParameters-U4lU1rUF.cjs +4 -0
  29. package/dist/admin/AdminParameters-gdf7036N.cjs +44 -0
  30. package/dist/admin/AdminParameters-gdf7036N.cjs.map +1 -0
  31. package/dist/admin/AdminParameters-prMcCgxf.js +4 -0
  32. package/dist/admin/AdminSessions-BF_P4lHs.cjs +128 -0
  33. package/dist/admin/AdminSessions-BF_P4lHs.cjs.map +1 -0
  34. package/dist/admin/AdminSessions-CATIU61I.cjs +4 -0
  35. package/dist/admin/AdminSessions-DqOXOpYR.js +4 -0
  36. package/dist/admin/AdminSessions-Pjdz-iZx.js +123 -0
  37. package/dist/admin/AdminSessions-Pjdz-iZx.js.map +1 -0
  38. package/dist/admin/AdminUsers-BgTL-zSY.js +4 -0
  39. package/dist/admin/AdminUsers-C1HsrRxn.js +104 -0
  40. package/dist/admin/AdminUsers-C1HsrRxn.js.map +1 -0
  41. package/dist/admin/AdminUsers-HqvxwNGZ.cjs +4 -0
  42. package/dist/admin/AdminUsers-M2uEQbp5.cjs +109 -0
  43. package/dist/admin/AdminUsers-M2uEQbp5.cjs.map +1 -0
  44. package/dist/admin/AdminVerifications-BVssbtfU.cjs +44 -0
  45. package/dist/admin/AdminVerifications-BVssbtfU.cjs.map +1 -0
  46. package/dist/admin/AdminVerifications-Df6DRgNo.js +4 -0
  47. package/dist/admin/AdminVerifications-DxAtcYUR.cjs +4 -0
  48. package/dist/admin/AdminVerifications-VMpm30mS.js +40 -0
  49. package/dist/admin/AdminVerifications-VMpm30mS.js.map +1 -0
  50. package/dist/admin/core-CzO6aavT.js +2507 -0
  51. package/dist/admin/core-CzO6aavT.js.map +1 -0
  52. package/dist/{index.cjs → admin/core-aFtK4l9I.cjs} +287 -204
  53. package/dist/admin/core-aFtK4l9I.cjs.map +1 -0
  54. package/dist/admin/index.cjs +87 -0
  55. package/dist/admin/index.cjs.map +1 -0
  56. package/dist/admin/index.d.cts +1739 -0
  57. package/dist/admin/index.d.ts +1745 -0
  58. package/dist/admin/index.js +78 -0
  59. package/dist/admin/index.js.map +1 -0
  60. package/dist/auth/IconGoogle-B17BTQyD.cjs +69 -0
  61. package/dist/auth/IconGoogle-B17BTQyD.cjs.map +1 -0
  62. package/dist/auth/IconGoogle-Bfmuv9Rv.js +58 -0
  63. package/dist/auth/IconGoogle-Bfmuv9Rv.js.map +1 -0
  64. package/dist/auth/Login-BTBmbnWl.cjs +181 -0
  65. package/dist/auth/Login-BTBmbnWl.cjs.map +1 -0
  66. package/dist/auth/Login-BcQOtG3v.js +5 -0
  67. package/dist/auth/Login-Btmd70Um.cjs +5 -0
  68. package/dist/auth/Login-JeXFsUf5.js +176 -0
  69. package/dist/auth/Login-JeXFsUf5.js.map +1 -0
  70. package/dist/auth/Register-CPQnvXCZ.js +318 -0
  71. package/dist/auth/Register-CPQnvXCZ.js.map +1 -0
  72. package/dist/auth/Register-CbesZal3.cjs +5 -0
  73. package/dist/auth/Register-DpI_JdyO.js +5 -0
  74. package/dist/auth/Register-HP3rP71B.cjs +323 -0
  75. package/dist/auth/Register-HP3rP71B.cjs.map +1 -0
  76. package/dist/auth/ResetPassword-B-tkzV7g.cjs +248 -0
  77. package/dist/auth/ResetPassword-B-tkzV7g.cjs.map +1 -0
  78. package/dist/auth/ResetPassword-BlK3xEpU.js +4 -0
  79. package/dist/auth/ResetPassword-BzUjGG_-.js +243 -0
  80. package/dist/auth/ResetPassword-BzUjGG_-.js.map +1 -0
  81. package/dist/auth/ResetPassword-W3xjOnWy.cjs +4 -0
  82. package/dist/auth/chunk-DhGyd7sr.js +28 -0
  83. package/dist/auth/core-D1MHij1j.js +1795 -0
  84. package/dist/auth/core-D1MHij1j.js.map +1 -0
  85. package/dist/auth/core-rDZ9d92K.cjs +1824 -0
  86. package/dist/auth/core-rDZ9d92K.cjs.map +1 -0
  87. package/dist/auth/index.cjs +211 -0
  88. package/dist/auth/index.cjs.map +1 -0
  89. package/dist/auth/index.d.cts +6265 -0
  90. package/dist/auth/index.d.ts +6274 -0
  91. package/dist/auth/index.js +206 -0
  92. package/dist/auth/index.js.map +1 -0
  93. package/dist/core/index.cjs +2620 -0
  94. package/dist/core/index.cjs.map +1 -0
  95. package/dist/core/index.d.cts +2737 -0
  96. package/dist/core/index.d.ts +2743 -0
  97. package/dist/{index.js → core/index.js} +298 -126
  98. package/dist/core/index.js.map +1 -0
  99. package/package.json +32 -14
  100. package/src/admin/AdminRouter.ts +58 -0
  101. package/src/admin/components/AdminFiles.tsx +117 -0
  102. package/src/admin/components/AdminJobs.tsx +158 -0
  103. package/src/admin/components/AdminLayout.tsx +114 -0
  104. package/src/admin/components/AdminNotifications.tsx +20 -0
  105. package/src/admin/components/AdminParameters.tsx +24 -0
  106. package/src/admin/components/AdminSessions.tsx +159 -0
  107. package/src/admin/components/AdminUsers.tsx +137 -0
  108. package/src/admin/components/AdminVerifications.tsx +25 -0
  109. package/src/admin/index.ts +29 -0
  110. package/src/auth/AuthI18n.ts +118 -0
  111. package/src/auth/AuthRouter.ts +53 -0
  112. package/src/auth/components/Login.tsx +193 -0
  113. package/src/auth/components/Register.tsx +421 -0
  114. package/src/auth/components/ResetPassword.tsx +259 -0
  115. package/src/auth/components/buttons/UserButton.tsx +118 -0
  116. package/src/auth/components/icons/IconGithub.tsx +21 -0
  117. package/src/auth/components/icons/IconGoogle.tsx +30 -0
  118. package/src/auth/index.ts +27 -0
  119. package/src/{RootRouter.ts → core/RootRouter.ts} +2 -1
  120. package/src/{components → core/components}/buttons/ActionButton.tsx +49 -6
  121. package/src/core/components/buttons/ClipboardButton.tsx +56 -0
  122. package/src/{components → core/components}/buttons/DarkModeButton.tsx +7 -8
  123. package/src/{components → core/components}/buttons/LanguageButton.tsx +2 -2
  124. package/src/{components → core/components}/buttons/OmnibarButton.tsx +1 -1
  125. package/src/{components → core/components}/dialogs/AlertDialog.tsx +1 -1
  126. package/src/{components → core/components}/dialogs/ConfirmDialog.tsx +1 -1
  127. package/src/{components → core/components}/dialogs/PromptDialog.tsx +1 -1
  128. package/src/{components → core/components}/form/Control.tsx +1 -0
  129. package/src/{components → core/components}/layout/AdminShell.tsx +38 -7
  130. package/src/{components → core/components}/layout/AlephaMantineProvider.tsx +12 -8
  131. package/src/{components → core/components}/layout/AppBar.tsx +1 -1
  132. package/src/{components → core/components}/layout/Omnibar.tsx +1 -1
  133. package/src/{components → core/components}/layout/Sidebar.tsx +29 -26
  134. package/src/{components → core/components}/table/DataTable.tsx +1 -1
  135. package/src/{constants → core/constants}/ui.ts +9 -0
  136. package/src/{index.ts → core/index.ts} +3 -0
  137. package/src/{services → core/services}/DialogService.tsx +3 -3
  138. package/src/{services → core/services}/ToastService.tsx +3 -1
  139. package/src/{utils → core/utils}/extractSchemaFields.ts +2 -8
  140. package/src/{utils → core/utils}/icons.tsx +5 -15
  141. package/src/{utils → core/utils}/parseInput.ts +34 -26
  142. package/dist/AlephaMantineProvider-CGpgWDt8.cjs +0 -3
  143. package/dist/AlephaMantineProvider-D8cHYAge.js +0 -152
  144. package/dist/AlephaMantineProvider-D8cHYAge.js.map +0 -1
  145. package/dist/AlephaMantineProvider-DuvZFAuk.cjs +0 -175
  146. package/dist/AlephaMantineProvider-DuvZFAuk.cjs.map +0 -1
  147. package/dist/AlephaMantineProvider-twBqV4IO.js +0 -3
  148. package/dist/index.cjs.map +0 -1
  149. package/dist/index.d.cts +0 -821
  150. package/dist/index.d.cts.map +0 -1
  151. package/dist/index.d.ts +0 -821
  152. package/dist/index.d.ts.map +0 -1
  153. package/dist/index.js.map +0 -1
  154. /package/src/{components → core/components}/buttons/BurgerButton.tsx +0 -0
  155. /package/src/{components → core/components}/buttons/ToggleSidebarButton.tsx +0 -0
  156. /package/src/{components → core/components}/data/JsonViewer.tsx +0 -0
  157. /package/src/{components → core/components}/form/ControlDate.tsx +0 -0
  158. /package/src/{components → core/components}/form/ControlNumber.tsx +0 -0
  159. /package/src/{components → core/components}/form/ControlQueryBuilder.tsx +0 -0
  160. /package/src/{components → core/components}/form/ControlSelect.tsx +0 -0
  161. /package/src/{components → core/components}/form/TypeForm.tsx +0 -0
  162. /package/src/{hooks → core/hooks}/useDialog.ts +0 -0
  163. /package/src/{hooks → core/hooks}/useToast.ts +0 -0
  164. /package/src/{utils → core/utils}/string.ts +0 -0
@@ -0,0 +1,421 @@
1
+ import { useClient, useRouter } from "@alepha/react";
2
+ import { useAuth } from "@alepha/react/auth";
3
+ import { useForm } from "@alepha/react/form";
4
+ import { useI18n } from "@alepha/react/i18n";
5
+ import { ActionButton, Control, capitalize } from "@alepha/ui";
6
+ import { Alert, Card, Flex, Group, PinInput, Stack, Text } from "@mantine/core";
7
+ import {
8
+ IconAlertCircle,
9
+ IconLock,
10
+ IconMail,
11
+ IconPhone,
12
+ IconUser,
13
+ } from "@tabler/icons-react";
14
+ import { TypeBoxError, t } from "alepha";
15
+ import type {
16
+ RegistrationIntentResponse,
17
+ UserController,
18
+ UserRealmConfig,
19
+ } from "alepha/api/users";
20
+ import { useMemo, useState } from "react";
21
+ import type { AuthI18n } from "../AuthI18n.ts";
22
+ import type { AuthRouter } from "../AuthRouter.ts";
23
+ import IconGithub from "./icons/IconGithub.tsx";
24
+ import IconGoogle from "./icons/IconGoogle.tsx";
25
+
26
+ export interface RegisterProps {
27
+ realmConfig: UserRealmConfig;
28
+ }
29
+
30
+ type RegistrationPhase = "form" | "verification";
31
+
32
+ interface RegistrationState {
33
+ phase: RegistrationPhase;
34
+ intent?: RegistrationIntentResponse;
35
+ credentials?: {
36
+ identifier: string;
37
+ password: string;
38
+ };
39
+ }
40
+
41
+ const Register = (props: RegisterProps) => {
42
+ const auth = useAuth();
43
+ const userCtrl = useClient<UserController>();
44
+ const router = useRouter<AuthRouter>();
45
+ const { tr } = useI18n<AuthI18n, "en">();
46
+ const redirect = router.query.redirect || "/";
47
+
48
+ const [registrationState, setRegistrationState] = useState<RegistrationState>(
49
+ {
50
+ phase: "form",
51
+ },
52
+ );
53
+ const [emailCode, setEmailCode] = useState("");
54
+ const [phoneCode, setPhoneCode] = useState("");
55
+ const [verificationError, setVerificationError] = useState<string | null>(
56
+ null,
57
+ );
58
+ const [isSubmitting, setIsSubmitting] = useState(false);
59
+
60
+ const hasUsernamePassword = props.realmConfig.authenticationMethods.find(
61
+ (it) => it.type === "CREDENTIALS",
62
+ );
63
+
64
+ const settings = props.realmConfig.settings || {};
65
+ const isRegistrationAllowed = settings.registrationAllowed !== false;
66
+
67
+ const registerSchema = useMemo(() => {
68
+ const registerSchema = t.object({
69
+ username: t.optional(t.text()),
70
+ email: t.optional(t.email()),
71
+ phoneNumber: t.optional(t.e164()),
72
+ password: t.string({ minLength: 8 }),
73
+ confirmPassword: t.string({ minLength: 8 }),
74
+ });
75
+
76
+ const required = registerSchema.required as string[];
77
+
78
+ if (settings.usernameRequired) required.push("username");
79
+ if (settings.emailRequired) required.push("email");
80
+ if (settings.phoneRequired) required.push("phoneNumber");
81
+
82
+ return registerSchema;
83
+ }, []);
84
+
85
+ const form = useForm({
86
+ schema: registerSchema,
87
+ handler: async (data) => {
88
+ if (data.password !== data.confirmPassword) {
89
+ throw new TypeBoxError({
90
+ message: "Passwords do not match",
91
+ instancePath: "/confirmPassword",
92
+ keyword: "not",
93
+ schemaPath: "",
94
+ params: {},
95
+ });
96
+ }
97
+
98
+ // Phase 1: Create registration intent
99
+ const intent = await userCtrl.createRegistrationIntent({
100
+ body: {
101
+ username: data.username,
102
+ email: data.email,
103
+ phoneNumber: data.phoneNumber,
104
+ password: data.password,
105
+ },
106
+ });
107
+
108
+ const identifier = data.username ?? data.email ?? data.phoneNumber;
109
+
110
+ // Check if verification is needed
111
+ if (
112
+ intent.expectEmailVerification ||
113
+ intent.expectPhoneVerification ||
114
+ intent.expectCaptcha
115
+ ) {
116
+ // Move to verification phase
117
+ setRegistrationState({
118
+ phase: "verification",
119
+ intent,
120
+ credentials: identifier
121
+ ? { identifier, password: data.password }
122
+ : undefined,
123
+ });
124
+ return;
125
+ }
126
+
127
+ // No verification needed - complete registration immediately
128
+ await userCtrl.createUserFromIntent({
129
+ body: { intentId: intent.intentId },
130
+ });
131
+
132
+ // Auto-login after registration
133
+ if (identifier) {
134
+ await auth.login("credentials", {
135
+ username: identifier,
136
+ password: data.password,
137
+ });
138
+ }
139
+
140
+ await router.go(router.query.r || "/");
141
+ },
142
+ });
143
+
144
+ const handleVerificationSubmit = async () => {
145
+ if (!registrationState.intent) return;
146
+
147
+ setIsSubmitting(true);
148
+ setVerificationError(null);
149
+
150
+ try {
151
+ // Phase 2: Complete registration with verification codes
152
+ await userCtrl.createUserFromIntent({
153
+ body: {
154
+ intentId: registrationState.intent.intentId,
155
+ emailCode: registrationState.intent.expectEmailVerification
156
+ ? emailCode
157
+ : undefined,
158
+ phoneCode: registrationState.intent.expectPhoneVerification
159
+ ? phoneCode
160
+ : undefined,
161
+ },
162
+ });
163
+
164
+ // Auto-login after registration
165
+ if (registrationState.credentials) {
166
+ await auth.login("credentials", {
167
+ username: registrationState.credentials.identifier,
168
+ password: registrationState.credentials.password,
169
+ });
170
+ }
171
+
172
+ await router.go(router.query.r || "/");
173
+ } catch (error) {
174
+ setVerificationError(
175
+ error instanceof Error ? error.message : "Verification failed",
176
+ );
177
+ } finally {
178
+ setIsSubmitting(false);
179
+ }
180
+ };
181
+
182
+ const canSubmitVerification = () => {
183
+ if (!registrationState.intent) return false;
184
+
185
+ if (
186
+ registrationState.intent.expectEmailVerification &&
187
+ emailCode.length !== 6
188
+ ) {
189
+ return false;
190
+ }
191
+
192
+ if (
193
+ registrationState.intent.expectPhoneVerification &&
194
+ phoneCode.length !== 6
195
+ ) {
196
+ return false;
197
+ }
198
+
199
+ return true;
200
+ };
201
+
202
+ // Verification phase UI
203
+ if (registrationState.phase === "verification" && registrationState.intent) {
204
+ return (
205
+ <Flex flex={1} justify={"center"} align={"center"}>
206
+ <Stack gap={"sm"} w={360}>
207
+ <Card withBorder p={"lg"} bg={"var(--alepha-elevated)"}>
208
+ <Stack gap={"md"}>
209
+ <Text size="lg" fw={500} ta="center">
210
+ {tr("registerVerifyTitle") ?? "Verify your account"}
211
+ </Text>
212
+ <Text size="sm" c="dimmed" ta="center">
213
+ {tr("registerVerifyDescription") ??
214
+ "Please enter the verification code(s) sent to you."}
215
+ </Text>
216
+
217
+ {verificationError && (
218
+ <Alert variant="light" color="red" icon={<IconAlertCircle />}>
219
+ <Text size="sm">{verificationError}</Text>
220
+ </Alert>
221
+ )}
222
+
223
+ {registrationState.intent.expectEmailVerification && (
224
+ <Stack gap={"xs"}>
225
+ <Text size="sm" fw={500}>
226
+ {tr("registerEmailCode") ?? "Email verification code"}
227
+ </Text>
228
+ <Flex justify="center">
229
+ <PinInput
230
+ length={6}
231
+ value={emailCode}
232
+ onChange={setEmailCode}
233
+ type="number"
234
+ oneTimeCode
235
+ aria-label="Email verification code"
236
+ />
237
+ </Flex>
238
+ </Stack>
239
+ )}
240
+
241
+ {registrationState.intent.expectPhoneVerification && (
242
+ <Stack gap={"xs"}>
243
+ <Text size="sm" fw={500}>
244
+ {tr("registerPhoneCode") ?? "Phone verification code"}
245
+ </Text>
246
+ <Flex justify="center">
247
+ <PinInput
248
+ length={6}
249
+ value={phoneCode}
250
+ onChange={setPhoneCode}
251
+ type="number"
252
+ oneTimeCode
253
+ aria-label="Phone verification code"
254
+ />
255
+ </Flex>
256
+ </Stack>
257
+ )}
258
+
259
+ <ActionButton
260
+ onClick={handleVerificationSubmit}
261
+ loading={isSubmitting}
262
+ disabled={!canSubmitVerification()}
263
+ >
264
+ {tr("registerVerifySubmit") ?? "Complete Registration"}
265
+ </ActionButton>
266
+
267
+ <ActionButton
268
+ variant="subtle"
269
+ onClick={() =>
270
+ setRegistrationState({ phase: "form", intent: undefined })
271
+ }
272
+ >
273
+ {tr("registerVerifyBack") ?? "Back to registration"}
274
+ </ActionButton>
275
+ </Stack>
276
+ </Card>
277
+ </Stack>
278
+ </Flex>
279
+ );
280
+ }
281
+
282
+ // Registration form phase UI
283
+ return (
284
+ <Flex flex={1} justify={"center"} align={"center"}>
285
+ <Stack gap={"sm"} w={360}>
286
+ <Card withBorder p={"lg"} bg={"var(--alepha-elevated)"}>
287
+ <Stack gap={"md"}>
288
+ {!isRegistrationAllowed ? (
289
+ <>
290
+ <Alert
291
+ variant="light"
292
+ color="yellow"
293
+ icon={<IconAlertCircle />}
294
+ >
295
+ <Text size="sm">{tr("registerDisabled")}</Text>
296
+ </Alert>
297
+ <ActionButton href={router.path("login")}>
298
+ {tr("registerBackToSignIn")}
299
+ </ActionButton>
300
+ </>
301
+ ) : hasUsernamePassword ? (
302
+ <>
303
+ <form {...form.props}>
304
+ <Stack flex={1} gap={"md"}>
305
+ {settings.usernameEnabled !== false &&
306
+ form.input.username && (
307
+ <Control
308
+ title={tr("registerUsername")}
309
+ input={form.input.username}
310
+ icon={<IconUser />}
311
+ text={{
312
+ autoComplete: "username",
313
+ }}
314
+ />
315
+ )}
316
+ {settings.emailEnabled !== false && form.input.email && (
317
+ <Control
318
+ title={tr("registerEmail")}
319
+ input={form.input.email}
320
+ icon={<IconMail />}
321
+ text={{
322
+ autoComplete: "email",
323
+ }}
324
+ />
325
+ )}
326
+ {settings.phoneEnabled === true &&
327
+ form.input.phoneNumber && (
328
+ <Control
329
+ title={tr("registerPhone")}
330
+ input={form.input.phoneNumber}
331
+ icon={<IconPhone />}
332
+ text={{
333
+ autoComplete: "tel",
334
+ }}
335
+ />
336
+ )}
337
+ <Control
338
+ title={tr("registerPassword")}
339
+ input={form.input.password}
340
+ icon={<IconLock />}
341
+ password={{
342
+ autoComplete: "new-password",
343
+ }}
344
+ />
345
+ <Control
346
+ title={tr("registerConfirmPassword")}
347
+ input={form.input.confirmPassword}
348
+ icon={<IconLock />}
349
+ password={{
350
+ autoComplete: "new-password",
351
+ }}
352
+ />
353
+ <ActionButton form={form}>
354
+ {tr("registerCreateAccount")}
355
+ </ActionButton>
356
+ </Stack>
357
+ </form>
358
+ <Group align="center" justify="center" gap={"md"}>
359
+ <Flex flex={1} h={"1px"} bg={"var(--alepha-text-muted)"} />
360
+ <Text size="xs">{tr("registerOr")}</Text>
361
+ <Flex flex={1} h={"1px"} bg={"var(--alepha-text-muted)"} />
362
+ </Group>
363
+ </>
364
+ ) : null}
365
+ {isRegistrationAllowed && (
366
+ <>
367
+ <Stack gap={"sm"}>
368
+ {props.realmConfig.authenticationMethods.map(
369
+ (method) =>
370
+ method.type !== "CREDENTIALS" && (
371
+ <ActionButton
372
+ variant={"default"}
373
+ key={method.type}
374
+ leftSection={leftSection(method.name.toLowerCase())}
375
+ onClick={() =>
376
+ auth.login(method.name, {
377
+ redirect,
378
+ })
379
+ }
380
+ >
381
+ {tr("registerContinueWith", {
382
+ args: [capitalize(method.name)],
383
+ })}
384
+ </ActionButton>
385
+ ),
386
+ )}
387
+ </Stack>
388
+ {props.realmConfig.authenticationMethods.length > 0 && (
389
+ <Text size="sm" ta="center">
390
+ {tr("registerHaveAccount")}{" "}
391
+ <ActionButton
392
+ href={router.path("login")}
393
+ anchorProps={{ inherit: true }}
394
+ >
395
+ {tr("registerSignIn")}
396
+ </ActionButton>
397
+ </Text>
398
+ )}
399
+ </>
400
+ )}
401
+ </Stack>
402
+ </Card>
403
+ <ActionButton variant={"subtle"} href={redirect}>
404
+ {tr("registerCancel")}
405
+ </ActionButton>
406
+ </Stack>
407
+ </Flex>
408
+ );
409
+ };
410
+
411
+ export default Register;
412
+
413
+ const leftSection = (name: string) => {
414
+ if (name === "google") {
415
+ return <IconGoogle />;
416
+ }
417
+
418
+ if (name === "github") {
419
+ return <IconGithub />;
420
+ }
421
+ };
@@ -0,0 +1,259 @@
1
+ import { useClient, useRouter } from "@alepha/react";
2
+ import { useForm } from "@alepha/react/form";
3
+ import { useI18n } from "@alepha/react/i18n";
4
+ import { ActionButton, Control } from "@alepha/ui";
5
+ import { Alert, Card, Flex, PinInput, Stack, Text } from "@mantine/core";
6
+ import {
7
+ IconAlertCircle,
8
+ IconCheck,
9
+ IconInfoCircle,
10
+ IconLock,
11
+ IconMail,
12
+ } from "@tabler/icons-react";
13
+ import { AlephaError, t } from "alepha";
14
+ import type {
15
+ PasswordResetIntentResponse,
16
+ UserController,
17
+ UserRealmConfig,
18
+ } from "alepha/api/users";
19
+ import { resetPasswordRequestSchema } from "alepha/api/users";
20
+ import { useState } from "react";
21
+ import type { AuthI18n } from "../AuthI18n.ts";
22
+ import type { AuthRouter } from "../AuthRouter.ts";
23
+
24
+ export interface ResetPasswordProps {
25
+ realmConfig: UserRealmConfig;
26
+ }
27
+
28
+ type Step = "email" | "code" | "password" | "success";
29
+
30
+ interface ResetState {
31
+ step: Step;
32
+ intent?: PasswordResetIntentResponse;
33
+ email?: string;
34
+ code?: string;
35
+ }
36
+
37
+ const ResetPassword = (props: ResetPasswordProps) => {
38
+ const router = useRouter<AuthRouter>();
39
+ const userCtrl = useClient<UserController>();
40
+ const { tr } = useI18n<AuthI18n, "en">();
41
+ const [resetState, setResetState] = useState<ResetState>({ step: "email" });
42
+ const [error, setError] = useState<string | null>(null);
43
+ const [isSubmitting, setIsSubmitting] = useState(false);
44
+ const redirect = router.query.redirect || "/";
45
+
46
+ const isResetPasswordAllowed =
47
+ props.realmConfig.settings?.resetPasswordAllowed !== false;
48
+
49
+ // Phase 1: Request password reset intent
50
+ const emailForm = useForm({
51
+ schema: resetPasswordRequestSchema,
52
+ handler: async (data) => {
53
+ setError(null);
54
+ const intent = await userCtrl.createPasswordResetIntent({
55
+ body: { email: data.email },
56
+ });
57
+
58
+ setResetState({
59
+ step: "code",
60
+ intent,
61
+ email: data.email,
62
+ });
63
+ },
64
+ });
65
+
66
+ // Phase 2: Complete password reset
67
+ const passwordForm = useForm(
68
+ {
69
+ schema: t.object({
70
+ password: t.string({ minLength: 8 }),
71
+ confirmPassword: t.string({ minLength: 8 }),
72
+ }),
73
+ handler: async (data) => {
74
+ if (data.password !== data.confirmPassword) {
75
+ throw new AlephaError("Passwords do not match");
76
+ }
77
+
78
+ if (!resetState.intent || !resetState.code) {
79
+ throw new AlephaError("Invalid reset state");
80
+ }
81
+
82
+ await userCtrl.completePasswordReset({
83
+ body: {
84
+ intentId: resetState.intent.intentId,
85
+ code: resetState.code,
86
+ newPassword: data.password,
87
+ },
88
+ });
89
+
90
+ setResetState({ step: "success" });
91
+ },
92
+ },
93
+ [resetState.intent, resetState.code],
94
+ );
95
+
96
+ const handleCodeComplete = (value: string) => {
97
+ if (value.length === 6) {
98
+ setResetState((prev) => ({
99
+ ...prev,
100
+ step: "password",
101
+ code: value,
102
+ }));
103
+ }
104
+ };
105
+
106
+ const handleResendCode = async () => {
107
+ if (!resetState.email) return;
108
+
109
+ setIsSubmitting(true);
110
+ setError(null);
111
+
112
+ try {
113
+ const intent = await userCtrl.createPasswordResetIntent({
114
+ body: { email: resetState.email },
115
+ });
116
+
117
+ setResetState((prev) => ({
118
+ ...prev,
119
+ intent,
120
+ }));
121
+ } catch (err) {
122
+ setError(err instanceof Error ? err.message : "Failed to resend code");
123
+ } finally {
124
+ setIsSubmitting(false);
125
+ }
126
+ };
127
+
128
+ return (
129
+ <Flex flex={1} justify={"center"} align={"center"}>
130
+ <Stack gap={"sm"} w={360}>
131
+ <Card withBorder p={"lg"} bg={"var(--alepha-elevated)"}>
132
+ <Stack gap={"md"}>
133
+ {error && (
134
+ <Alert variant="light" color="red" icon={<IconAlertCircle />}>
135
+ <Text size="sm">{error}</Text>
136
+ </Alert>
137
+ )}
138
+
139
+ {!isResetPasswordAllowed ? (
140
+ <>
141
+ <Alert
142
+ variant="light"
143
+ color="yellow"
144
+ icon={<IconAlertCircle />}
145
+ >
146
+ <Text size="sm">{tr("resetPasswordDisabled")}</Text>
147
+ </Alert>
148
+ <ActionButton href={router.path("login")}>
149
+ {tr("resetPasswordBackToSignIn")}
150
+ </ActionButton>
151
+ </>
152
+ ) : resetState.step === "email" ? (
153
+ <form {...emailForm.props}>
154
+ <Stack flex={1} gap={"md"}>
155
+ <Text size="lg" fw={500} ta="center">
156
+ {tr("resetPasswordTitle")}
157
+ </Text>
158
+ <Text size="sm" c="dimmed">
159
+ {tr("resetPasswordEnterEmail")}
160
+ </Text>
161
+ <Control
162
+ title={tr("resetPasswordEmail")}
163
+ input={emailForm.input.email}
164
+ icon={<IconMail />}
165
+ text={{
166
+ autoComplete: "email",
167
+ autoFocus: true,
168
+ disabled: !isResetPasswordAllowed,
169
+ }}
170
+ />
171
+ <ActionButton
172
+ form={emailForm}
173
+ disabled={!isResetPasswordAllowed}
174
+ >
175
+ {tr("resetPasswordSendCode")}
176
+ </ActionButton>
177
+ </Stack>
178
+ </form>
179
+ ) : resetState.step === "code" ? (
180
+ <Stack gap={"md"}>
181
+ <Text size="lg" fw={500} ta="center">
182
+ {tr("resetPasswordTitle")}
183
+ </Text>
184
+ <Alert variant="light" color="blue" icon={<IconInfoCircle />}>
185
+ <Text size="sm">{tr("resetPasswordCodeSent")}</Text>
186
+ </Alert>
187
+ <Text size="sm" c="dimmed" ta="center">
188
+ {tr("resetPasswordEnterCode")}
189
+ </Text>
190
+ <Flex justify="center">
191
+ <PinInput
192
+ length={6}
193
+ type="number"
194
+ autoFocus
195
+ oneTimeCode
196
+ onComplete={handleCodeComplete}
197
+ aria-label="Password reset verification code"
198
+ />
199
+ </Flex>
200
+ <ActionButton
201
+ variant="subtle"
202
+ onClick={handleResendCode}
203
+ loading={isSubmitting}
204
+ >
205
+ {tr("resetPasswordResendCode")}
206
+ </ActionButton>
207
+ </Stack>
208
+ ) : resetState.step === "password" ? (
209
+ <form {...passwordForm.props}>
210
+ <Stack flex={1} gap={"md"}>
211
+ <Text size="lg" fw={500} ta="center">
212
+ {tr("resetPasswordTitle")}
213
+ </Text>
214
+ <Text size="sm" c="dimmed">
215
+ {tr("resetPasswordEnterNewPassword")}
216
+ </Text>
217
+ <Control
218
+ title={tr("resetPasswordNewPassword")}
219
+ input={passwordForm.input.password}
220
+ icon={<IconLock />}
221
+ password={{
222
+ autoComplete: "new-password",
223
+ autoFocus: true,
224
+ }}
225
+ />
226
+ <Control
227
+ title={tr("resetPasswordConfirmPassword")}
228
+ input={passwordForm.input.confirmPassword}
229
+ icon={<IconLock />}
230
+ password={{
231
+ autoComplete: "new-password",
232
+ }}
233
+ />
234
+ <ActionButton form={passwordForm}>
235
+ {tr("resetPasswordSetNewPassword")}
236
+ </ActionButton>
237
+ </Stack>
238
+ </form>
239
+ ) : (
240
+ <>
241
+ <Alert variant="light" color="green" icon={<IconCheck />}>
242
+ <Text size="sm">{tr("resetPasswordSuccess")}</Text>
243
+ </Alert>
244
+ <ActionButton href={router.path("login")}>
245
+ {tr("resetPasswordBackToSignIn")}
246
+ </ActionButton>
247
+ </>
248
+ )}
249
+ </Stack>
250
+ </Card>
251
+ <ActionButton variant={"subtle"} href={redirect}>
252
+ {tr("resetPasswordCancel")}
253
+ </ActionButton>
254
+ </Stack>
255
+ </Flex>
256
+ );
257
+ };
258
+
259
+ export default ResetPassword;