From 335c77cc8c414c809a89f19c91d1465c7804e85e Mon Sep 17 00:00:00 2001 From: zuiidea Date: Mon, 27 Apr 2026 18:45:26 +0800 Subject: [PATCH] feat(basic): register API, MSW, /register route, dashboard timeline Made-with: Cursor --- apps/basic/src/api/auth.ts | 12 +- apps/basic/src/api/schemas.ts | 18 ++ apps/basic/src/mocks/data.ts | 9 + apps/basic/src/mocks/handlers/auth.ts | 64 ++++++ apps/basic/src/routeTree.gen.ts | 30 ++- .../src/routes/_auth/dashboard/index.css | 8 +- .../src/routes/_auth/dashboard/index.tsx | 80 +++++-- apps/basic/src/routes/login/index.css | 14 ++ apps/basic/src/routes/login/index.tsx | 17 +- apps/basic/src/routes/register/index.tsx | 215 ++++++++++++++++++ 10 files changed, 431 insertions(+), 36 deletions(-) create mode 100644 apps/basic/src/routes/register/index.tsx diff --git a/apps/basic/src/api/auth.ts b/apps/basic/src/api/auth.ts index 7afbd41..2d4314e 100644 --- a/apps/basic/src/api/auth.ts +++ b/apps/basic/src/api/auth.ts @@ -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 }; diff --git a/apps/basic/src/api/schemas.ts b/apps/basic/src/api/schemas.ts index 724308d..429abac 100644 --- a/apps/basic/src/api/schemas.ts +++ b/apps/basic/src/api/schemas.ts @@ -35,6 +35,24 @@ export const LoginRequestSchema = z.object({ export type LoginRequest = z.infer; +/** 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; + export const PermissionsListSchema = z.array(z.string()); export type PermissionsList = z.infer; diff --git a/apps/basic/src/mocks/data.ts b/apps/basic/src/mocks/data.ts index 1d39ccd..7a87b65 100644 --- a/apps/basic/src/mocks/data.ts +++ b/apps/basic/src/mocks/data.ts @@ -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 = { diff --git a/apps/basic/src/mocks/handlers/auth.ts b/apps/basic/src/mocks/handlers/auth.ts index 8976cc7..fe197d5 100644 --- a/apps/basic/src/mocks/handlers/auth.ts +++ b/apps/basic/src/mocks/handlers/auth.ts @@ -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[]); } diff --git a/apps/basic/src/routeTree.gen.ts b/apps/basic/src/routeTree.gen.ts index e3430b5..328b65f 100644 --- a/apps/basic/src/routeTree.gen.ts +++ b/apps/basic/src/routeTree.gen.ts @@ -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) diff --git a/apps/basic/src/routes/_auth/dashboard/index.css b/apps/basic/src/routes/_auth/dashboard/index.css index 655beab..e681ac0 100644 --- a/apps/basic/src/routes/_auth/dashboard/index.css +++ b/apps/basic/src/routes/_auth/dashboard/index.css @@ -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 { diff --git a/apps/basic/src/routes/_auth/dashboard/index.tsx b/apps/basic/src/routes/_auth/dashboard/index.tsx index e2d7575..dd6bd32 100644 --- a/apps/basic/src/routes/_auth/dashboard/index.tsx +++ b/apps/basic/src/routes/_auth/dashboard/index.tsx @@ -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: ( + + 08:30 · Deploy V3.2.0 + Release branch merged and production rollout completed. + + ), + }, + { + color: "blue" as const, + content: ( + + 10:10 · Menu policy updated + Admin changed sidebar visibility and permission mapping. + + ), + }, + { + color: "gold" as const, + content: ( + + 13:20 · Security review + Token refresh behavior and 403 routes validated. + + ), + }, + { + color: "red" as const, + content: ( + + 15:50 · Incident recovery + User creation spike handled and queue restored. + + ), + }, + ], + [], + ); + if (isPending) { return ; } @@ -228,33 +270,29 @@ function DashboardPage() { Overview} + title={ + + + Timeline + + + Today + + + } > - - - Chart Placeholder - - + Recent Sales} + title={ + + Recent Sales + + } > Username} rules={[{ required: true, message: "Please enter username" }]} > - + + + + Don't have an account?{" "} + + Sign up + + + { + 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 ( + + + + + + + + + {APP_BRAND_NAME} + + + +
{ + const payload = RegisterRequestSchema.parse(values); + registerMutation.mutate(payload); + }} + initialValues={{ username: "", password: "", confirmPassword: "", email: "" }} + validateTrigger={"onBlur"} + requiredMark={false} + > + Username} + rules={[{ required: true, message: "Please enter your username" }]} + > + + + + Email address} + rules={[{ type: "email", message: "Please enter a valid email address" }]} + > + + + + Password} + rules={[ + { required: true, message: "Please enter your password" }, + { min: 6, message: "Password must be at least 6 characters long" }, + ]} + > + + + + Confirm password} + 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")); + }, + }), + ]} + > + + + + + + + + + + Already have an account?{" "} + + Sign in + + + +
+
+
+ + + +
+
+ ); +}