From 293f34c2939162a12978864398efb6d6bbb3c1b1 Mon Sep 17 00:00:00 2001 From: Harshil Date: Wed, 2 Sep 2026 15:14:44 +0530 Subject: [PATCH 1/5] refactor: improve signup and signin flow with email verification status --- backend/src/controllers/auth.ts | 28 ++- backend/src/controllers/user.ts | 3 +- backend/src/middleware/auth.ts | 32 ++- backend/src/routes/auth.ts | 2 +- backend/src/routes/user.ts | 2 +- backend/src/types/principal.ts | 1 + backend/tests/access.test.ts | 30 ++- frontend/src/components/login-form.tsx | 6 +- frontend/src/components/market/data-panel.tsx | 15 +- frontend/src/components/market/trade-form.tsx | 29 ++- frontend/src/components/navbar.tsx | 14 +- frontend/src/components/route-guards.tsx | 7 +- frontend/src/components/signup-form.tsx | 172 +++------------ frontend/src/context/AuthContext.tsx | 6 +- frontend/src/lib/api.ts | 13 +- frontend/src/pages/trade.tsx | 6 +- frontend/src/pages/verify-email.tsx | 207 ++++++++++-------- frontend/src/types/api.ts | 1 + 18 files changed, 285 insertions(+), 289 deletions(-) diff --git a/backend/src/controllers/auth.ts b/backend/src/controllers/auth.ts index 68629e4..292bb64 100644 --- a/backend/src/controllers/auth.ts +++ b/backend/src/controllers/auth.ts @@ -50,10 +50,16 @@ export async function signup(req: Request, res: Response) { sendVerificationEmail(user.name, user.email, token); - res.status(201).json({ - success: true, - message: "User created successfully. Please check your email to verify your account.", - }); + res + .status(201) + .cookie("token", createToken({ id: user.id }), config.cookie) + .json({ + id: user.id, + name: user.name, + email: user.email, + emailVerified: user.emailVerified, + message: "Account created. Check your email to verify it.", + }); } catch (e) { logger.error("Signup failed", e); res.status(500).json({ error: "Internal server error" }); @@ -86,11 +92,6 @@ export async function signin(req: Request, res: Response) { return; } - if (!user.emailVerified) { - res.status(403).json({ error: "Email not verified" }); - return; - } - res .status(200) .cookie("token", createToken({ id: user.id }), config.cookie) @@ -98,6 +99,7 @@ export async function signin(req: Request, res: Response) { id: user.id, name: user.name, email: user.email, + emailVerified: user.emailVerified, }); } catch (e) { logger.error("Signin failed", e); @@ -149,16 +151,12 @@ export async function verifyEmail(req: Request, res: Response) { data: { usedAt: new Date() }, }); - const user = await prisma.user.update({ + await prisma.user.update({ where: { id: verification.userId }, data: { emailVerified: true }, - select: { id: true, name: true, email: true }, }); - res - .status(200) - .cookie("token", createToken({ id: verification.userId }), config.cookie) - .json(user); + res.status(200).json({ message: "Email verified successfully" }); } catch (e) { logger.error("Email verification failed", e); res.status(500).json({ error: "Internal server error" }); diff --git a/backend/src/controllers/user.ts b/backend/src/controllers/user.ts index c809982..b86f2e9 100644 --- a/backend/src/controllers/user.ts +++ b/backend/src/controllers/user.ts @@ -32,9 +32,10 @@ export async function getUserData(req: Request, res: Response) { } res.status(200).json({ - userId: user.id, + id: user.id, email: user.email, name: user.name, + emailVerified: user.emailVerified, }); } catch (e) { logger.error("getUserData failed", e); diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index c7b21f6..90962dc 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -15,6 +15,7 @@ interface TokenPayload { interface AccessOptions { types?: PrincipalType[]; scopes?: Scope[]; + allowUnverified?: boolean; } export function createToken(payload: TokenPayload): string { @@ -122,8 +123,8 @@ export async function authenticate(req: Request, res: Response, next: NextFuncti } } -export function requireAccess({ types, scopes }: AccessOptions) { - return (req: Request, res: Response, next: NextFunction) => { +export function requireAccess({ types, scopes, allowUnverified = false }: AccessOptions) { + return async (req: Request, res: Response, next: NextFunction) => { const principal = req.principal; if (!principal) { @@ -136,6 +137,33 @@ export function requireAccess({ types, scopes }: AccessOptions) { return; } + if (!allowUnverified && principal.type !== "service") { + let emailVerified = principal.emailVerified; + + if (emailVerified === undefined && principal.userId) { + const user = await prisma.user.findUnique({ + where: { id: principal.userId }, + select: { emailVerified: true }, + }); + + if (!user) { + res.status(401).json({ error: "Authentication required" }); + return; + } + + emailVerified = user.emailVerified; + principal.emailVerified = emailVerified; + } + + if (!emailVerified) { + res.status(403).json({ + error: "Verify your email to continue", + code: "EMAIL_NOT_VERIFIED", + }); + return; + } + } + if (scopes?.length && principal.type !== "session") { const authorized = scopes.every((scope) => principal.scopes?.includes(scope)); diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 720a5f3..c33d90d 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -8,6 +8,6 @@ export const authRouter = Router(); authRouter.post("/signup", authLimiter, asyncHandler(signup)); authRouter.post("/login", authLimiter, asyncHandler(signin)); -authRouter.post("/logout", requireAccess({ types: ["session"] }), asyncHandler(signout)); +authRouter.post("/logout", requireAccess({ types: ["session"], allowUnverified: true }), asyncHandler(signout)); authRouter.post("/verify-email", authLimiter, asyncHandler(verifyEmail)); authRouter.post("/resend-verification-email", authLimiter, asyncHandler(resendVerificationEmail)); diff --git a/backend/src/routes/user.ts b/backend/src/routes/user.ts index f5af054..c46bb41 100644 --- a/backend/src/routes/user.ts +++ b/backend/src/routes/user.ts @@ -6,7 +6,7 @@ import { getTradeHistory } from "../controllers/user"; export const userRouter = Router(); -userRouter.get("/users/me", requireAccess({ types: ["session"] }), asyncHandler(getUserData)); +userRouter.get("/users/me", requireAccess({ types: ["session"], allowUnverified: true }), asyncHandler(getUserData)); userRouter.get("/trades", requireAccess({ scopes: ["ORDER_READ"] }), asyncHandler(getTradeHistory)); userRouter.get("/balances", requireAccess({ scopes: ["ACCOUNT_READ"] }), asyncHandler(getBalance)); userRouter.post("/deposits", requireAccess({ types: ["service"] }), asyncHandler(createDeposit)); diff --git a/backend/src/types/principal.ts b/backend/src/types/principal.ts index 9d255b5..526e728 100644 --- a/backend/src/types/principal.ts +++ b/backend/src/types/principal.ts @@ -9,4 +9,5 @@ export interface Principal { userId?: string; keyId?: string; scopes?: Scope[]; + emailVerified?: boolean; } diff --git a/backend/tests/access.test.ts b/backend/tests/access.test.ts index 7d26f3c..0299700 100644 --- a/backend/tests/access.test.ts +++ b/backend/tests/access.test.ts @@ -20,6 +20,7 @@ const apiKeyPrincipal: Principal = { userId: "user-1", keyId: "key-1", scopes: ["ACCOUNT_READ", "ORDER_CREATE"], + emailVerified: true, }; test("requireAccess rejects anonymous requests with 401", () => { @@ -36,6 +37,30 @@ test("requireAccess rejects disallowed principal types with 403", () => { expect(next).not.toHaveBeenCalled(); }); +test("requireAccess rejects users with unverified email", () => { + const { res, next } = run( + { type: "session", userId: "user-1", emailVerified: false }, + {}, + ); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ + error: "Verify your email to continue", + code: "EMAIL_NOT_VERIFIED", + }); + expect(next).not.toHaveBeenCalled(); +}); + +test("requireAccess can allow an unverified session", () => { + const { res, next } = run( + { type: "session", userId: "user-1", emailVerified: false }, + { allowUnverified: true }, + ); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); +}); + test("requireAccess allows matching principal type", () => { const { res, next } = run(apiKeyPrincipal, { types: ["session", "api_key"] }); @@ -44,7 +69,10 @@ test("requireAccess allows matching principal type", () => { }); test("requireAccess lets sessions bypass scope checks", () => { - const { res, next } = run({ type: "session", userId: "u" }, { scopes: ["ORDER_CANCEL"] }); + const { res, next } = run( + { type: "session", userId: "u", emailVerified: true }, + { scopes: ["ORDER_CANCEL"] }, + ); expect(next).toHaveBeenCalled(); expect(res.status).not.toHaveBeenCalled(); diff --git a/frontend/src/components/login-form.tsx b/frontend/src/components/login-form.tsx index e7b99e6..5e179c1 100644 --- a/frontend/src/components/login-form.tsx +++ b/frontend/src/components/login-form.tsx @@ -25,10 +25,10 @@ export function LoginForm({ className, ...props }: React.ComponentProps<"div">) setIsLoading(true); try { - const user = await api.signin(email, password); + const user = await api.signin(email.trim(), password); setUser(user); toast.success("Successfully logged in"); - navigate("/markets"); + navigate(user.emailVerified ? "/markets" : "/verify-email"); } catch (err) { toast.error(err instanceof Error ? err.message : "Login failed"); } finally { @@ -63,7 +63,7 @@ export function LoginForm({ className, ...props }: React.ComponentProps<"div">) setPassword(e.target.value)} autoComplete="current-password" diff --git a/frontend/src/components/market/data-panel.tsx b/frontend/src/components/market/data-panel.tsx index 6fb198c..7243ce7 100644 --- a/frontend/src/components/market/data-panel.tsx +++ b/frontend/src/components/market/data-panel.tsx @@ -205,7 +205,7 @@ function Pagination({ } export function DataPanel({ loading = false, refreshKey, symbol }: DataPanelProps) { - const { user } = useAuth(); + const { user, verified } = useAuth(); const { markets } = useMarkets(); const [tab, setTab] = useState("open"); const [openOrders, setOpenOrders] = useState([]); @@ -250,8 +250,8 @@ export function DataPanel({ loading = false, refreshKey, symbol }: DataPanelProp }, []); useEffect(() => { - if (user) fetchData(); - }, [user, refreshKey, fetchData]); + if (user && verified) fetchData(); + }, [user, verified, refreshKey, fetchData]); useEffect(() => { setPage(1); @@ -400,6 +400,15 @@ export function DataPanel({ loading = false, refreshKey, symbol }: DataPanelProp ); } + if (!verified) { + return ( +
+ Verify your email +  to view account data. +
+ ); + } + return (
diff --git a/frontend/src/components/market/trade-form.tsx b/frontend/src/components/market/trade-form.tsx index 6f241ea..4274c66 100644 --- a/frontend/src/components/market/trade-form.tsx +++ b/frontend/src/components/market/trade-form.tsx @@ -51,7 +51,8 @@ export function TradeForm({ const [submitting, setSubmitting] = useState(false); const [balance, setBalance] = useState({}); const [balanceRefreshKey, setBalanceRefreshKey] = useState(0); - const { authenticated, loading: authLoading } = useAuth(); + const { authenticated, verified, loading: authLoading } = useAuth(); + const canTrade = authenticated && verified; const market = useMarket(symbol); const base = market?.baseAsset ?? symbol.split("_")[0] ?? symbol; @@ -60,8 +61,8 @@ export function TradeForm({ const qtyPrecision = market?.qtyPrecision ?? 4; const priceStep = 10 ** -pricePrecision; const qtyStep = 10 ** -qtyPrecision; - const availableQuote = authenticated ? Number(balance[quote]?.available ?? 0) : 0; - const availableBase = authenticated ? Number(balance[base]?.available ?? 0) : 0; + const availableQuote = canTrade ? Number(balance[quote]?.available ?? 0) : 0; + const availableBase = canTrade ? Number(balance[base]?.available ?? 0) : 0; const effectivePrice = orderType === "LIMIT" ? Number(price) : Number(lastPrice); const midPrice = useMemo(() => { @@ -73,17 +74,17 @@ export function TradeForm({ const bboPrice = side === "BUY" ? Number(bestAsk) : Number(bestBid); const maxQuantity = useMemo(() => { - if (!authenticated) return 0; + if (!canTrade) return 0; if (side === "SELL") return availableBase; if (!isPositive(effectivePrice)) return 0; return availableQuote / effectivePrice; - }, [authenticated, side, availableBase, availableQuote, effectivePrice]); + }, [canTrade, side, availableBase, availableQuote, effectivePrice]); const maximumQuantityValue = truncate(maxQuantity, qtyPrecision); const maximumQuantityText = formatQty(maximumQuantityValue, qtyPrecision); const requestedQuantity = Number(quantity); const exceedsMaximum = - authenticated && + canTrade && isPositive(requestedQuantity) && requestedQuantity > maxQuantity + Number.EPSILON; const maximumMessage = @@ -91,13 +92,13 @@ export function TradeForm({ ? `You can buy a maximum of ${maximumQuantityText} ${base} with your available ${quote}.` : `You can sell a maximum of ${maximumQuantityText} ${base}.`; const sliderDisabled = - !authenticated || + !canTrade || orderType === "MARKET" || maxQuantity <= 0 || (side === "BUY" && !isPositive(effectivePrice)); useEffect(() => { - if (!authenticated) { + if (!canTrade) { setBalance({}); return; } @@ -108,7 +109,7 @@ export function TradeForm({ console.error("Failed to fetch balance:", error); toast.error("Failed to fetch balance"); }); - }, [authenticated, balanceRefreshKey]); + }, [canTrade, balanceRefreshKey]); useEffect(() => { setPrice(formatPrice(lastPrice ?? "", pricePrecision)); @@ -206,7 +207,7 @@ export function TradeForm({ : formatQty(availableBase, qtyPrecision); const balanceAsset = side === "BUY" ? quote : base; const showMaximum = - authenticated && isPositive(maxQuantity) && (side === "SELL" || orderType === "LIMIT"); + canTrade && isPositive(maxQuantity) && (side === "SELL" || orderType === "LIMIT"); return (
@@ -259,7 +260,7 @@ export function TradeForm({
Balance - {authenticated ? `${displayBalance} ${balanceAsset}` : "—"} + {canTrade ? `${displayBalance} ${balanceAsset}` : "—"}
@@ -338,7 +339,7 @@ export function TradeForm({

{maximumMessage}

)} - {authenticated ? ( + {canTrade ? ( + ) : authenticated ? ( + ) : (
- - - - -
- -

- Didn't receive the email? Check your spam folder or try resending it. -

- - -
- ); - } - return (
Create your account - Start trading with paper money + Verify your email before you start trading
- + - Full Name + Full name setName(e.target.value)} + onChange={(event) => setName(event.target.value)} autoComplete="name" required /> - Email + Email setEmail(e.target.value)} + onChange={(event) => setEmail(event.target.value)} autoComplete="email" required /> - - - Password - setPassword(e.target.value)} - autoComplete="new-password" - required - /> - - - Confirm Password - setConfirmPassword(e.target.value)} - autoComplete="new-password" - required - /> - - - Must be at least 8 characters long. + Password + setPassword(event.target.value)} + autoComplete="new-password" + minLength={8} + required + /> + Use at least 8 characters. - Already have an account? Sign in diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index aecbbf7..c40d42b 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -1,16 +1,18 @@ -import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from "react"; +import React, { createContext, useCallback, useContext, useEffect, useState } from "react"; import { api, isUnauthorized } from "@/lib/api"; type User = { id: string; email: string; name: string; + emailVerified: boolean; }; type AuthContext = { user: User | null; loading: boolean; authenticated: boolean; + verified: boolean; refreshUser: () => Promise; setUser: (user: User | null) => void; }; @@ -42,7 +44,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { }, [refreshUser]); return ( - + {children} ); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index f3c26c8..074ad05 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -60,26 +60,23 @@ export const api = { }, signin(email: string, password: string) { - return request<{ id: string; name: string; email: string }>("/auth/login", { + return request("/auth/login", { method: "POST", body: JSON.stringify({ email, password }), }); }, signup(email: string, name: string, password: string) { - return request<{ success: boolean; message: string }>("/auth/signup", { + return request("/auth/signup", { method: "POST", body: JSON.stringify({ email, name, password }), }); }, verifyEmail(token: string) { - return request<{ id: string; name: string; email: string }>( - `/auth/verify-email?token=${encodeURIComponent(token)}`, - { - method: "POST", - }, - ); + return request<{ message: string }>(`/auth/verify-email?token=${encodeURIComponent(token)}`, { + method: "POST", + }); }, resendVerificationEmail(email: string): Promise<{ success: boolean; message: string }> { diff --git a/frontend/src/pages/trade.tsx b/frontend/src/pages/trade.tsx index 106ace1..c3fada2 100644 --- a/frontend/src/pages/trade.tsx +++ b/frontend/src/pages/trade.tsx @@ -17,7 +17,7 @@ import { useTrades } from "@/hooks/use-trades"; export function TradePage() { const { symbol = "BTC_USD" } = useParams(); - const { loading: authLoading } = useAuth(); + const { loading: authLoading, authenticated, verified } = useAuth(); const { markets } = useMarkets(); const { tickers, loading: tickerLoading } = useTickers(); const [orderbookRefreshKey, setOrderbookRefreshKey] = useState(0); @@ -135,7 +135,9 @@ export function TradePage() {
-
+
diff --git a/frontend/src/pages/verify-email.tsx b/frontend/src/pages/verify-email.tsx index e3e9416..56c84e9 100644 --- a/frontend/src/pages/verify-email.tsx +++ b/frontend/src/pages/verify-email.tsx @@ -1,122 +1,143 @@ import { useEffect, useState } from "react"; -import { Link, useNavigate, useSearchParams } from "react-router-dom"; +import { Link, useLocation, useNavigate, useSearchParams } from "react-router-dom"; +import { Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { api } from "@/lib/api"; -import { Loader2 } from "lucide-react"; import { useAuth } from "@/context/AuthContext"; +import { api } from "@/lib/api"; import { toast } from "sonner"; +type Status = "waiting" | "verifying" | "success" | "error"; + export function VerifyEmailPage() { const [searchParams] = useSearchParams(); const token = searchParams.get("token"); - const { setUser } = useAuth(); + const { user, verified, loading, refreshUser } = useAuth(); const navigate = useNavigate(); - - const [status, setStatus] = useState<"loading" | "success" | "no-token" | "api-error">("loading"); + const location = useLocation(); + const emailWasJustSent = Boolean((location.state as { emailSent?: boolean } | null)?.emailSent); + const [status, setStatus] = useState(token ? "verifying" : "waiting"); + const [resending, setResending] = useState(false); + const [resendAt, setResendAt] = useState(() => + emailWasJustSent ? Date.now() + 30_000 : null, + ); + const [countdown, setCountdown] = useState(emailWasJustSent ? 30 : 0); useEffect(() => { - if (!token) { - setStatus("no-token"); - return; - } + if (!resendAt) return; - let cancelled = false; + const updateCountdown = () => { + const remaining = Math.max(0, Math.ceil((resendAt - Date.now()) / 1000)); + setCountdown(remaining); + if (remaining === 0) setResendAt(null); + }; - (async () => { - try { - const user = await api.verifyEmail(token); - setUser(user); - if (!cancelled) setStatus("success"); - } catch (err) { - if (!cancelled) { - setStatus("api-error"); - toast.error( - err instanceof Error ? err.message : "An error occurred while verifying your email.", - ); - } - } - })(); + updateCountdown(); + const timer = window.setInterval(updateCountdown, 1000); + return () => window.clearInterval(timer); + }, [resendAt]); + + useEffect(() => { + if (!token || loading || verified) return; + let active = true; + + api + .verifyEmail(token) + .then((result) => { + if (!active) return; + refreshUser(); + setStatus("success"); + toast.success(result.message); + }) + .catch((error) => { + if (!active) return; + setStatus("error"); + toast.error(error instanceof Error ? error.message : "Email verification failed"); + }); return () => { - cancelled = true; + active = false; }; - }, [token, setUser]); + }, [token, loading, verified]); useEffect(() => { - if (status === "success") { - const id = setTimeout(() => navigate("/markets"), 1500); - return () => clearTimeout(id); + if (!verified) return; + const timer = window.setTimeout(() => navigate("/markets", { replace: true }), 1000); + return () => window.clearTimeout(timer); + }, [verified, navigate]); + + const handleResend = async () => { + if (!user || resending || resendAt) return; + setResending(true); + try { + const result = await api.resendVerificationEmail(user.email); + setResendAt(Date.now() + 30_000); + toast.success(result.message); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Failed to resend verification email"); + } finally { + setResending(false); } - }, [status, navigate]); + }; + + if (loading || status === "verifying") { + return ( +
+ + Verifying your email... +
+ ); + } return (
-
- - - {status === "loading" && ( - <> - Verifying your email - - We're confirming your email address. This will only take a moment. - - - )} - - {status === "success" && ( + + + + {verified + ? "Email verified" + : status === "error" + ? "Verification failed" + : "Check your inbox"} + + + {verified ? ( + "Your email has been successfully verified. Redirecting to the markets page..." + ) : user ? ( <> - You're all set - - Your email has been verified successfully. Redirecting you shortly. - + To create your PaperDrill account, click the verification button in the email we + sent to: {user.email}. + ) : ( + "Sign in to request a new verification email." )} - - {status === "no-token" && ( - <> - Unable to verify - - We're unable to process your verification request. Please try signing in and - request a new verification email. - - - )} - - {status === "api-error" && ( - <> - Verification failed - - This verification link is invalid or has expired. Please sign in and request a new - one. - - - )} - - - - {status === "loading" && ( -
- - Verifying your account... -
- )} - - {status === "success" && ( -
- - Redirecting to markets... -
- )} - - {(status === "no-token" || status === "api-error") && ( - - - - )} -
-
-
+ + + + {user && !verified && ( + <> + Don't see the email in your inbox or spam folder? + + + )} + {!user && !verified && ( + + )} + +
); } diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index a581a7a..603f54b 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -2,6 +2,7 @@ export type UserData = { id: string; email: string; name: string; + emailVerified: boolean; }; export type OrderResult = { From 65f6eb4ae259388da70f78e6e2c5f44c2625226d Mon Sep 17 00:00:00 2001 From: Harshil Date: Wed, 2 Sep 2026 15:57:09 +0530 Subject: [PATCH 2/5] feat: implement user balance initialization on signup --- backend/src/controllers/auth.ts | 31 ++++++++++++ backend/src/types/engine.ts | 3 +- engine/src/core/dispatcher.ts | 2 + engine/src/handlers/initializeBalance.ts | 14 ++++++ engine/src/modules/balance.ts | 60 ++++++++++++------------ engine/src/schema/index.ts | 2 + engine/src/types/request.ts | 3 +- engine/tests/get-balance.test.ts | 56 ++++++++++++++++++---- engine/tests/utils.ts | 10 ++-- 9 files changed, 134 insertions(+), 47 deletions(-) create mode 100644 engine/src/handlers/initializeBalance.ts diff --git a/backend/src/controllers/auth.ts b/backend/src/controllers/auth.ts index 292bb64..966d41b 100644 --- a/backend/src/controllers/auth.ts +++ b/backend/src/controllers/auth.ts @@ -13,6 +13,30 @@ import { logger } from "../utils/logger"; import { config } from "../config"; import crypto from "crypto"; import { sendVerificationEmail } from "../utils/emailClient"; +import { sendToEngine } from "../utils/engineClient"; +import { assetPrecision } from "../store/market"; +import { toBigInt } from "../utils/convert"; + +const INITIAL_BALANCE_ASSET = "USD"; +const INITIAL_BALANCE_AMOUNT = "10000"; + +async function initializeBalance(userId: string) { + const precision = assetPrecision.get(INITIAL_BALANCE_ASSET); + + if (precision == null) { + throw new Error(`Missing precision for ${INITIAL_BALANCE_ASSET}`); + } + + const response = await sendToEngine("initialize_balance", { + userId, + asset: INITIAL_BALANCE_ASSET, + amount: toBigInt(INITIAL_BALANCE_AMOUNT, precision).toString(), + }); + + if (!response.success) { + throw new Error(response.error ?? "Balance initialization failed"); + } +} export async function signup(req: Request, res: Response) { const parsedBody = signupSchema.safeParse(req.body); @@ -38,6 +62,13 @@ export async function signup(req: Request, res: Response) { data: { email, name, password: hashedPassword }, }); + try { + await initializeBalance(user.id); + } catch (error) { + await prisma.user.delete({ where: { id: user.id } }); + throw error; + } + const token = crypto.randomBytes(32).toString("hex"); await prisma.verificationToken.create({ diff --git a/backend/src/types/engine.ts b/backend/src/types/engine.ts index 7e3880e..d1f1f55 100644 --- a/backend/src/types/engine.ts +++ b/backend/src/types/engine.ts @@ -5,7 +5,8 @@ export type EngineCommandType = | "get_open_orders" | "get_trades" | "cancel_order" - | "create_deposit"; + | "create_deposit" + | "initialize_balance"; export interface EngineRequest { correlationId: string; diff --git a/engine/src/core/dispatcher.ts b/engine/src/core/dispatcher.ts index 2d85a68..ab40b31 100644 --- a/engine/src/core/dispatcher.ts +++ b/engine/src/core/dispatcher.ts @@ -6,6 +6,7 @@ import { getUserBalanceHandler } from "../handlers/getUserBalance"; import { getOpenOrdersHandler } from "../handlers/getOpenOrders"; import { getTradesHandler } from "../handlers/getTrades"; import { createDepositHandler } from "../handlers/createDeposit"; +import { initializeBalanceHandler } from "../handlers/initializeBalance"; type EngineHandler = (payload: Record) => Promise; @@ -17,6 +18,7 @@ const handlers: Record = { get_open_orders: getOpenOrdersHandler, get_trades: getTradesHandler, create_deposit: createDepositHandler, + initialize_balance: initializeBalanceHandler, }; export async function dispatch(request: EngineRequest) { diff --git a/engine/src/handlers/initializeBalance.ts b/engine/src/handlers/initializeBalance.ts new file mode 100644 index 0000000..cfab190 --- /dev/null +++ b/engine/src/handlers/initializeBalance.ts @@ -0,0 +1,14 @@ +import { initializeUserBalance } from "../modules/balance"; +import { initializeBalancePayloadSchema } from "../schema"; + +export async function initializeBalanceHandler(payload: Record) { + const parsed = initializeBalancePayloadSchema.safeParse(payload); + + if (!parsed.success) { + throw new Error(parsed.error.issues[0]?.message ?? "Invalid payload"); + } + + const { userId, asset, amount } = parsed.data; + + return initializeUserBalance(userId, asset, amount); +} diff --git a/engine/src/modules/balance.ts b/engine/src/modules/balance.ts index 3314374..05d855d 100644 --- a/engine/src/modules/balance.ts +++ b/engine/src/modules/balance.ts @@ -23,51 +23,51 @@ function qtyScale(qtyPrecision: number): bigint { return 10n ** BigInt(qtyPrecision); } -function initializeBalance(): UserBalance { - const balance: UserBalance = {}; - for (const asset of ASSETS) { - balance[asset] ??= { available: 10000000n, locked: 0n }; - } - - return balance; -} - function getAssetBalance(balance: UserBalance, asset: string) { const assetBalance = balance[asset]; if (!assetBalance) throw new Error(`Missing balance for asset: ${asset}`); return assetBalance; } -export function addBalance(userId: string, asset: string, amount: bigint) { +function ensureAssetBalance(balance: UserBalance, asset: string) { + return (balance[asset] ??= { available: 0n, locked: 0n }); +} + +export function initializeUserBalance(userId: string, asset: string, amount: bigint) { if (!ASSETS.has(asset)) { throw new Error(`Unknown asset: ${asset}`); } if (!BALANCES[userId]) { - BALANCES[userId] = initializeBalance(); + BALANCES[userId] = { + [asset]: { available: amount, locked: 0n }, + }; } - const userBalance = BALANCES[userId]; + return BALANCES[userId]; +} - if (!userBalance[asset]) { - userBalance[asset] = { available: 0n, locked: 0n }; +export function addBalance(userId: string, asset: string, amount: bigint) { + if (!ASSETS.has(asset)) { + throw new Error(`Unknown asset: ${asset}`); } - userBalance[asset].available += amount; + const userBalance = (BALANCES[userId] ??= {}); + const assetBalance = ensureAssetBalance(userBalance, asset); + assetBalance.available += amount; - return { [asset]: userBalance[asset] }; + return { [asset]: assetBalance }; } export function getUserBalance(userId: string, asset?: string): UserBalance { - if (!BALANCES[userId]) { - BALANCES[userId] = initializeBalance(); - } + const userBalance = BALANCES[userId] ?? {}; if (asset) { - return { [asset]: getAssetBalance(BALANCES[userId], asset) }; + const assetBalance = userBalance[asset]; + return assetBalance ? { [asset]: assetBalance } : {}; } - return BALANCES[userId]; + return userBalance; } export function lockBalance(order: CreateOrderInput): LockResult { @@ -76,10 +76,8 @@ export function lockBalance(order: CreateOrderInput): LockResult { const market = getMarket(symbol); const userBalance = getUserBalance(userId); - const base = getAssetBalance(userBalance, market.baseAsset); - const quote = getAssetBalance(userBalance, market.quoteAsset); - if (side === "BUY") { + const quote = userBalance[market.quoteAsset]; const scale = qtyScale(market.qtyPrecision); let lockAmount: bigint; @@ -92,7 +90,7 @@ export function lockBalance(order: CreateOrderInput): LockResult { lockAmount = (((bestAsk * qty) / scale) * 11n) / 10n; } - if (quote.available < lockAmount) { + if (!quote || quote.available < lockAmount) { throw new Error("Insufficient balance"); } @@ -104,11 +102,12 @@ export function lockBalance(order: CreateOrderInput): LockResult { locked: lockAmount, }; } else { + const base = userBalance[market.baseAsset]; if (type === "MARKET" && market.bestBid == null) { throw new Error("No liquidity"); } - if (base.available < qty) { + if (!base || base.available < qty) { throw new Error("Insufficient balance"); } @@ -131,9 +130,9 @@ export function settleFills(fills: Fill[]): SettleResult[] { const sellerBalance = getUserBalance(sellerId); const buyerQuote = getAssetBalance(buyerBalance, market.quoteAsset); - const sellerQuote = getAssetBalance(sellerBalance, market.quoteAsset); + const sellerQuote = ensureAssetBalance(sellerBalance, market.quoteAsset); - const buyerBase = getAssetBalance(buyerBalance, market.baseAsset); + const buyerBase = ensureAssetBalance(buyerBalance, market.baseAsset); const sellerBase = getAssetBalance(sellerBalance, market.baseAsset); const scale = qtyScale(market.qtyPrecision); @@ -162,10 +161,8 @@ export function releaseBalance(order: InternalOrder): ReleaseResult { const market = getMarket(symbol); const userBalance = getUserBalance(order.userId); - const quote = getAssetBalance(userBalance, market.quoteAsset); - const base = getAssetBalance(userBalance, market.baseAsset); - if (side === "BUY") { + const quote = getAssetBalance(userBalance, market.quoteAsset); const remaining = lockedAmount! - spentAmount; if (remaining < 0) throw new Error("Invalid remaining amount"); @@ -176,6 +173,7 @@ export function releaseBalance(order: InternalOrder): ReleaseResult { return { asset: market.quoteAsset, released: remaining }; } else { + const base = getAssetBalance(userBalance, market.baseAsset); const remainingQty = qty - filledQty; if (base.locked < remainingQty) throw new Error("Insufficient Locked Balance"); diff --git a/engine/src/schema/index.ts b/engine/src/schema/index.ts index 4e77856..4c3f0db 100644 --- a/engine/src/schema/index.ts +++ b/engine/src/schema/index.ts @@ -47,3 +47,5 @@ export const depositPayloadSchema = userPayloadSchema.extend({ amount: z.coerce.bigint().positive("amount must be a positive number"), asset: z.string().trim().min(1, "asset is required"), }); + +export const initializeBalancePayloadSchema = depositPayloadSchema; diff --git a/engine/src/types/request.ts b/engine/src/types/request.ts index 7e3880e..d1f1f55 100644 --- a/engine/src/types/request.ts +++ b/engine/src/types/request.ts @@ -5,7 +5,8 @@ export type EngineCommandType = | "get_open_orders" | "get_trades" | "cancel_order" - | "create_deposit"; + | "create_deposit" + | "initialize_balance"; export interface EngineRequest { correlationId: string; diff --git a/engine/tests/get-balance.test.ts b/engine/tests/get-balance.test.ts index 7281efa..218fcc1 100644 --- a/engine/tests/get-balance.test.ts +++ b/engine/tests/get-balance.test.ts @@ -1,26 +1,64 @@ import { beforeEach, expect, test } from "bun:test"; -import { getUserBalance } from "../src/modules/balance"; +import { getUserBalance, initializeUserBalance } from "../src/modules/balance"; +import { BALANCES } from "../src/store"; import { resetState, cancelOrder, placeOrder } from "./utils"; beforeEach(() => { resetState(); }); -test("new user balance", () => { - const balance = getUserBalance("1"); +test("reading a new user balance does not initialize funds", () => { + const balance = getUserBalance("3"); - expect(balance).toMatchObject({ + expect(balance).toEqual({}); + expect(BALANCES["3"]).toBeUndefined(); +}); + +test("initializes a new user with only the starting asset", () => { + const balance = initializeUserBalance("3", "USD", 100000n); + + expect(balance).toEqual({ USD: { - available: 1000000n, - locked: 0n, - }, - BTC: { - available: 1000000n, + available: 100000n, locked: 0n, }, }); }); +test("does not grant the starting balance twice", () => { + initializeUserBalance("3", "USD", 100000n); + initializeUserBalance("3", "USD", 100000n); + + expect(getUserBalance("3").USD?.available).toBe(100000n); +}); + +test("a fill adds acquired assets to sparse balances", async () => { + delete BALANCES["1"]?.USD; + delete BALANCES["2"]?.BTC; + + await placeOrder({ + id: crypto.randomUUID(), + userId: "1", + side: "SELL", + type: "LIMIT", + symbol: "BTC_USD", + price: 10000n, + qty: 50000n, + }); + await placeOrder({ + id: crypto.randomUUID(), + userId: "2", + side: "BUY", + type: "LIMIT", + symbol: "BTC_USD", + price: 10000n, + qty: 50000n, + }); + + expect(getUserBalance("1").USD).toEqual({ available: 50000n, locked: 0n }); + expect(getUserBalance("2").BTC).toEqual({ available: 50000n, locked: 0n }); +}); + test("buyer balance after fill", async () => { await placeOrder({ id: crypto.randomUUID(), diff --git a/engine/tests/utils.ts b/engine/tests/utils.ts index e07cb5c..35df954 100644 --- a/engine/tests/utils.ts +++ b/engine/tests/utils.ts @@ -1,10 +1,14 @@ -import { BALANCES, ORDERBOOK, ORDERS, ARCHIVED_ORDERS, RECENT_TRADES } from "../src/store"; +import { ASSETS, BALANCES, ORDERBOOK, ORDERS, ARCHIVED_ORDERS, RECENT_TRADES } from "../src/store"; import { createOrderHandler } from "../src/handlers/createOrder"; import { cancelOrderHandler } from "../src/handlers/cancelOrder"; export function resetState() { for (const symbol of Object.keys(ORDERBOOK)) delete ORDERBOOK[symbol]; for (const symbol of Object.keys(RECENT_TRADES)) delete RECENT_TRADES[symbol]; + for (const userId of Object.keys(BALANCES)) delete BALANCES[userId]; + ASSETS.clear(); + ASSETS.add("BTC"); + ASSETS.add("USD"); ORDERBOOK.BTC_USD = { baseAsset: "BTC", @@ -20,15 +24,11 @@ export function resetState() { BALANCES["1"] = { USD: { available: 1000000n, locked: 0n }, BTC: { available: 1000000n, locked: 0n }, - SOL: { available: 1000000n, locked: 0n }, - ETH: { available: 1000000n, locked: 0n }, }; BALANCES["2"] = { USD: { available: 1000000n, locked: 0n }, BTC: { available: 1000000n, locked: 0n }, - SOL: { available: 1000000n, locked: 0n }, - ETH: { available: 1000000n, locked: 0n }, }; ORDERS.clear(); From 0ece96aa847b75b6e053c382b417bd44b3f290ba Mon Sep 17 00:00:00 2001 From: Harshil Date: Wed, 2 Sep 2026 19:02:19 +0530 Subject: [PATCH 3/5] feat: add dashboard, api keys, account overview, improved profile --- frontend/bun-env.d.ts | 2 + frontend/src/App.tsx | 20 +- frontend/src/components/app-layout.tsx | 9 +- frontend/src/components/app-sidebar.tsx | 57 ++ frontend/src/components/dashboard-layout.tsx | 61 ++ frontend/src/components/dashboard-page.tsx | 29 + frontend/src/components/login-form.tsx | 2 +- frontend/src/components/market/data-panel.tsx | 30 +- frontend/src/components/nav-main.tsx | 46 ++ frontend/src/components/nav-user.tsx | 86 +++ frontend/src/components/navbar.tsx | 43 +- frontend/src/components/route-guards.tsx | 2 +- frontend/src/components/ui/breadcrumb.tsx | 109 +++ frontend/src/components/ui/collapsible.tsx | 33 + frontend/src/components/ui/sidebar.tsx | 680 ++++++++++++++++++ frontend/src/components/ui/tooltip.tsx | 55 ++ frontend/src/hooks/use-mobile.ts | 19 + frontend/src/lib/api.ts | 29 +- frontend/src/lib/seo.ts | 16 + frontend/src/pages/dashboard/api-keys.tsx | 286 ++++++++ frontend/src/pages/dashboard/balances.tsx | 111 +++ frontend/src/pages/dashboard/data.tsx | 15 + frontend/src/pages/dashboard/overview.tsx | 234 ++++++ frontend/src/pages/profile.tsx | 220 +++--- frontend/src/pages/verify-email.tsx | 2 +- frontend/src/types/index.ts | 15 + frontend/src/utils/format.ts | 2 +- frontend/styles/globals.css | 24 + 28 files changed, 2061 insertions(+), 176 deletions(-) create mode 100644 frontend/src/components/app-sidebar.tsx create mode 100644 frontend/src/components/dashboard-layout.tsx create mode 100644 frontend/src/components/dashboard-page.tsx create mode 100644 frontend/src/components/nav-main.tsx create mode 100644 frontend/src/components/nav-user.tsx create mode 100644 frontend/src/components/ui/breadcrumb.tsx create mode 100644 frontend/src/components/ui/collapsible.tsx create mode 100644 frontend/src/components/ui/sidebar.tsx create mode 100644 frontend/src/components/ui/tooltip.tsx create mode 100644 frontend/src/hooks/use-mobile.ts create mode 100644 frontend/src/pages/dashboard/api-keys.tsx create mode 100644 frontend/src/pages/dashboard/balances.tsx create mode 100644 frontend/src/pages/dashboard/data.tsx create mode 100644 frontend/src/pages/dashboard/overview.tsx diff --git a/frontend/bun-env.d.ts b/frontend/bun-env.d.ts index 96cf916..a4c8f5a 100644 --- a/frontend/bun-env.d.ts +++ b/frontend/bun-env.d.ts @@ -16,6 +16,8 @@ declare module "*.svg" { export = path; } +declare module "*.css"; + declare module "*.module.css" { /** * A record of class names to their corresponding CSS module classes diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d51c01b..f7dc259 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,5 @@ import "./index.css"; -import { Routes, Route, Link } from "react-router-dom"; +import { Routes, Route, Link, Navigate } from "react-router-dom"; import { LandingPage } from "./pages/landing"; import { LoginPage } from "./pages/login"; import { SignupPage } from "./pages/signup"; @@ -14,6 +14,11 @@ import { PrivacyPage } from "./pages/privacy"; import { AppLayout } from "./components/app-layout"; import { RootLayout } from "./components/root-layout"; import { Protected, PublicOnly } from "./components/route-guards"; +import { DashboardLayout } from "./components/dashboard-layout"; +import { DashboardOverviewPage } from "./pages/dashboard/overview"; +import { DashboardApiKeysPage } from "./pages/dashboard/api-keys"; +import { DashboardBalancesPage } from "./pages/dashboard/balances"; +import { DashboardDataPage } from "./pages/dashboard/data"; function NotFound() { return ( @@ -40,13 +45,20 @@ export function App() { }> - + } - /> + > + } /> + } /> + } /> + } /> + } /> + + } /> } /> } /> - + {!isDashboard && }
- + {!isDashboard && }
diff --git a/frontend/src/components/app-sidebar.tsx b/frontend/src/components/app-sidebar.tsx new file mode 100644 index 0000000..b838d63 --- /dev/null +++ b/frontend/src/components/app-sidebar.tsx @@ -0,0 +1,57 @@ +import { ArrowUpRight, Database, KeyRound, LayoutDashboard, WalletCards } from "lucide-react"; +import type { ComponentProps } from "react"; +import { Link } from "react-router-dom"; +import { BrandLogo } from "@/components/brand-logo"; +import { NavMain, type DashboardNavItem } from "@/components/nav-main"; +import { NavUser } from "@/components/nav-user"; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarRail, +} from "@/components/ui/sidebar"; + +const navigation: DashboardNavItem[] = [ + { title: "Overview", url: "/dashboard", icon: LayoutDashboard, end: true }, + { title: "API keys", url: "/dashboard/api-keys", icon: KeyRound }, + { title: "Balances", url: "/dashboard/balances", icon: WalletCards }, + { title: "Account data", url: "/dashboard/data", icon: Database }, +]; + +export function AppSidebar(props: ComponentProps) { + return ( + + + + + + +
+ + + + + + Open trading + + + + +
+
+ + + + +
+ ); +} diff --git a/frontend/src/components/dashboard-layout.tsx b/frontend/src/components/dashboard-layout.tsx new file mode 100644 index 0000000..fb14286 --- /dev/null +++ b/frontend/src/components/dashboard-layout.tsx @@ -0,0 +1,61 @@ +import { Outlet, useLocation } from "react-router-dom"; +import { Moon, Sun } from "lucide-react"; +import { AppSidebar } from "@/components/app-sidebar"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb"; +import { Separator } from "@/components/ui/separator"; +import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; +import { Button } from "@/components/ui/button"; +import { useTheme } from "@/lib/theme-provider"; + +const pageNames: Record = { + "/dashboard": "Overview", + "/dashboard/api-keys": "API keys", + "/dashboard/balances": "Balances", + "/dashboard/data": "Account data", + "/dashboard/profile": "Profile", +}; + +export function DashboardLayout() { + const { pathname } = useLocation(); + const { theme, toggleTheme } = useTheme(); + const pageName = pageNames[pathname] ?? "Dashboard"; + + return ( + + + +
+ + + + + Dashboard + + + {pageName} + + + + +
+
+ +
+
+
+ ); +} diff --git a/frontend/src/components/dashboard-page.tsx b/frontend/src/components/dashboard-page.tsx new file mode 100644 index 0000000..ad50d09 --- /dev/null +++ b/frontend/src/components/dashboard-page.tsx @@ -0,0 +1,29 @@ +import type { ReactNode } from "react"; +import { cn } from "@/lib/utils"; + +export function DashboardPage({ + title, + description, + action, + children, + className, +}: { + title: string; + description: string; + action?: ReactNode; + children: ReactNode; + className?: string; +}) { + return ( +
+
+
+

{title}

+

{description}

+
+ {action} +
+ {children} +
+ ); +} diff --git a/frontend/src/components/login-form.tsx b/frontend/src/components/login-form.tsx index 5e179c1..ff2a810 100644 --- a/frontend/src/components/login-form.tsx +++ b/frontend/src/components/login-form.tsx @@ -28,7 +28,7 @@ export function LoginForm({ className, ...props }: React.ComponentProps<"div">) const user = await api.signin(email.trim(), password); setUser(user); toast.success("Successfully logged in"); - navigate(user.emailVerified ? "/markets" : "/verify-email"); + navigate(user.emailVerified ? "/dashboard" : "/verify-email"); } catch (err) { toast.error(err instanceof Error ? err.message : "Login failed"); } finally { diff --git a/frontend/src/components/market/data-panel.tsx b/frontend/src/components/market/data-panel.tsx index 7243ce7..7e498d7 100644 --- a/frontend/src/components/market/data-panel.tsx +++ b/frontend/src/components/market/data-panel.tsx @@ -215,7 +215,7 @@ export function DataPanel({ loading = false, refreshKey, symbol }: DataPanelProp const [fetching, setFetching] = useState(true); const [cancelling, setCancelling] = useState(null); const [search, setSearch] = useState(""); - const [currentMarketOnly, setCurrentMarketOnly] = useState(true); + const [currentMarketOnly, setCurrentMarketOnly] = useState(Boolean(symbol)); const [marketFilter, setMarketFilter] = useState("all"); const [sideFilter, setSideFilter] = useState("all"); const [typeFilter, setTypeFilter] = useState("all"); @@ -403,7 +403,9 @@ export function DataPanel({ loading = false, refreshKey, symbol }: DataPanelProp if (!verified) { return (
- Verify your email + + Verify your email +  to view account data.
); @@ -435,17 +437,19 @@ export function DataPanel({ loading = false, refreshKey, symbol }: DataPanelProp {tab !== "balance" && (
- + {symbol && ( + + )} diff --git a/frontend/src/components/nav-main.tsx b/frontend/src/components/nav-main.tsx new file mode 100644 index 0000000..e9da14c --- /dev/null +++ b/frontend/src/components/nav-main.tsx @@ -0,0 +1,46 @@ +import type { LucideIcon } from "lucide-react"; +import { NavLink } from "react-router-dom"; +import { + SidebarGroup, + SidebarGroupContent, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar"; + +export type DashboardNavItem = { + title: string; + url: string; + icon: LucideIcon; + end?: boolean; +}; + +export function NavMain({ items }: { items: DashboardNavItem[] }) { + return ( + + + + {items.map((item) => ( + + + {({ isActive }) => ( + + + + {item.title} + + + )} + + + ))} + + + + ); +} diff --git a/frontend/src/components/nav-user.tsx b/frontend/src/components/nav-user.tsx new file mode 100644 index 0000000..d1a89c0 --- /dev/null +++ b/frontend/src/components/nav-user.tsx @@ -0,0 +1,86 @@ +import { ChevronsUpDown, KeyRound, LogOut, UserRound } from "lucide-react"; +import { Link, useNavigate } from "react-router-dom"; +import { toast } from "sonner"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from "@/components/ui/sidebar"; +import { useAuth } from "@/context/AuthContext"; +import { api } from "@/lib/api"; + +export function NavUser() { + const { user, setUser } = useAuth(); + const { isMobile } = useSidebar(); + const navigate = useNavigate(); + + if (!user) return null; + + const handleLogout = async () => { + try { + await api.signout(); + } catch (error) { + console.error("Signout failed:", error); + } finally { + setUser(null); + toast.success("Logged out successfully"); + navigate("/"); + } + }; + + return ( + + + + + + + + {user.name.slice(0, 1).toUpperCase()} + + +
+ {user.name} + {user.email} +
+ +
+
+ + + + Profile + + + + + API keys + + + + + Log out + + +
+
+
+ ); +} diff --git a/frontend/src/components/navbar.tsx b/frontend/src/components/navbar.tsx index 3aa5b20..1e6f3b4 100644 --- a/frontend/src/components/navbar.tsx +++ b/frontend/src/components/navbar.tsx @@ -1,6 +1,17 @@ import { Link, useNavigate } from "react-router-dom"; import { Button } from "./ui/button"; -import { Menu, X, Sun, Moon, User, LogOut, TrendingUp, Activity, ChevronDown, MailCheck } from "lucide-react"; +import { + Menu, + X, + Sun, + Moon, + User, + LogOut, + LayoutDashboard, + KeyRound, + ChevronDown, + MailCheck, +} from "lucide-react"; import { useTheme } from "../lib/theme-provider"; import { BrandLogo } from "@/components/brand-logo"; import { useAuth } from "@/context/AuthContext"; @@ -18,9 +29,9 @@ import { Avatar, AvatarFallback } from "./ui/avatar"; import { Sheet, SheetClose, SheetContent, SheetTitle, SheetTrigger } from "./ui/sheet"; const NAV_LINKS = [ - { label: "Home", to: "/" }, { label: "Markets", to: "/markets" }, { label: "Trading", to: "/trade/BTC_USD" }, + { label: "Dashboard", to: "/dashboard" }, ]; export function Navbar() { @@ -109,7 +120,7 @@ export function Navbar() { <> @@ -213,33 +224,39 @@ export function Navbar() { {!verified && ( - + Verify email )} - - - Profile & Balances + + + Dashboard - - - Spot Markets + + + API keys - - Trading Console + + Profile diff --git a/frontend/src/components/route-guards.tsx b/frontend/src/components/route-guards.tsx index 86391d4..d2ef9db 100644 --- a/frontend/src/components/route-guards.tsx +++ b/frontend/src/components/route-guards.tsx @@ -16,6 +16,6 @@ export function PublicOnly({ children }: { children: ReactNode }) { const { user, verified, loading } = useAuth(); if (loading) return ; - if (user) return ; + if (user) return ; return <>{children}; } diff --git a/frontend/src/components/ui/breadcrumb.tsx b/frontend/src/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..004bb63 --- /dev/null +++ b/frontend/src/components/ui/breadcrumb.tsx @@ -0,0 +1,109 @@ +import * as React from "react" +import { ChevronRight, MoreHorizontal } from "lucide-react" +import { Slot } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { + return