mirror of
https://github.com/zuiidea/antd-admin.git
synced 2026-10-19 16:14:37 +00:00
feat(basic): register API, MSW, /register route, dashboard timeline
Made-with: Cursor
This commit is contained in:
@@ -1,11 +1,19 @@
|
||||
import type { AuthTokens, LoginRequest, User, MenuItem, PermissionsList } from "./schemas";
|
||||
import type {
|
||||
AuthTokens,
|
||||
LoginRequest,
|
||||
User,
|
||||
MenuItem,
|
||||
PermissionsList,
|
||||
RegisterRequest,
|
||||
} from "./schemas";
|
||||
|
||||
export const AUTH_ENDPOINTS = {
|
||||
login: "/api/auth/login",
|
||||
register: "/api/auth/register",
|
||||
refresh: "/api/auth/refresh",
|
||||
logout: "/api/auth/logout",
|
||||
user: "/api/auth/user",
|
||||
permissions: "/api/auth/permissions",
|
||||
} as const;
|
||||
|
||||
export type { AuthTokens, LoginRequest, User, MenuItem, PermissionsList };
|
||||
export type { AuthTokens, LoginRequest, User, MenuItem, PermissionsList, RegisterRequest };
|
||||
|
||||
@@ -35,6 +35,24 @@ export const LoginRequestSchema = z.object({
|
||||
|
||||
export type LoginRequest = z.infer<typeof LoginRequestSchema>;
|
||||
|
||||
/** Ant Design Form may submit `""` for an empty optional email */
|
||||
const registerOptionalEmailSchema = z
|
||||
.union([z.string(), z.undefined()])
|
||||
.transform((v) => {
|
||||
if (v === undefined) return undefined;
|
||||
const t = String(v).trim();
|
||||
return t === "" ? undefined : t;
|
||||
})
|
||||
.pipe(z.union([z.string().email(), z.undefined()]));
|
||||
|
||||
export const RegisterRequestSchema = z.object({
|
||||
username: z.string().min(1),
|
||||
password: z.string().min(6),
|
||||
email: registerOptionalEmailSchema,
|
||||
});
|
||||
|
||||
export type RegisterRequest = z.infer<typeof RegisterRequestSchema>;
|
||||
|
||||
export const PermissionsListSchema = z.array(z.string());
|
||||
|
||||
export type PermissionsList = z.infer<typeof PermissionsListSchema>;
|
||||
|
||||
@@ -13,6 +13,15 @@ const MOCK_IDENTITIES: ReadonlyArray<[string, string]> = [
|
||||
["ava.nguyen", "ava.nguyen@northstar.io"],
|
||||
["noah.berg", "noah.berg@northstar.io"],
|
||||
["mia.silva", "mia.silva@northstar.io"],
|
||||
["lucas.rossi", "lucas.rossi@northstar.io"],
|
||||
["isabella.ali", "isabella.ali@northstar.io"],
|
||||
["ethan.kovacs", "ethan.kovacs@northstar.io"],
|
||||
["sophia.dubois", "sophia.dubois@northstar.io"],
|
||||
["mateo.santos", "mateo.santos@northstar.io"],
|
||||
["harper.ivanov", "harper.ivanov@northstar.io"],
|
||||
["elijah.mohamed", "elijah.mohamed@northstar.io"],
|
||||
["amelia.schmidt", "amelia.schmidt@northstar.io"],
|
||||
["henry.okafor", "henry.okafor@northstar.io"],
|
||||
];
|
||||
|
||||
export const GUEST_AUTH_USER_BODY = {
|
||||
|
||||
@@ -13,14 +13,24 @@ import {
|
||||
LoginRequestSchema,
|
||||
PermissionsListSchema,
|
||||
RefreshTokenRequestSchema,
|
||||
RegisterRequestSchema,
|
||||
} from "@/api/schemas";
|
||||
|
||||
const GUEST_REFRESH = "mock-guest-refresh";
|
||||
const REGISTERED_ACCESS = "mock-registered-access";
|
||||
const REGISTERED_REFRESH = "mock-registered-refresh";
|
||||
|
||||
/** Last successful register (in-memory) so /user matches the new account in MSW */
|
||||
let mockRegisteredSession: { username: string; email: string | null } | null = null;
|
||||
|
||||
function isGuestAuth(authorization: string | null): boolean {
|
||||
return (authorization ?? "").includes("mock-guest-access");
|
||||
}
|
||||
|
||||
function isRegisteredAuth(authorization: string | null): boolean {
|
||||
return (authorization ?? "").includes(REGISTERED_ACCESS);
|
||||
}
|
||||
|
||||
export const authHandlers = [
|
||||
http.post("/api/auth/login", async ({ request }) => {
|
||||
await withDelay(300);
|
||||
@@ -50,6 +60,36 @@ export const authHandlers = [
|
||||
return errorResponse(ERROR_CODES.INVALID_CREDENTIALS, "Invalid username or password");
|
||||
}),
|
||||
|
||||
http.post("/api/auth/register", async ({ request }) => {
|
||||
await withDelay(300);
|
||||
let json: unknown;
|
||||
try {
|
||||
json = await request.json();
|
||||
} catch {
|
||||
return errorResponse(ERROR_CODES.BAD_REQUEST, "Invalid JSON body");
|
||||
}
|
||||
const parsed = RegisterRequestSchema.safeParse(json);
|
||||
if (!parsed.success) {
|
||||
return errorResponse(ERROR_CODES.BAD_REQUEST, "Invalid register body");
|
||||
}
|
||||
const { username, password, email } = parsed.data;
|
||||
if (password.length < 6) {
|
||||
return errorResponse(ERROR_CODES.BAD_REQUEST, "Password must be at least 6 characters");
|
||||
}
|
||||
const taken = username === "guest" || MOCK_USERS.some((u) => u.username === username);
|
||||
if (taken) {
|
||||
return errorResponse(ERROR_CODES.BAD_REQUEST, "Username already taken");
|
||||
}
|
||||
mockRegisteredSession = {
|
||||
username,
|
||||
email: email ?? null,
|
||||
};
|
||||
return successWithSchema(AuthTokensSchema, {
|
||||
accessToken: REGISTERED_ACCESS,
|
||||
refreshToken: REGISTERED_REFRESH,
|
||||
});
|
||||
}),
|
||||
|
||||
http.post("/api/auth/refresh", async ({ request }) => {
|
||||
await withDelay(100);
|
||||
let json: unknown;
|
||||
@@ -68,6 +108,15 @@ export const authHandlers = [
|
||||
refreshToken: "mock-guest-refresh-refreshed",
|
||||
});
|
||||
}
|
||||
if (
|
||||
parsed.data.refreshToken === REGISTERED_REFRESH ||
|
||||
parsed.data.refreshToken.startsWith(`${REGISTERED_REFRESH}-`)
|
||||
) {
|
||||
return successWithSchema(AuthTokensSchema, {
|
||||
accessToken: `${REGISTERED_ACCESS}-refreshed`,
|
||||
refreshToken: `${REGISTERED_REFRESH}-refreshed`,
|
||||
});
|
||||
}
|
||||
return successWithSchema(AuthTokensSchema, {
|
||||
accessToken: "mock-new-access-token",
|
||||
refreshToken: "mock-new-refresh-token",
|
||||
@@ -78,6 +127,18 @@ export const authHandlers = [
|
||||
|
||||
http.get("/api/auth/user", ({ request }) => {
|
||||
const auth = request.headers.get("authorization");
|
||||
if (isRegisteredAuth(auth)) {
|
||||
const session = mockRegisteredSession;
|
||||
if (session) {
|
||||
return successWithSchema(AuthUserResponseSchema, {
|
||||
id: "reg-mock",
|
||||
username: session.username,
|
||||
avatar: null,
|
||||
email: session.email,
|
||||
roles: ["editor"],
|
||||
});
|
||||
}
|
||||
}
|
||||
if (isGuestAuth(auth)) {
|
||||
return successWithSchema(AuthUserResponseSchema, GUEST_AUTH_USER_BODY);
|
||||
}
|
||||
@@ -87,6 +148,9 @@ export const authHandlers = [
|
||||
|
||||
http.get("/api/auth/permissions", ({ request }) => {
|
||||
const auth = request.headers.get("authorization");
|
||||
if (isRegisteredAuth(auth) && mockRegisteredSession) {
|
||||
return successWithSchema(PermissionsListSchema, ["user:view"] as string[]);
|
||||
}
|
||||
if (isGuestAuth(auth)) {
|
||||
return successWithSchema(PermissionsListSchema, [] as string[]);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as AuthRouteImport } from './routes/_auth'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as RegisterIndexRouteImport } from './routes/register/index'
|
||||
import { Route as LoginIndexRouteImport } from './routes/login/index'
|
||||
import { Route as R404IndexRouteImport } from './routes/404/index'
|
||||
import { Route as AuthUsersIndexRouteImport } from './routes/_auth/users/index'
|
||||
@@ -26,6 +27,11 @@ const IndexRoute = IndexRouteImport.update({
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const RegisterIndexRoute = RegisterIndexRouteImport.update({
|
||||
id: '/register/',
|
||||
path: '/register/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const LoginIndexRoute = LoginIndexRouteImport.update({
|
||||
id: '/login/',
|
||||
path: '/login/',
|
||||
@@ -56,6 +62,7 @@ export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/404/': typeof R404IndexRoute
|
||||
'/login/': typeof LoginIndexRoute
|
||||
'/register/': typeof RegisterIndexRoute
|
||||
'/403/': typeof Auth403IndexRoute
|
||||
'/dashboard/': typeof AuthDashboardIndexRoute
|
||||
'/users/': typeof AuthUsersIndexRoute
|
||||
@@ -64,6 +71,7 @@ export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/404': typeof R404IndexRoute
|
||||
'/login': typeof LoginIndexRoute
|
||||
'/register': typeof RegisterIndexRoute
|
||||
'/403': typeof Auth403IndexRoute
|
||||
'/dashboard': typeof AuthDashboardIndexRoute
|
||||
'/users': typeof AuthUsersIndexRoute
|
||||
@@ -74,21 +82,30 @@ export interface FileRoutesById {
|
||||
'/_auth': typeof AuthRouteWithChildren
|
||||
'/404/': typeof R404IndexRoute
|
||||
'/login/': typeof LoginIndexRoute
|
||||
'/register/': typeof RegisterIndexRoute
|
||||
'/_auth/403/': typeof Auth403IndexRoute
|
||||
'/_auth/dashboard/': typeof AuthDashboardIndexRoute
|
||||
'/_auth/users/': typeof AuthUsersIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/404/' | '/login/' | '/403/' | '/dashboard/' | '/users/'
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/404/'
|
||||
| '/login/'
|
||||
| '/register/'
|
||||
| '/403/'
|
||||
| '/dashboard/'
|
||||
| '/users/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/404' | '/login' | '/403' | '/dashboard' | '/users'
|
||||
to: '/' | '/404' | '/login' | '/register' | '/403' | '/dashboard' | '/users'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/_auth'
|
||||
| '/404/'
|
||||
| '/login/'
|
||||
| '/register/'
|
||||
| '/_auth/403/'
|
||||
| '/_auth/dashboard/'
|
||||
| '/_auth/users/'
|
||||
@@ -99,6 +116,7 @@ export interface RootRouteChildren {
|
||||
AuthRoute: typeof AuthRouteWithChildren
|
||||
R404IndexRoute: typeof R404IndexRoute
|
||||
LoginIndexRoute: typeof LoginIndexRoute
|
||||
RegisterIndexRoute: typeof RegisterIndexRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
@@ -117,6 +135,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/register/': {
|
||||
id: '/register/'
|
||||
path: '/register'
|
||||
fullPath: '/register/'
|
||||
preLoaderRoute: typeof RegisterIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/login/': {
|
||||
id: '/login/'
|
||||
path: '/login'
|
||||
@@ -174,6 +199,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
AuthRoute: AuthRouteWithChildren,
|
||||
R404IndexRoute: R404IndexRoute,
|
||||
LoginIndexRoute: LoginIndexRoute,
|
||||
RegisterIndexRoute: RegisterIndexRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
||||
@@ -20,12 +20,12 @@
|
||||
background-color: var(--dash-recent-hover-bg);
|
||||
}
|
||||
|
||||
.dash-chart-placeholder {
|
||||
transition: border-color 0.2s ease;
|
||||
.dash-timeline {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.dash-chart-placeholder:hover {
|
||||
border-color: var(--dash-chart-hover-border);
|
||||
.dash-timeline .ant-timeline-item-content {
|
||||
min-height: 52px;
|
||||
}
|
||||
|
||||
.dash-skel-stat .ant-skeleton-input {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Card, Col, Row, Typography, Avatar, theme, Flex, Skeleton } from "antd";
|
||||
import { Card, Col, Row, Typography, Avatar, theme, Flex, Skeleton, Timeline, Tag } from "antd";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { DollarSign, Users, CreditCard, Activity } from "lucide-react";
|
||||
import "./index.css";
|
||||
@@ -194,6 +194,48 @@ function DashboardPage() {
|
||||
[],
|
||||
);
|
||||
|
||||
const timelineItems = useMemo(
|
||||
() => [
|
||||
{
|
||||
color: "green" as const,
|
||||
content: (
|
||||
<Flex vertical gap={4}>
|
||||
<Text strong>08:30 · Deploy V3.2.0</Text>
|
||||
<Text type="secondary">Release branch merged and production rollout completed.</Text>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
{
|
||||
color: "blue" as const,
|
||||
content: (
|
||||
<Flex vertical gap={4}>
|
||||
<Text strong>10:10 · Menu policy updated</Text>
|
||||
<Text type="secondary">Admin changed sidebar visibility and permission mapping.</Text>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
{
|
||||
color: "gold" as const,
|
||||
content: (
|
||||
<Flex vertical gap={4}>
|
||||
<Text strong>13:20 · Security review</Text>
|
||||
<Text type="secondary">Token refresh behavior and 403 routes validated.</Text>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
{
|
||||
color: "red" as const,
|
||||
content: (
|
||||
<Flex vertical gap={4}>
|
||||
<Text strong>15:50 · Incident recovery</Text>
|
||||
<Text type="secondary">User creation spike handled and queue restored.</Text>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
if (isPending) {
|
||||
return <DashboardSkeleton />;
|
||||
}
|
||||
@@ -228,33 +270,29 @@ function DashboardPage() {
|
||||
<Card
|
||||
className="dash-card-interactive"
|
||||
style={{ ...cardHoverStyle, height: "100%" }}
|
||||
title={<Title level={5} style={{ margin: 0 }}>Overview</Title>}
|
||||
title={
|
||||
<Flex align="center" gap={token.marginSM}>
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Timeline
|
||||
</Title>
|
||||
<Tag variant="filled" color="processing">
|
||||
Today
|
||||
</Tag>
|
||||
</Flex>
|
||||
}
|
||||
>
|
||||
<Flex
|
||||
className="dash-chart-placeholder"
|
||||
style={{
|
||||
height: 300,
|
||||
width: "100%",
|
||||
backgroundColor: token.colorBgLayout,
|
||||
borderRadius: token.borderRadius,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
border: `1px dashed ${token.colorBorder}`,
|
||||
["--dash-chart-hover-bg" as string]: token.colorBgLayout,
|
||||
["--dash-chart-hover-border" as string]: token.colorTextTertiary,
|
||||
}}
|
||||
>
|
||||
<Text type="secondary" style={{ fontSize: token.fontSizeSM }}>
|
||||
Chart Placeholder
|
||||
</Text>
|
||||
</Flex>
|
||||
<Timeline className="dash-timeline" items={timelineItems} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={10}>
|
||||
<Card
|
||||
className="dash-card-interactive"
|
||||
style={{ ...cardHoverStyle, height: "100%" }}
|
||||
title={<Title level={5} style={{ margin: 0 }}>Recent Sales</Title>}
|
||||
title={
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Recent Sales
|
||||
</Title>
|
||||
}
|
||||
>
|
||||
<Flex
|
||||
vertical
|
||||
|
||||
@@ -5,3 +5,17 @@
|
||||
-webkit-backdrop-filter: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Keep browser autofill from painting a solid background over the glass card. */
|
||||
.login-page__card .ant-input:-webkit-autofill,
|
||||
.login-page__card .ant-input:-webkit-autofill:hover,
|
||||
.login-page__card .ant-input:-webkit-autofill:focus,
|
||||
.login-page__card .ant-input-affix-wrapper input:-webkit-autofill,
|
||||
.login-page__card .ant-input-affix-wrapper input:-webkit-autofill:hover,
|
||||
.login-page__card .ant-input-affix-wrapper input:-webkit-autofill:focus {
|
||||
-webkit-text-fill-color: var(--ant-color-text, currentColor) !important;
|
||||
caret-color: var(--ant-color-text, currentColor);
|
||||
-webkit-box-shadow: 0 0 0 1000px transparent inset !important;
|
||||
box-shadow: 0 0 0 1000px transparent inset !important;
|
||||
transition: background-color 9999s ease-out 0s;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
|
||||
import { createFileRoute, Link, redirect, useNavigate } from "@tanstack/react-router";
|
||||
import { Form, Input, Button, Card, App, theme, Typography, Flex, Checkbox, Space } from "antd";
|
||||
import type { CSSProperties } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
@@ -140,12 +140,7 @@ function LoginPage() {
|
||||
label={<span style={{ fontWeight: 500 }}>Username</span>}
|
||||
rules={[{ required: true, message: "Please enter username" }]}
|
||||
>
|
||||
<Input
|
||||
id="login-username"
|
||||
aria-label="Username"
|
||||
placeholder="admin"
|
||||
size="large"
|
||||
/>
|
||||
<Input id="login-username" aria-label="Username" placeholder="admin" size="large" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
@@ -192,6 +187,14 @@ function LoginPage() {
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Flex justify="center" style={{ marginTop: token.margin }}>
|
||||
<Typography.Text type="secondary">
|
||||
Don't have an account?{" "}
|
||||
<Link to="/register" style={{ color: token.colorPrimary }}>
|
||||
Sign up
|
||||
</Link>
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Flex>
|
||||
<Flex
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { Link, createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
|
||||
import { Form, Input, Button, Card, App, theme, Typography, Flex } from "antd";
|
||||
import type { CSSProperties } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { httpClient } from "@/utils/http";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { AUTH_ENDPOINTS } from "@/api/auth";
|
||||
import { AuthTokensSchema, RegisterRequestSchema } from "@/api/schemas";
|
||||
import { fetchSessionAndApplyToStore } from "@/utils/session";
|
||||
import type { RegisterRequest } from "@/api/auth";
|
||||
import { APP_BRAND_NAME, APP_FAVICON_SRC } from "@/utils/constants";
|
||||
import { Aurora } from "@/components/Aurora";
|
||||
import { AppFooter } from "@/components/Layout/AppFooter";
|
||||
import "../login/index.css";
|
||||
|
||||
export const Route = createFileRoute("/register/")({
|
||||
beforeLoad: () => {
|
||||
const { isAuthenticated } = useAuthStore.getState();
|
||||
if (isAuthenticated) {
|
||||
throw redirect({ to: "/dashboard" });
|
||||
}
|
||||
},
|
||||
component: RegisterPage,
|
||||
});
|
||||
|
||||
function RegisterPage() {
|
||||
const navigate = useNavigate();
|
||||
const { message } = App.useApp();
|
||||
const setTokens = useAuthStore((s) => s.setTokens);
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const registerMutation = useMutation({
|
||||
mutationFn: async (values: RegisterRequest) => {
|
||||
const parsed = RegisterRequestSchema.parse(values);
|
||||
const tokens = await httpClient.post(AUTH_ENDPOINTS.register, parsed);
|
||||
const validTokens = AuthTokensSchema.parse(tokens);
|
||||
setTokens(validTokens);
|
||||
await fetchSessionAndApplyToStore();
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success("Account created successfully");
|
||||
void navigate({ to: "/dashboard" });
|
||||
},
|
||||
onError: (err) => {
|
||||
message.error(err instanceof Error ? err.message : "Registration failed");
|
||||
},
|
||||
});
|
||||
|
||||
const shellStyle: CSSProperties = {
|
||||
position: "relative",
|
||||
isolation: "isolate",
|
||||
minHeight: "100vh",
|
||||
backgroundColor: token.colorBgLayout,
|
||||
color: token.colorText,
|
||||
};
|
||||
|
||||
const contentStyle: CSSProperties = {
|
||||
position: "relative",
|
||||
zIndex: 1,
|
||||
flex: "1 1 0%",
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const cardStyle: CSSProperties = {
|
||||
width: "100%",
|
||||
maxWidth: 384,
|
||||
background: `color-mix(in srgb, ${token.colorBgContainer} 44%, transparent)`,
|
||||
backdropFilter: "blur(18px) saturate(1.35)",
|
||||
WebkitBackdropFilter: "blur(18px) saturate(1.35)",
|
||||
borderColor: token.colorBorderSecondary,
|
||||
boxShadow: token.boxShadow,
|
||||
["--login-card-fallback-bg" as string]: token.colorBgElevated,
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex vertical style={shellStyle}>
|
||||
<Aurora />
|
||||
<Flex vertical style={contentStyle}>
|
||||
<Flex
|
||||
flex={1}
|
||||
align="center"
|
||||
justify="center"
|
||||
style={{ padding: token.padding, minHeight: 0 }}
|
||||
>
|
||||
<Card
|
||||
className="login-page__card"
|
||||
style={cardStyle}
|
||||
styles={{
|
||||
body: { padding: token.paddingLG, background: "transparent" },
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
gap={token.margin}
|
||||
wrap="wrap"
|
||||
style={{ marginBottom: token.marginLG }}
|
||||
>
|
||||
<img
|
||||
src={APP_FAVICON_SRC}
|
||||
alt=""
|
||||
width={32}
|
||||
height={32}
|
||||
draggable={false}
|
||||
style={{ display: "block", flexShrink: 0 }}
|
||||
/>
|
||||
<Typography.Title
|
||||
level={3}
|
||||
style={{
|
||||
margin: 0,
|
||||
fontWeight: "bold",
|
||||
letterSpacing: "-0.025em",
|
||||
lineHeight: 1.2,
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
{APP_BRAND_NAME}
|
||||
</Typography.Title>
|
||||
</Flex>
|
||||
|
||||
<Form
|
||||
layout="vertical"
|
||||
onFinish={(values) => {
|
||||
const payload = RegisterRequestSchema.parse(values);
|
||||
registerMutation.mutate(payload);
|
||||
}}
|
||||
initialValues={{ username: "", password: "", confirmPassword: "", email: "" }}
|
||||
validateTrigger={"onBlur"}
|
||||
requiredMark={false}
|
||||
>
|
||||
<Form.Item
|
||||
name="username"
|
||||
label={<span style={{ fontWeight: 500 }}>Username</span>}
|
||||
rules={[{ required: true, message: "Please enter your username" }]}
|
||||
>
|
||||
<Input placeholder="new-user" size="large" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="email"
|
||||
label={<span style={{ fontWeight: 500 }}>Email address</span>}
|
||||
rules={[{ type: "email", message: "Please enter a valid email address" }]}
|
||||
>
|
||||
<Input placeholder="you@example.com" size="large" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
label={<span style={{ fontWeight: 500 }}>Password</span>}
|
||||
rules={[
|
||||
{ required: true, message: "Please enter your password" },
|
||||
{ min: 6, message: "Password must be at least 6 characters long" },
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="Enter at least 6 characters" size="large" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="confirmPassword"
|
||||
label={<span style={{ fontWeight: 500 }}>Confirm password</span>}
|
||||
dependencies={["password"]}
|
||||
rules={[
|
||||
{ required: true, message: "Please confirm your password" },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue("password") === value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error("Passwords do not match"));
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="Re-enter your password" size="large" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item style={{ marginBottom: 0, marginTop: token.marginLG }}>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={registerMutation.isPending}
|
||||
block
|
||||
size="large"
|
||||
>
|
||||
Create account
|
||||
</Button>
|
||||
</Form.Item>
|
||||
|
||||
<Flex justify="center" style={{ marginTop: token.margin }}>
|
||||
<Typography.Text type="secondary">
|
||||
Already have an account?{" "}
|
||||
<Link to="/login" style={{ color: token.colorPrimary }}>
|
||||
Sign in
|
||||
</Link>
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
</Form>
|
||||
</Card>
|
||||
</Flex>
|
||||
<Flex
|
||||
vertical
|
||||
align="center"
|
||||
style={{
|
||||
padding: `${token.paddingSM}px ${token.padding}px`,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<AppFooter />
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user