@seedgrid/fe-security 2026.7.0 → 2026.7.2

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 (125) hide show
  1. package/README.md +113 -0
  2. package/dist/client/index.d.ts +4 -0
  3. package/dist/client/index.d.ts.map +1 -0
  4. package/dist/client/index.js +5 -0
  5. package/dist/client/permission-provider.d.ts +18 -0
  6. package/dist/client/permission-provider.d.ts.map +1 -0
  7. package/dist/client/permission-provider.js +57 -0
  8. package/dist/client/pick-list-field.d.ts +15 -0
  9. package/dist/client/pick-list-field.d.ts.map +1 -0
  10. package/dist/client/pick-list-field.js +30 -0
  11. package/dist/client/status-badge.d.ts +6 -0
  12. package/dist/client/status-badge.d.ts.map +1 -0
  13. package/dist/client/status-badge.js +6 -0
  14. package/dist/config.d.ts +34 -0
  15. package/dist/config.d.ts.map +1 -0
  16. package/dist/config.js +4 -0
  17. package/dist/dtos.d.ts +88 -0
  18. package/dist/dtos.d.ts.map +1 -0
  19. package/dist/dtos.js +3 -0
  20. package/dist/index.d.ts +8 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +10 -0
  23. package/dist/menu.d.ts +19 -0
  24. package/dist/menu.d.ts.map +1 -0
  25. package/dist/menu.js +41 -0
  26. package/dist/messages.d.ts +1858 -0
  27. package/dist/messages.d.ts.map +1 -0
  28. package/dist/messages.js +990 -0
  29. package/dist/module.d.ts +3 -0
  30. package/dist/module.d.ts.map +1 -0
  31. package/dist/module.js +37 -0
  32. package/dist/module.json +21 -0
  33. package/dist/paths.d.ts +1 -0
  34. package/dist/paths.d.ts.map +1 -1
  35. package/dist/paths.js +3 -0
  36. package/dist/permissions.d.ts +14 -0
  37. package/dist/permissions.d.ts.map +1 -0
  38. package/dist/permissions.js +39 -0
  39. package/dist/server/action.d.ts +3 -0
  40. package/dist/server/action.d.ts.map +1 -0
  41. package/dist/server/action.js +21 -0
  42. package/dist/server/create-security-server.d.ts +4 -0
  43. package/dist/server/create-security-server.d.ts.map +1 -0
  44. package/dist/server/create-security-server.js +166 -0
  45. package/dist/server/errors.d.ts +13 -0
  46. package/dist/server/errors.d.ts.map +1 -0
  47. package/dist/server/errors.js +26 -0
  48. package/dist/server/index.d.ts +8 -0
  49. package/dist/server/index.d.ts.map +1 -0
  50. package/dist/server/index.js +8 -0
  51. package/dist/server/problem-response.d.ts +3 -0
  52. package/dist/server/problem-response.d.ts.map +1 -0
  53. package/dist/server/problem-response.js +28 -0
  54. package/dist/server/route.d.ts +3 -0
  55. package/dist/server/route.d.ts.map +1 -0
  56. package/dist/server/route.js +22 -0
  57. package/dist/server/to-problem.d.ts +3 -0
  58. package/dist/server/to-problem.d.ts.map +1 -0
  59. package/dist/server/to-problem.js +22 -0
  60. package/dist/server-contract.d.ts +23 -0
  61. package/dist/server-contract.d.ts.map +1 -0
  62. package/dist/server-contract.js +4 -0
  63. package/dist/session-contract.d.ts +36 -0
  64. package/dist/session-contract.d.ts.map +1 -0
  65. package/dist/session-contract.js +8 -0
  66. package/dist/session-result.d.ts +22 -0
  67. package/dist/session-result.d.ts.map +1 -0
  68. package/dist/session-result.js +14 -0
  69. package/package.json +38 -3
  70. package/scaffold/README.md +97 -0
  71. package/scaffold/src/app/api/security/session/2fa/resend/route.ts +8 -0
  72. package/scaffold/src/app/api/security/session/2fa/verify/route.ts +8 -0
  73. package/scaffold/src/app/api/security/session/login/route.ts +7 -0
  74. package/scaffold/src/app/api/security/session/logout/route.ts +5 -0
  75. package/scaffold/src/app/api/security/session/refresh/route.ts +7 -0
  76. package/scaffold/src/app/api/security/users/[publicId]/avatar/route.ts +15 -0
  77. package/scaffold/src/app/auth/2fa/verify/page.tsx +286 -0
  78. package/scaffold/src/app/auth/forgot-password/confirm/page.tsx +96 -0
  79. package/scaffold/src/app/auth/forgot-password/page.tsx +165 -0
  80. package/scaffold/src/app/auth/forgot-password/success/page.tsx +31 -0
  81. package/scaffold/src/app/auth/login/page.tsx +386 -0
  82. package/scaffold/src/app/auth/welcome/page.tsx +90 -0
  83. package/scaffold/src/app/login/page.tsx +7 -0
  84. package/scaffold/src/app/page.tsx +12 -0
  85. package/scaffold/src/app/security/2fa/page.tsx +370 -0
  86. package/scaffold/src/app/security/companies/(create)/new/page.tsx +12 -0
  87. package/scaffold/src/app/security/companies/(edit)/[publicId]/page.tsx +18 -0
  88. package/scaffold/src/app/security/companies/(list)/page.tsx +298 -0
  89. package/scaffold/src/app/security/companies/editors/CompanyEditor.tsx +754 -0
  90. package/scaffold/src/app/security/profile/page.tsx +492 -0
  91. package/scaffold/src/app/security/roles/(create)/new/page.tsx +11 -0
  92. package/scaffold/src/app/security/roles/(edit)/[publicId]/page.tsx +17 -0
  93. package/scaffold/src/app/security/roles/(list)/page.tsx +269 -0
  94. package/scaffold/src/app/security/roles/editors/RoleEditor.tsx +328 -0
  95. package/scaffold/src/app/security/users/(create)/new/page.tsx +10 -0
  96. package/scaffold/src/app/security/users/(edit)/[publicId]/page.tsx +16 -0
  97. package/scaffold/src/app/security/users/(list)/page.tsx +289 -0
  98. package/scaffold/src/app/security/users/editors/UserEditor.tsx +478 -0
  99. package/scaffold/src/app/users/change-password/page.tsx +172 -0
  100. package/scaffold/src/modules/security/AuthProvider.tsx +405 -0
  101. package/scaffold/src/modules/security/components/AppShell.tsx.hbs +889 -0
  102. package/scaffold/src/modules/security/components/CompanyManagementGuard.tsx +59 -0
  103. package/scaffold/src/modules/security/components/PageFrame.tsx +105 -0
  104. package/scaffold/src/modules/security/components/RouteGuard.tsx +114 -0
  105. package/scaffold/src/modules/security/components/SecurityDashboard.tsx.hbs +315 -0
  106. package/scaffold/src/modules/security/config.ts +55 -0
  107. package/scaffold/src/modules/security/hooks/useAuth.ts +7 -0
  108. package/scaffold/src/modules/security/hooks/useDebouncedSearch.ts +13 -0
  109. package/scaffold/src/modules/security/hooks/usePermission.ts +35 -0
  110. package/scaffold/src/modules/security/host-access.ts +59 -0
  111. package/scaffold/src/modules/security/index.ts +44 -0
  112. package/scaffold/src/modules/security/navigation.ts +41 -0
  113. package/scaffold/src/modules/security/server/avatar-routes.ts +86 -0
  114. package/scaffold/src/modules/security/server/public-api-base-url.ts +26 -0
  115. package/scaffold/src/modules/security/server/session-routes.ts.hbs +322 -0
  116. package/scaffold/src/modules/security/server/session-server.ts.hbs +30 -0
  117. package/scaffold/src/modules/security/services/auth-service.ts +332 -0
  118. package/scaffold/src/modules/security/services/security-api.test.ts +178 -0
  119. package/scaffold/src/modules/security/services/security-api.ts +281 -0
  120. package/scaffold/src/modules/security/services/security-service.test.ts +71 -0
  121. package/scaffold/src/modules/security/services/security-service.ts +727 -0
  122. package/scaffold/src/modules/security/services/session-storage.ts +80 -0
  123. package/scaffold/src/modules/security/types.ts +295 -0
  124. package/scaffold/src/modules/security/useSecurityAppShellSections.tsx.hbs +180 -0
  125. package/scaffold/src/modules/security/utils.ts +194 -0
