feat(basic): register API, MSW, /register route, dashboard timeline

Made-with: Cursor
This commit is contained in:
zuiidea
2026-04-27 18:45:26 +08:00
parent c8062e1299
commit 335c77cc8c
10 changed files with 431 additions and 36 deletions
+10 -2
View File
@@ -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 };
+18
View File
@@ -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>;
+9
View File
@@ -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 = {
+64
View File
@@ -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[]);
}
+28 -2
View File
@@ -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 {
+59 -21
View File
@@ -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
+14
View File
@@ -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;
}
+10 -7
View File
@@ -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&apos;t have an account?{" "}
<Link to="/register" style={{ color: token.colorPrimary }}>
Sign up
</Link>
</Typography.Text>
</Flex>
</Card>
</Flex>
<Flex
+215
View File
@@ -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>
);
}