@@ -0,0 +1,165 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+
5
+ import {
6
+ SgButton,
7
+ SgInputEmail,
8
+ SgInputPassword,
9
+ SgPanel,
10
+ SgStack,
11
+ toast,
12
+ } from "@seedgrid/fe-components";
13
+ import {
14
+ type FieldErrors,
15
+ type FieldValues,
16
+ type RegisterOptions,
17
+ type UseFormRegister,
18
+ useForm,
19
+ } from "react-hook-form";
20
+
21
+ import { useI18n } from "@/i18n";
22
+ import {
23
+ PageFrame,
24
+ authService,
25
+ extractApiMessage,
26
+ securityConfig,
27
+ } from "@/modules/security";
28
+
29
+ type ForgotPasswordFormValues = {
30
+ email: string;
31
+ newPassword: string;
32
+ confirmPassword: string;
33
+ };
34
+
35
+ export default function ForgotPasswordPage() {
36
+ const { t } = useI18n();
37
+ const [pending, setPending] = useState(false);
38
+ const { handleSubmit, register, reset } = useForm<ForgotPasswordFormValues>({
39
+ defaultValues: {
40
+ email: "",
41
+ newPassword: "",
42
+ confirmPassword: "",
43
+ },
44
+ });
45
+ const emailRules = {
46
+ required: t("security.forgot_password.validation.email_required"),
47
+ };
48
+ const newPasswordRules = {
49
+ required: t("security.forgot_password.validation.new_password_required"),
50
+ };
51
+ const confirmPasswordRules = {
52
+ required: t("security.forgot_password.validation.confirm_password_required"),
53
+ };
54
+ const formRegister = register as unknown as UseFormRegister<FieldValues>;
55
+ const emailFieldRules = emailRules as RegisterOptions<FieldValues>;
56
+ const newPasswordFieldRules = newPasswordRules as RegisterOptions<FieldValues>;
57
+ const confirmPasswordFieldRules =
58
+ confirmPasswordRules as RegisterOptions<FieldValues>;
59
+ const emailInputProps = {
60
+ autoComplete: "email",
61
+ };
62
+ const newPasswordInputProps = {
63
+ autoComplete: "new-password",
64
+ };
65
+ async function handleForgotPassword(values: ForgotPasswordFormValues) {
66
+ if (values.newPassword !== values.confirmPassword) {
67
+ toast.error(t("security.forgot_password.mismatch"));
68
+ return;
69
+ }
70
+
71
+ setPending(true);
72
+
73
+ try {
74
+ await authService.forgotPassword({
75
+ email: values.email.trim(),
76
+ newPassword: values.newPassword,
77
+ });
78
+ reset();
79
+ toast.success(t("security.forgot_password.success"));
80
+ } catch (error) {
81
+ toast.error(
82
+ extractApiMessage(error) ?? t("security.forgot_password.error")
83
+ );
84
+ } finally {
85
+ setPending(false);
86
+ }
87
+ }
88
+
89
+ function handleInvalidSubmit(errors: FieldErrors<ForgotPasswordFormValues>) {
90
+ const firstMessage =
91
+ errors.email?.message ??
92
+ errors.newPassword?.message ??
93
+ errors.confirmPassword?.message;
94
+
95
+ if (firstMessage) {
96
+ toast.error(firstMessage);
97
+ }
98
+ }
99
+
100
+ return (
101
+ <PageFrame
102
+ title={t("security.forgot_password.title")}
103
+ description={t("security.forgot_password.description")}
104
+ actions={
105
+ <SgButton
106
+ appearance="outline"
107
+ severity="secondary"
108
+ shape="rounded"
109
+ label={t("security.common.back")}
110
+ onClick={() => {
111
+ window.location.assign(securityConfig.routes.login);
112
+ }}
113
+ />
114
+ }
115
+ >
116
+ <SgPanel borderStyle="none" className="mx-auto w-full max-w-xl pt-8">
117
+ <form
118
+ noValidate
119
+ onSubmit={handleSubmit(handleForgotPassword, handleInvalidSubmit)}
120
+ >
121
+ <SgStack gap={16}>
122
+ <SgInputEmail
123
+ id="security-forgot-password-email"
124
+ name="email"
125
+ label={t("security.forgot_password.email")}
126
+ register={formRegister}
127
+ required
128
+ rules={emailFieldRules}
129
+ inputProps={emailInputProps}
130
+ />
131
+ <SgInputPassword
132
+ id="security-forgot-password-new-password"
133
+ name="newPassword"
134
+ label={t("security.forgot_password.new_password")}
135
+ register={formRegister}
136
+ required
137
+ rules={newPasswordFieldRules}
138
+ inputProps={newPasswordInputProps}
139
+ />
140
+ <SgInputPassword
141
+ id="security-forgot-password-confirm-password"
142
+ name="confirmPassword"
143
+ label={t("security.forgot_password.confirm_password")}
144
+ register={formRegister}
145
+ required
146
+ rules={confirmPasswordFieldRules}
147
+ inputProps={newPasswordInputProps}
148
+ />
149
+ <SgStack direction="row" justify="end">
150
+ <SgButton
151
+ appearance="solid"
152
+ severity="primary"
153
+ shape="rounded"
154
+ label={t("security.forgot_password.submit")}
155
+ loading={pending}
156
+ disabled={pending}
157
+ type="submit"
158
+ />
159
+ </SgStack>
160
+ </SgStack>
161
+ </form>
162
+ </SgPanel>
163
+ </PageFrame>
164
+ );
165
+ }
@@ -0,0 +1,31 @@
1
+ "use client";
2
+
3
+ import { SgButton, SgPanel, SgStack } from "@seedgrid/fe-components";
4
+
5
+ import { useI18n } from "@/i18n";
6
+ import { PageFrame, securityConfig } from "@/modules/security";
7
+
8
+ export default function ForgotPasswordSuccessPage() {
9
+ const { t } = useI18n();
10
+
11
+ return (
12
+ <PageFrame
13
+ title={t("security.forgot_password.success_page.title")}
14
+ description={t("security.forgot_password.success_page.description")}
15
+ >
16
+ <SgPanel borderStyle="none" className="mx-auto w-full max-w-xl">
17
+ <SgStack direction="row" justify="center">
18
+ <SgButton
19
+ appearance="solid"
20
+ severity="primary"
21
+ shape="rounded"
22
+ label={t("security.forgot_password.success_page.enter")}
23
+ onClick={() => {
24
+ window.location.assign(securityConfig.routes.login);
25
+ }}
26
+ />
27
+ </SgStack>
28
+ </SgPanel>
29
+ </PageFrame>
30
+ );
31
+ }
@@ -0,0 +1,386 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+
5
+ import {
6
+ SgButton,
7
+ SgCard,
8
+ SgGrid,
9
+ SgInputEmail,
10
+ SgInputPassword,
11
+ SgPanel,
12
+ SgStack,
13
+ toast,
14
+ } from "@seedgrid/fe-components";
15
+ import { useRouter } from "next/navigation";
16
+ import { type FieldErrors, useForm } from "react-hook-form";
17
+
18
+ import { appEnvironment } from "@/config/environment";
19
+ import { useI18n } from "@/i18n";
20
+ import {
21
+ InlineNotice,
22
+ extractApiMessage,
23
+ resolvePostAuthPath,
24
+ securityConfig,
25
+ useAuth,
26
+ } from "@/modules/security";
27
+ import { readStoredSession } from "@/modules/security/services/session-storage";
28
+
29
+ type LoginHostKind = "local" | "master" | "tenant" | "unknown";
30
+
31
+ type LoginFormValues = {
32
+ login: string;
33
+ password: string;
34
+ };
35
+
36
+ function resolveHostKind(hostname: string): LoginHostKind {
37
+ const normalizedHostname = hostname.trim().toLowerCase();
38
+ const masterHost = appEnvironment.hosts.masterAppHost.trim().toLowerCase();
39
+
40
+ if (
41
+ !normalizedHostname ||
42
+ normalizedHostname === "localhost" ||
43
+ normalizedHostname === "127.0.0.1" ||
44
+ normalizedHostname.endsWith(".local")
45
+ ) {
46
+ return "local";
47
+ }
48
+
49
+ if (normalizedHostname === masterHost) {
50
+ return "master";
51
+ }
52
+
53
+ if (masterHost && normalizedHostname.endsWith(`.${masterHost}`)) {
54
+ return "tenant";
55
+ }
56
+
57
+ return "unknown";
58
+ }
59
+
60
+ function formatProductName(value: string) {
61
+ const normalized = value.trim().replace(/[-_]+/g, " ");
62
+
63
+ if (!normalized) {
64
+ return "SeedGrid";
65
+ }
66
+
67
+ return normalized
68
+ .split(/\s+/)
69
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
70
+ .join(" ");
71
+ }
72
+
73
+ function buildMonogram(value: string) {
74
+ const words = value.trim().split(/\s+/).filter(Boolean);
75
+
76
+ if (words.length >= 2) {
77
+ return `${words[0]?.[0] ?? ""}${words[1]?.[0] ?? ""}`.toUpperCase();
78
+ }
79
+
80
+ return value.trim().slice(0, 1).toUpperCase() || "S";
81
+ }
82
+
83
+ function extractTenantLabel(hostname: string) {
84
+ const normalizedHostname = hostname.trim().toLowerCase();
85
+ const masterHost = appEnvironment.hosts.masterAppHost.trim().toLowerCase();
86
+
87
+ if (!masterHost) {
88
+ return "";
89
+ }
90
+
91
+ const tenantSuffix = `.${masterHost}`;
92
+ if (!normalizedHostname.endsWith(tenantSuffix)) {
93
+ return "";
94
+ }
95
+
96
+ return normalizedHostname.slice(0, -tenantSuffix.length);
97
+ }
98
+
99
+ function resolveTenantLogoUrl(template: string, hostname: string) {
100
+ const normalizedTemplate = template.trim();
101
+ const normalizedHostname = hostname.trim().toLowerCase();
102
+
103
+ if (!normalizedTemplate || !normalizedHostname) {
104
+ return null;
105
+ }
106
+
107
+ return normalizedTemplate
108
+ .replaceAll("{tenant}", extractTenantLabel(normalizedHostname))
109
+ .replaceAll("{host}", normalizedHostname)
110
+ .replaceAll("{hostname}", normalizedHostname)
111
+ .replaceAll(
112
+ "{masterHost}",
113
+ appEnvironment.hosts.masterAppHost.trim().toLowerCase()
114
+ );
115
+ }
116
+
117
+ function resolveLoginLogoSource(hostKind: LoginHostKind, hostname: string) {
118
+ if (hostKind === "tenant") {
119
+ return resolveTenantLogoUrl(appEnvironment.branding.tenantLogoUrl, hostname);
120
+ }
121
+
122
+ return appEnvironment.branding.masterLogoPath.trim() || null;
123
+ }
124
+
125
+ function EmailIcon() {
126
+ return (
127
+ <svg viewBox="0 0 24 24" className="size-4 text-slate-400" aria-hidden="true">
128
+ <path
129
+ d="M4 6.75A2.75 2.75 0 0 1 6.75 4h10.5A2.75 2.75 0 0 1 20 6.75v10.5A2.75 2.75 0 0 1 17.25 20H6.75A2.75 2.75 0 0 1 4 17.25V6.75Zm2.1-.25 5.9 4.64 5.9-4.64H6.1Zm11.4 1.91-4.73 3.71a1.25 1.25 0 0 1-1.54 0L6.5 8.41v8.84c0 .14.11.25.25.25h10.5c.14 0 .25-.11.25-.25V8.41Z"
130
+ fill="currentColor"
131
+ />
132
+ </svg>
133
+ );
134
+ }
135
+
136
+ function LockIcon() {
137
+ return (
138
+ <svg viewBox="0 0 24 24" className="size-4 text-slate-400" aria-hidden="true">
139
+ <path
140
+ d="M8 10V8a4 4 0 1 1 8 0v2h.75A2.25 2.25 0 0 1 19 12.25v6.5A2.25 2.25 0 0 1 16.75 21h-9.5A2.25 2.25 0 0 1 5 18.75v-6.5A2.25 2.25 0 0 1 7.25 10H8Zm2.5 0h3V8a1.5 1.5 0 0 0-3 0v2Z"
141
+ fill="currentColor"
142
+ />
143
+ </svg>
144
+ );
145
+ }
146
+
147
+ export default function LoginPage() {
148
+ const router = useRouter();
149
+ const { t } = useI18n();
150
+ const auth = useAuth();
151
+ const [pending, setPending] = useState(false);
152
+ const [hostKind, setHostKind] = useState<LoginHostKind>("unknown");
153
+ const [hostname, setHostname] = useState("");
154
+ const [logoLoadFailed, setLogoLoadFailed] = useState(false);
155
+ const { handleSubmit, register, reset } = useForm<LoginFormValues>({
156
+ defaultValues: {
157
+ login: "",
158
+ password: "",
159
+ },
160
+ });
161
+ const canShowSignup = hostKind !== "master";
162
+ const productName = formatProductName(
163
+ appEnvironment.branding.productName || appEnvironment.namespace
164
+ );
165
+ const productSlogan = appEnvironment.branding.productSlogan.trim() || productName;
166
+ const monogram = buildMonogram(productName);
167
+ const logoSource = resolveLoginLogoSource(hostKind, hostname);
168
+ const loginRules = {
169
+ required: t("security.login.validation.email_required"),
170
+ };
171
+ const passwordRules = {
172
+ required: t("security.login.validation.password_required"),
173
+ };
174
+ const emailInputProps = {
175
+ autoComplete: "email",
176
+ };
177
+ const passwordInputProps = {
178
+ autoComplete: "current-password",
179
+ };
180
+
181
+ useEffect(() => {
182
+ if (typeof window === "undefined") {
183
+ return;
184
+ }
185
+
186
+ setHostname(window.location.hostname);
187
+ setHostKind(resolveHostKind(window.location.hostname));
188
+ }, []);
189
+
190
+ useEffect(() => {
191
+ setLogoLoadFailed(false);
192
+ }, [logoSource]);
193
+
194
+ async function handleLogin(values: LoginFormValues) {
195
+ setPending(true);
196
+
197
+ try {
198
+ const outcome = await auth.login({
199
+ login: values.login.trim(),
200
+ password: values.password,
201
+ });
202
+
203
+ if (outcome.kind === "two_factor_required") {
204
+ toast.info(t("security.login.two_factor_required"));
205
+ router.push(securityConfig.routes.verifyTwoFactor);
206
+ return;
207
+ }
208
+
209
+ const storedSession = readStoredSession();
210
+
211
+ if (storedSession?.changePasswordRequired) {
212
+ toast.info(t("security.login.change_password_required"));
213
+ reset();
214
+ router.push(securityConfig.routes.changePassword);
215
+ return;
216
+ }
217
+
218
+ const nextPermissions = storedSession?.permissions ?? [];
219
+ toast.success(t("security.login.success"));
220
+ reset();
221
+ router.push(await resolvePostAuthPath(nextPermissions));
222
+ } catch (error) {
223
+ toast.error(extractApiMessage(error) ?? t("security.login.error"));
224
+ } finally {
225
+ setPending(false);
226
+ }
227
+ }
228
+
229
+ function handleInvalidSubmit(errors: FieldErrors<LoginFormValues>) {
230
+ const firstMessage =
231
+ errors.login?.message ??
232
+ errors.password?.message;
233
+
234
+ if (firstMessage) {
235
+ toast.error(firstMessage);
236
+ }
237
+ }
238
+
239
+ return (
240
+ <main className="flex min-h-screen items-center justify-center bg-slate-100 px-4 py-8 sm:px-6">
241
+ <SgCard
242
+ cardStyle="elevated"
243
+ size="lg"
244
+ className="w-full max-w-2xl overflow-hidden border border-white/70 bg-white/92 backdrop-blur"
245
+ >
246
+ <SgStack gap={32} className="px-6 py-8 sm:px-10 sm:py-10">
247
+ <SgStack align="center" className="mx-auto w-full max-w-[30rem]" gap={16}>
248
+ <SgPanel
249
+ className="relative w-full rounded-[2rem] bg-slate-50 px-5 pb-5 pt-16 sm:px-8 sm:pb-8 sm:pt-20"
250
+ borderStyle="none"
251
+ >
252
+ <div className="absolute left-1/2 top-0 -translate-x-1/2 -translate-y-[30%]">
253
+ <div className="flex size-28 items-center justify-center rounded-[1.75rem] border-4 border-slate-950 bg-white shadow-[0_18px_45px_-28px_rgba(15,23,42,0.35)] sm:size-32">
254
+ {logoSource && !logoLoadFailed ? (
255
+ <img
256
+ src={logoSource}
257
+ alt={t("security.login.logo_alt", { productName })}
258
+ className="h-full w-full rounded-[1.45rem] object-contain p-4"
259
+ loading="eager"
260
+ decoding="async"
261
+ onError={() => {
262
+ setLogoLoadFailed(true);
263
+ }}
264
+ />
265
+ ) : (
266
+ <span className="text-4xl font-black text-emerald-700 sm:text-5xl">
267
+ {monogram}
268
+ </span>
269
+ )}
270
+ </div>
271
+ </div>
272
+
273
+ <SgPanel
274
+ className="rounded-[1.5rem] border border-white/80 bg-white/95 px-5 py-5 shadow-[0_18px_48px_-36px_rgba(15,23,42,0.45)] sm:px-6"
275
+ borderStyle="none"
276
+ >
277
+ <SgStack gap={8}>
278
+ <h1 className="text-2xl font-semibold tracking-tight text-slate-950 sm:text-3xl">
279
+ {productName}
280
+ </h1>
281
+ <p className="text-sm leading-6 text-slate-600">
282
+ {t("security.login.branding_message", {
283
+ productName,
284
+ })}
285
+ </p>
286
+ <p className="text-base font-medium leading-6 text-slate-900">
287
+ {productSlogan}
288
+ </p>
289
+ </SgStack>
290
+ </SgPanel>
291
+ </SgPanel>
292
+ </SgStack>
293
+
294
+ <form
295
+ noValidate
296
+ onSubmit={handleSubmit(handleLogin, handleInvalidSubmit)}
297
+ >
298
+ <SgStack className="mx-auto w-full max-w-[28rem]" gap={16}>
299
+ {auth.pendingTwoFactor ? (
300
+ <InlineNotice tone="warning">
301
+ {t("security.login.two_factor_required")}
302
+ </InlineNotice>
303
+ ) : null}
304
+
305
+ <SgInputEmail
306
+ id="security-login-email"
307
+ name="login"
308
+ label={t("security.login.email")}
309
+ register={register}
310
+ required
311
+ rules={loginRules}
312
+ prefixIcon={<EmailIcon />}
313
+ inputProps={emailInputProps}
314
+ />
315
+ <SgInputPassword
316
+ id="security-login-password"
317
+ name="password"
318
+ label={t("security.login.password")}
319
+ register={register}
320
+ required
321
+ rules={passwordRules}
322
+ minSize={0}
323
+ showStrengthBar={false}
324
+ commonPasswordCheck={false}
325
+ upperRequired={false}
326
+ lowerRequired={false}
327
+ numberRequired={false}
328
+ specialCharacterRequired={false}
329
+ prohibitsRepeatedCharactersInSequence={false}
330
+ prohibitsSequentialAscCharacters={false}
331
+ prohibitsSequentialDescCharacters={false}
332
+ prefixIcon={<LockIcon />}
333
+ inputProps={passwordInputProps}
334
+ />
335
+
336
+ <SgButton
337
+ appearance="solid"
338
+ severity="primary"
339
+ size="lg"
340
+ shape="rounded"
341
+ label={
342
+ pending
343
+ ? t("security.login.processing")
344
+ : t("security.login.submit")
345
+ }
346
+ loading={pending}
347
+ disabled={pending}
348
+ type="submit"
349
+ className="mt-2 w-full"
350
+ />
351
+
352
+ <SgButton
353
+ appearance="ghost"
354
+ severity="info"
355
+ label={t("security.login.forgot_password")}
356
+ onClick={() => {
357
+ router.push(securityConfig.routes.forgotPassword);
358
+ }}
359
+ className="mx-auto"
360
+ />
361
+ </SgStack>
362
+ </form>
363
+
364
+ {canShowSignup ? (
365
+ <SgGrid
366
+ columns={1}
367
+ gap={6}
368
+ className="mx-auto w-full max-w-[28rem] border-t border-slate-200 pt-6 text-center text-sm text-slate-500"
369
+ >
370
+ <span>{t("security.login.signup_prompt")}</span>
371
+ <SgButton
372
+ appearance="ghost"
373
+ severity="success"
374
+ label={t("security.login.signup_action")}
375
+ onClick={() => {
376
+ router.push(securityConfig.routes.selfEnterPublic);
377
+ }}
378
+ className="mx-auto"
379
+ />
380
+ </SgGrid>
381
+ ) : null}
382
+ </SgStack>
383
+ </SgCard>
384
+ </main>
385
+ );
386
+ }
@@ -0,0 +1,90 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+
5
+ import { SgButton, SgPanel, SgStack } from "@seedgrid/fe-components";
6
+ import { useSearchParams } from "next/navigation";
7
+
8
+ import { useI18n } from "@/i18n";
9
+ import {
10
+ InlineNotice,
11
+ PageFrame,
12
+ authService,
13
+ extractApiMessage,
14
+ securityConfig,
15
+ } from "@/modules/security";
16
+
17
+ export default function WelcomePage() {
18
+ const searchParams = useSearchParams();
19
+ const { t } = useI18n();
20
+ const [confirmed, setConfirmed] = useState(false);
21
+ const [errorMessage, setErrorMessage] = useState<string | null>(null);
22
+
23
+ useEffect(() => {
24
+ const token = searchParams.get("token")?.trim();
25
+
26
+ if (!token) {
27
+ setErrorMessage(t("security.welcome.invalid_token"));
28
+ return;
29
+ }
30
+
31
+ let active = true;
32
+
33
+ void (async () => {
34
+ try {
35
+ await authService.confirmEmail(token);
36
+
37
+ if (!active) {
38
+ return;
39
+ }
40
+
41
+ setConfirmed(true);
42
+ } catch (error) {
43
+ if (!active) {
44
+ return;
45
+ }
46
+
47
+ setErrorMessage(
48
+ extractApiMessage(error) ?? t("security.welcome.error")
49
+ );
50
+ }
51
+ })();
52
+
53
+ return () => {
54
+ active = false;
55
+ };
56
+ }, [searchParams, t]);
57
+
58
+ return (
59
+ <PageFrame
60
+ title={t("security.welcome.title")}
61
+ description={t("security.welcome.description")}
62
+ >
63
+ <SgPanel borderStyle="none" className="mx-auto w-full max-w-xl">
64
+ <SgStack gap={16}>
65
+ {errorMessage ? (
66
+ <InlineNotice tone="danger">{errorMessage}</InlineNotice>
67
+ ) : confirmed ? (
68
+ <InlineNotice tone="success">{t("security.welcome.success")}</InlineNotice>
69
+ ) : (
70
+ <InlineNotice>{t("security.welcome.loading")}</InlineNotice>
71
+ )}
72
+
73
+ {(confirmed || errorMessage) ? (
74
+ <SgStack direction="row" justify="center">
75
+ <SgButton
76
+ appearance="solid"
77
+ severity="primary"
78
+ shape="rounded"
79
+ label={t("security.welcome.enter")}
80
+ onClick={() => {
81
+ window.location.assign(securityConfig.routes.login);
82
+ }}
83
+ />
84
+ </SgStack>
85
+ ) : null}
86
+ </SgStack>
87
+ </SgPanel>
88
+ </PageFrame>
89
+ );
90
+ }
@@ -0,0 +1,7 @@
1
+ import { redirect } from "next/navigation";
2
+
3
+ import { securityConfig } from "@/modules/security";
4
+
5
+ export default function LegacyLoginPage() {
6
+ redirect(securityConfig.routes.login);
7
+ }
@@ -0,0 +1,12 @@
1
+ "use client";
2
+
3
+ import { RouteGuard } from "@/modules/security";
4
+ import { SecurityDashboard } from "@/modules/security/components/SecurityDashboard";
5
+
6
+ export default function DashboardPage() {
7
+ return (
8
+ <RouteGuard>
9
+ <SecurityDashboard />
10
+ </RouteGuard>
11
+ );
12
+ }