diff --git a/apps/backend/prisma/migrations/20260611120000_password_auth/migration.sql b/apps/backend/prisma/migrations/20260611120000_password_auth/migration.sql
new file mode 100644
index 00000000..d520dc2d
--- /dev/null
+++ b/apps/backend/prisma/migrations/20260611120000_password_auth/migration.sql
@@ -0,0 +1,3 @@
+-- Add nullable password storage for email/password accounts.
+-- Existing OAuth and seeded users keep working without a password hash.
+ALTER TABLE "users" ADD COLUMN "password_hash" TEXT;
diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma
index 28458021..5cf7b116 100644
--- a/apps/backend/prisma/schema.prisma
+++ b/apps/backend/prisma/schema.prisma
@@ -20,6 +20,7 @@ model User {
accentColor String @default("#6366f1") @map("accent_color")
provider String
providerId String @map("provider_id")
+ passwordHash String? @map("password_hash")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -194,4 +195,4 @@ model TeamMember{
@@unique([userId, teamId])
@@index([userId])
@@map("team_members")
-}
\ No newline at end of file
+}
diff --git a/apps/backend/src/__tests__/auth.test.ts b/apps/backend/src/__tests__/auth.test.ts
new file mode 100644
index 00000000..3cab323c
--- /dev/null
+++ b/apps/backend/src/__tests__/auth.test.ts
@@ -0,0 +1,161 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import Fastify from 'fastify';
+import cookie from '@fastify/cookie';
+import jwt from '@fastify/jwt';
+import { authRoutes } from '../routes/auth.js';
+import type { PrismaClient } from '@prisma/client';
+
+const mockSafeUser = {
+ id: 'user-123',
+ email: 'test@example.com',
+ username: 'testuser',
+ displayName: 'Test User',
+ bio: null,
+ pronouns: null,
+ role: null,
+ company: null,
+ avatarUrl: null,
+ accentColor: '#6366f1',
+ createdAt: new Date('2026-01-01T00:00:00.000Z'),
+};
+
+const mockPrisma = {
+ user: {
+ findFirst: vi.fn(),
+ findUnique: vi.fn(),
+ create: vi.fn(),
+ },
+ card: {
+ create: vi.fn(),
+ },
+ $transaction: vi.fn(async (callback: (tx: any) => Promise) => callback(mockPrisma)),
+};
+
+async function buildApp() {
+ const app = Fastify();
+ await app.register(cookie);
+ await app.register(jwt, { secret: 'test-secret-for-unit-tests-only' });
+ app.decorate('prisma', mockPrisma as unknown as PrismaClient);
+ app.decorate('authenticate', async (request: any, reply: any) => {
+ try {
+ const payload = await request.jwtVerify();
+ request.user = payload;
+ } catch (err) {
+ reply.status(401).send({ error: 'Unauthorized' });
+ }
+ });
+ await app.register(authRoutes, { prefix: '/auth' });
+ await app.ready();
+ return app;
+}
+
+describe('email/password auth', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockPrisma.$transaction.mockImplementation(async (callback: (tx: any) => Promise) => callback(mockPrisma));
+ });
+
+ it('registers a user, creates a default card, and returns a token', async () => {
+ mockPrisma.user.findFirst.mockResolvedValue(null);
+ mockPrisma.user.create.mockResolvedValue(mockSafeUser);
+ mockPrisma.card.create.mockResolvedValue({ id: 'card-123' });
+
+ const app = await buildApp();
+ const res = await app.inject({
+ method: 'POST',
+ url: '/auth/signup',
+ payload: {
+ email: 'Test@Example.com',
+ password: 'strong-password',
+ username: 'testuser',
+ displayName: 'Test User',
+ },
+ });
+
+ expect(res.statusCode).toBe(201);
+ const body = res.json();
+ expect(body.token).toEqual(expect.any(String));
+ expect(body.user.email).toBe('test@example.com');
+ expect(body.user.passwordHash).toBeUndefined();
+ expect(mockPrisma.user.create).toHaveBeenCalledWith(expect.objectContaining({
+ data: expect.objectContaining({
+ email: 'test@example.com',
+ provider: 'password',
+ passwordHash: expect.stringMatching(/^scrypt:/),
+ }),
+ }));
+ expect(mockPrisma.card.create).toHaveBeenCalledWith(expect.objectContaining({
+ data: expect.objectContaining({
+ userId: 'user-123',
+ title: 'Main DevCard',
+ isDefault: true,
+ }),
+ }));
+ });
+
+ it('rejects duplicate usernames during signup', async () => {
+ mockPrisma.user.findFirst.mockResolvedValue({ email: 'other@example.com', username: 'testuser' });
+
+ const app = await buildApp();
+ const res = await app.inject({
+ method: 'POST',
+ url: '/auth/signup',
+ payload: {
+ email: 'test@example.com',
+ password: 'strong-password',
+ username: 'testuser',
+ displayName: 'Test User',
+ },
+ });
+
+ expect(res.statusCode).toBe(409);
+ expect(res.json().error).toBe('Username already taken');
+ expect(mockPrisma.user.create).not.toHaveBeenCalled();
+ });
+
+ it('logs in with valid credentials', async () => {
+ mockPrisma.user.findFirst.mockResolvedValue(null);
+ mockPrisma.user.create.mockResolvedValue(mockSafeUser);
+ mockPrisma.card.create.mockResolvedValue({ id: 'card-123' });
+
+ const app = await buildApp();
+ const signup = await app.inject({
+ method: 'POST',
+ url: '/auth/signup',
+ payload: {
+ email: 'test@example.com',
+ password: 'strong-password',
+ username: 'testuser',
+ displayName: 'Test User',
+ },
+ });
+ const createdHash = mockPrisma.user.create.mock.calls[0][0].data.passwordHash;
+
+ mockPrisma.user.findUnique.mockResolvedValue({ ...mockSafeUser, passwordHash: createdHash });
+
+ const login = await app.inject({
+ method: 'POST',
+ url: '/auth/login',
+ payload: { email: 'test@example.com', password: 'strong-password' },
+ });
+
+ expect(signup.statusCode).toBe(201);
+ expect(login.statusCode).toBe(200);
+ expect(login.json().token).toEqual(expect.any(String));
+ expect(login.json().user.passwordHash).toBeUndefined();
+ });
+
+ it('rejects invalid login credentials', async () => {
+ mockPrisma.user.findUnique.mockResolvedValue(null);
+
+ const app = await buildApp();
+ const res = await app.inject({
+ method: 'POST',
+ url: '/auth/login',
+ payload: { email: 'test@example.com', password: 'wrong-password' },
+ });
+
+ expect(res.statusCode).toBe(401);
+ expect(res.json().error).toBe('Invalid email or password');
+ });
+});
diff --git a/apps/backend/src/__tests__/profiles.test.ts b/apps/backend/src/__tests__/profiles.test.ts
index 0633b841..fe58a93e 100644
--- a/apps/backend/src/__tests__/profiles.test.ts
+++ b/apps/backend/src/__tests__/profiles.test.ts
@@ -20,6 +20,7 @@ const mockUser = {
cards: [],
provider: 'github',
providerId: 'gh-123',
+ passwordHash: 'scrypt:salt:hash',
};
const mockPrisma = {
@@ -54,6 +55,7 @@ describe('GET /api/profiles/me', () => {
expect(body.email).toBe('test@example.com');
expect(body.provider).toBeUndefined();
expect(body.providerId).toBeUndefined();
+ expect(body.passwordHash).toBeUndefined();
});
it('should return 404 if user not found', async () => {
@@ -149,4 +151,4 @@ describe('PUT /api/profiles/me', () => {
expect(res.statusCode).toBe(200);
expect(mockPrisma.user.findFirst).not.toHaveBeenCalled();
});
-});
\ No newline at end of file
+});
diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts
index cffebea7..e69ae5e1 100644
--- a/apps/backend/src/routes/auth.ts
+++ b/apps/backend/src/routes/auth.ts
@@ -1,6 +1,8 @@
import { encrypt } from '../utils/encryption.js';
import { extractRawJwt, blocklistKey } from '../utils/jwt.js';
import { buildOAuthState, getMobileRedirectUri } from '../utils/oauth.js';
+import { loginSchema, signupSchema } from '../utils/validators.js';
+import { hashPassword, verifyPassword } from '../utils/password.js';
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
@@ -16,7 +18,123 @@ interface OAuthCallbackQuery {
state?: string;
}
+const authUserSelect = {
+ id: true,
+ email: true,
+ username: true,
+ displayName: true,
+ bio: true,
+ pronouns: true,
+ role: true,
+ company: true,
+ avatarUrl: true,
+ accentColor: true,
+ createdAt: true,
+};
+
+function setAuthCookie(reply: FastifyReply, token: string) {
+ reply.setCookie('token', token, {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === 'production',
+ sameSite: 'lax',
+ path: '/',
+ maxAge: 30 * 24 * 60 * 60,
+ });
+}
+
+function getDuplicateAuthError(error: any): string {
+ const fields = Array.isArray(error?.meta?.target) ? error.meta.target : [];
+ if (fields.includes('username')) {
+ return 'Username already taken';
+ }
+ return 'Email already registered';
+}
+
export async function authRoutes(app: FastifyInstance): Promise {
+ app.post('/signup', async (request: FastifyRequest, reply: FastifyReply) => {
+ const parsed = signupSchema.safeParse(request.body);
+ if (!parsed.success) {
+ return reply.status(400).send({ error: 'Validation failed', details: parsed.error.flatten() });
+ }
+
+ const { email, password, username, displayName } = parsed.data;
+
+ const existing = await app.prisma.user.findFirst({
+ where: { OR: [{ email }, { username }] },
+ select: { email: true, username: true },
+ });
+
+ if (existing?.email === email) {
+ return reply.status(409).send({ error: 'Email already registered' });
+ }
+
+ if (existing?.username === username) {
+ return reply.status(409).send({ error: 'Username already taken' });
+ }
+
+ try {
+ const passwordHash = await hashPassword(password);
+ const user = await app.prisma.$transaction(async (tx) => {
+ const createdUser = await tx.user.create({
+ data: {
+ email,
+ username,
+ displayName,
+ provider: 'password',
+ providerId: email,
+ passwordHash,
+ },
+ select: authUserSelect,
+ });
+
+ await tx.card.create({
+ data: {
+ userId: createdUser.id,
+ title: 'Main DevCard',
+ isDefault: true,
+ },
+ });
+
+ return createdUser;
+ });
+
+ const token = app.jwt.sign({ id: user.id, username: user.username }, { expiresIn: '30d' });
+ setAuthCookie(reply, token);
+
+ return reply.status(201).send({ token, user });
+ } catch (error: any) {
+ if (error?.code === 'P2002') {
+ return reply.status(409).send({ error: getDuplicateAuthError(error) });
+ }
+
+ app.log.error({ error }, 'Email signup failed');
+ return reply.status(500).send({ error: 'Signup failed' });
+ }
+ });
+
+ app.post('/login', async (request: FastifyRequest, reply: FastifyReply) => {
+ const parsed = loginSchema.safeParse(request.body);
+ if (!parsed.success) {
+ return reply.status(400).send({ error: 'Validation failed', details: parsed.error.flatten() });
+ }
+
+ const { email, password } = parsed.data;
+ const user = await app.prisma.user.findUnique({
+ where: { email },
+ select: { ...authUserSelect, passwordHash: true },
+ });
+
+ const passwordMatches = await verifyPassword(password, user?.passwordHash ?? null);
+ if (!user || !passwordMatches) {
+ return reply.status(401).send({ error: 'Invalid email or password' });
+ }
+
+ const { passwordHash, ...safeUser } = user;
+ const token = app.jwt.sign({ id: user.id, username: user.username }, { expiresIn: '30d' });
+ setAuthCookie(reply, token);
+
+ return { token, user: safeUser };
+ });
// Developer login bypass (development only)
if (process.env.NODE_ENV !== 'production') {
app.post('/dev-login', async (request: FastifyRequest, reply: FastifyReply) => {
@@ -137,13 +255,7 @@ export async function authRoutes(app: FastifyInstance): Promise {
return reply.redirect(`${mobileRedirect}#token=${token}`);
}
- reply.setCookie('token', token, {
- httpOnly: true,
- secure: process.env.NODE_ENV === 'production',
- sameSite: 'lax',
- path: '/',
- maxAge: 30 * 24 * 60 * 60,
- });
+ setAuthCookie(reply, token);
return reply.redirect(`${process.env.PUBLIC_APP_URL}/dashboard`);
} catch (error) {
@@ -239,13 +351,7 @@ export async function authRoutes(app: FastifyInstance): Promise {
return reply.redirect(`${mobileRedirect}#token=${token}`);
}
- reply.setCookie('token', token, {
- httpOnly: true,
- secure: process.env.NODE_ENV === 'production',
- sameSite: 'lax',
- path: '/',
- maxAge: 30 * 24 * 60 * 60,
- });
+ setAuthCookie(reply, token);
return reply.redirect(`${process.env.PUBLIC_APP_URL}/dashboard`);
} catch (error) {
diff --git a/apps/backend/src/services/profileService.ts b/apps/backend/src/services/profileService.ts
index dc97b2a4..d9d4745e 100644
--- a/apps/backend/src/services/profileService.ts
+++ b/apps/backend/src/services/profileService.ts
@@ -14,7 +14,7 @@ export async function getOwnProfile(app: FastifyInstance, userId: string) {
if (!user) return null
- const { provider, providerId, ...profileData } = user as any
+ const { provider, providerId, passwordHash, ...profileData } = user as any
return { ...profileData, defaultCardId: user.cards[0]?.id || null }
}
diff --git a/apps/backend/src/utils/password.ts b/apps/backend/src/utils/password.ts
new file mode 100644
index 00000000..230467ac
--- /dev/null
+++ b/apps/backend/src/utils/password.ts
@@ -0,0 +1,31 @@
+import { timingSafeEqual, randomBytes, scrypt as scryptCallback } from 'node:crypto';
+import { promisify } from 'node:util';
+
+const scrypt = promisify(scryptCallback);
+const KEY_LENGTH = 64;
+
+export async function hashPassword(password: string): Promise {
+ const salt = randomBytes(16).toString('hex');
+ const derivedKey = (await scrypt(password, salt, KEY_LENGTH)) as Buffer;
+ return `scrypt:${salt}:${derivedKey.toString('hex')}`;
+}
+
+export async function verifyPassword(password: string, storedHash: string | null): Promise {
+ if (!storedHash) {
+ return false;
+ }
+
+ const [algorithm, salt, key] = storedHash.split(':');
+ if (algorithm !== 'scrypt' || !salt || !key) {
+ return false;
+ }
+
+ const storedKey = Buffer.from(key, 'hex');
+ const derivedKey = (await scrypt(password, salt, storedKey.length)) as Buffer;
+
+ if (storedKey.length !== derivedKey.length) {
+ return false;
+ }
+
+ return timingSafeEqual(storedKey, derivedKey);
+}
diff --git a/apps/backend/src/utils/validators.ts b/apps/backend/src/utils/validators.ts
index bd41bef2..9bbcbe07 100644
--- a/apps/backend/src/utils/validators.ts
+++ b/apps/backend/src/utils/validators.ts
@@ -54,3 +54,20 @@ export const updateCardSchema = z.object({
title: z.string().min(1).max(100).optional(),
linkIds: z.array(z.string().uuid()).optional(),
});
+
+export const signupSchema = z.object({
+ email: z.string().email().max(255).transform((value) => value.toLowerCase().trim()),
+ password: z.string().min(8, 'Password must be at least 8 characters').max(128),
+ username: z
+ .string()
+ .min(3)
+ .max(50)
+ .regex(/^[a-zA-Z0-9_-]+$/, 'Username can only contain letters, numbers, hyphens, and underscores')
+ .transform((value) => value.trim()),
+ displayName: z.string().min(1).max(100).transform((value) => value.trim()),
+});
+
+export const loginSchema = z.object({
+ email: z.string().email().max(255).transform((value) => value.toLowerCase().trim()),
+ password: z.string().min(1).max(128),
+});
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 49c29037..0c73874c 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -3,11 +3,17 @@ import LandingPage from './pages/LandingPage';
import ProfilePage from './pages/ProfilePage';
import CardPage from './pages/CardPage';
import NotFound from './pages/NotFound';
+import LoginPage from './pages/LoginPage';
+import SignupPage from './pages/SignupPage';
+import DashboardPage from './pages/DashboardPage';
export default function App() {
return (
} />
+ } />
+ } />
+ } />
} />
} />
} />
diff --git a/apps/web/src/components/Navbar.css b/apps/web/src/components/Navbar.css
index 4e66a2f2..94f3fefd 100644
--- a/apps/web/src/components/Navbar.css
+++ b/apps/web/src/components/Navbar.css
@@ -48,9 +48,28 @@
outline-offset: 3px;
}
+.nav-actions {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
@media (max-width: 860px) {
.navbar {
margin-top: 0.9rem;
padding: 0.85rem 1.1rem;
}
}
+
+@media (max-width: 640px) {
+ .nav-content {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .nav-actions {
+ display: grid;
+ grid-template-columns: auto 1fr 1fr;
+ margin-top: 1rem;
+ }
+}
diff --git a/apps/web/src/components/Navbar.tsx b/apps/web/src/components/Navbar.tsx
index debd63c7..bc035be4 100644
--- a/apps/web/src/components/Navbar.tsx
+++ b/apps/web/src/components/Navbar.tsx
@@ -1,9 +1,23 @@
-import { Link } from 'react-router-dom';
+import { useEffect, useState } from 'react';
+import { Link, useNavigate } from 'react-router-dom';
import { useTheme } from '../lib/theme';
+import { getStoredToken, clearStoredToken } from '../lib/auth';
import './Navbar.css';
export default function Navbar() {
const { theme, toggleTheme } = useTheme();
+ const navigate = useNavigate();
+ const [hasToken, setHasToken] = useState(false);
+
+ useEffect(() => {
+ setHasToken(!!getStoredToken());
+ }, []);
+
+ function handleLogout() {
+ clearStoredToken();
+ setHasToken(false);
+ navigate('/');
+ }
return (
@@ -12,14 +26,27 @@ export default function Navbar() {
⚡
DevCard
-
- {theme === 'dark' ? '☀️' : '🌙'}
-
+
+
+ {theme === 'dark' ? '☀️' : '🌙'}
+
+ {hasToken ? (
+ <>
+ Dashboard
+ Log out
+ >
+ ) : (
+ <>
+ Log in
+ Create card
+ >
+ )}
+
);
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts
index be6afdd8..256b3677 100644
--- a/apps/web/src/lib/api.ts
+++ b/apps/web/src/lib/api.ts
@@ -1,15 +1,50 @@
-const API_BASE_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000';
+// In the browser, the Vite dev proxy can forward /auth and /api to the backend,
+// so we use relative URLs to avoid CORS. We fall back to VITE_API_URL if configured.
+const API_BASE_URL = import.meta.env.VITE_API_URL ?? '';
+
+type RequestOptions = {
+ method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
+ body?: unknown;
+ token?: string | null;
+ onUnauthorized?: () => void;
+};
+
+export async function apiFetch(
+ endpoint: string,
+ { method = 'GET', body, token, onUnauthorized }: RequestOptions = {}
+): Promise {
+ const headers: Record = {
+ 'Content-Type': 'application/json',
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ };
-export async function apiFetch(endpoint: string): Promise {
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
- headers: { 'Content-Type': 'application/json' },
+ method,
+ headers,
+ ...(body ? { body: JSON.stringify(body) } : {}),
});
+ if (response.status === 401 || response.status === 403) {
+ onUnauthorized?.();
+ throw new Error('Unauthorized');
+ }
+
if (!response.ok) {
- const error = await response.json().catch(() => ({}));
- throw new Error(
- (error as Record)?.message ?? `Request failed: ${response.status}`
- );
+ const errorBody = await response.json().catch(() => ({})) as any;
+
+ // Unpack zod fieldErrors into a readable string
+ const fieldErrors = errorBody?.details?.fieldErrors as Record | undefined;
+ if (fieldErrors) {
+ const parts = Object.entries(fieldErrors)
+ .flatMap(([field, msgs]) => msgs.map((m: string) => `${field}: ${m}`));
+ if (parts.length) throw new Error(parts.join(' · '));
+ }
+
+ throw new Error(errorBody?.error ?? errorBody?.message ?? `Request failed: ${response.status}`);
+ }
+
+ if (response.status === 204) {
+ return undefined as T;
}
return response.json() as Promise;
diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts
new file mode 100644
index 00000000..94849875
--- /dev/null
+++ b/apps/web/src/lib/auth.ts
@@ -0,0 +1,42 @@
+import type { AuthResponse } from '../shared/types';
+import { apiFetch } from './api';
+
+const TOKEN_KEY = 'devcard_token';
+
+export function getStoredToken(): string | null {
+ if (typeof localStorage === 'undefined') {
+ return null;
+ }
+ return localStorage.getItem(TOKEN_KEY);
+}
+
+export function storeToken(token: string) {
+ localStorage.setItem(TOKEN_KEY, token);
+}
+
+export function clearStoredToken() {
+ localStorage.removeItem(TOKEN_KEY);
+}
+
+export async function signup(payload: {
+ email: string;
+ password: string;
+ username: string;
+ displayName: string;
+}) {
+ const response = await apiFetch('/auth/signup', {
+ method: 'POST',
+ body: payload,
+ });
+ storeToken(response.token);
+ return response;
+}
+
+export async function login(payload: { email: string; password: string }) {
+ const response = await apiFetch('/auth/login', {
+ method: 'POST',
+ body: payload,
+ });
+ storeToken(response.token);
+ return response;
+}
diff --git a/apps/web/src/pages/DashboardPage.css b/apps/web/src/pages/DashboardPage.css
new file mode 100644
index 00000000..6c5f1e6a
--- /dev/null
+++ b/apps/web/src/pages/DashboardPage.css
@@ -0,0 +1,315 @@
+.dashboard {
+ width: min(1180px, calc(100% - 2rem));
+ margin: 0 auto;
+ padding: 1.25rem 0 3rem;
+}
+
+.topbar {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 1rem;
+ padding: 0.75rem 0 2rem;
+}
+
+.brand {
+ font-family: 'Outfit', sans-serif;
+ font-size: 1.35rem;
+ font-weight: 900;
+ text-decoration: none;
+ color: var(--text-primary);
+}
+
+nav {
+ display: flex;
+ align-items: center;
+ gap: 1.5rem;
+ color: var(--text-secondary);
+ font-weight: 700;
+}
+
+nav a {
+ text-decoration: none;
+ color: var(--text-secondary);
+ transition: color 0.2s;
+}
+
+nav a:hover {
+ color: var(--primary);
+}
+
+.text-button {
+ border: 0;
+ background: transparent;
+ color: var(--text-secondary);
+ cursor: pointer;
+ font-weight: 800;
+ font-size: inherit;
+ font-family: inherit;
+ padding: 0;
+}
+
+.text-button:hover {
+ color: #ef4444;
+}
+
+.hero-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
+ gap: 1.25rem;
+ align-items: end;
+ margin-bottom: 1.25rem;
+}
+
+.eyebrow {
+ color: var(--primary);
+ font-size: 0.78rem;
+ font-weight: 900;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ margin-bottom: 0.65rem;
+}
+
+h1 {
+ font-size: clamp(2rem, 4vw, 3.5rem);
+ margin-bottom: 0.75rem;
+}
+
+h2 {
+ font-size: 1.2rem;
+}
+
+.muted,
+.empty {
+ color: var(--text-secondary);
+ line-height: 1.6;
+}
+
+.qr-panel,
+.panel,
+.status-panel {
+ border-radius: var(--radius-xl);
+ background: rgba(255, 255, 255, 0.78);
+ border: 1px solid var(--border);
+}
+
+html.dark .qr-panel,
+html.dark .panel,
+html.dark .status-panel {
+ background: rgba(15, 23, 42, 0.82);
+}
+
+.qr-panel {
+ display: grid;
+ gap: 0.85rem;
+ padding: 1rem;
+}
+
+.qr-panel img {
+ width: 160px;
+ height: 160px;
+ border-radius: 10px;
+ background: #fff;
+ padding: 0.5rem;
+}
+
+.qr-label {
+ color: var(--text-muted);
+ font-size: 0.8rem;
+ font-weight: 800;
+ margin-bottom: 0.25rem;
+ text-transform: uppercase;
+}
+
+.qr-panel a {
+ color: var(--primary);
+ font-weight: 800;
+ overflow-wrap: anywhere;
+}
+
+.dashboard-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
+ gap: 1.25rem;
+}
+
+.panel,
+.status-panel {
+ padding: 1.25rem;
+}
+
+.status-panel {
+ display: grid;
+ gap: 1rem;
+ justify-items: start;
+}
+
+.panel-heading {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ margin-bottom: 1.1rem;
+}
+
+.count {
+ border-radius: 999px;
+ background: rgba(99, 102, 241, 0.14);
+ color: var(--primary);
+ font-weight: 900;
+ min-width: 2rem;
+ padding: 0.25rem 0.65rem;
+ text-align: center;
+}
+
+.form-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 1rem;
+}
+
+.wide {
+ grid-column: 1 / -1;
+}
+
+label {
+ display: grid;
+ gap: 0.45rem;
+ color: var(--text-secondary);
+ font-weight: 800;
+}
+
+input,
+select,
+textarea {
+ min-width: 0;
+ width: 100%;
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ background: var(--bg-card);
+ color: var(--text-primary);
+ font: inherit;
+ padding: 0.8rem 0.9rem;
+ box-sizing: border-box;
+}
+
+input[type='color'] {
+ height: 46px;
+ padding: 0.25rem;
+ cursor: pointer;
+}
+
+textarea {
+ resize: vertical;
+}
+
+input:focus,
+select:focus,
+textarea:focus {
+ border-color: var(--primary);
+ outline: 3px solid rgba(99, 102, 241, 0.18);
+}
+
+.add-link,
+.link-row {
+ display: grid;
+ grid-template-columns: minmax(120px, 160px) minmax(0, 1fr) auto;
+ gap: 0.75rem;
+ align-items: center;
+}
+
+.link-list {
+ display: grid;
+ gap: 0.75rem;
+ margin-top: 1rem;
+}
+
+.link-row {
+ grid-template-columns: minmax(120px, 150px) minmax(0, 1fr) auto auto;
+ border-top: 1px solid var(--border);
+ padding-top: 0.75rem;
+}
+
+.compact {
+ min-height: 42px;
+ padding: 0.7rem 1rem;
+ white-space: nowrap;
+}
+
+.danger-button {
+ border: 1px solid rgba(239, 68, 68, 0.28);
+ border-radius: 10px;
+ background: rgba(239, 68, 68, 0.1);
+ color: #dc2626;
+ cursor: pointer;
+ font: inherit;
+ font-weight: 800;
+ transition: background-color 0.2s;
+}
+
+.danger-button:hover {
+ background: rgba(239, 68, 68, 0.2);
+}
+
+.alert {
+ border-radius: 10px;
+ font-weight: 800;
+ margin-bottom: 1rem;
+ padding: 0.85rem 1rem;
+}
+
+.alert.error {
+ background: rgba(239, 68, 68, 0.12);
+ color: #b91c1c;
+}
+
+.alert.success {
+ background: rgba(34, 197, 94, 0.14);
+ color: #15803d;
+}
+
+button:disabled {
+ cursor: wait;
+ opacity: 0.7;
+}
+
+@media (max-width: 920px) {
+ .hero-row,
+ .dashboard-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .qr-panel {
+ grid-template-columns: auto minmax(0, 1fr);
+ align-items: center;
+ }
+
+ .qr-panel .btn-secondary {
+ grid-column: 1 / -1;
+ }
+}
+
+@media (max-width: 660px) {
+ .dashboard {
+ width: min(100% - 1rem, 1180px);
+ }
+
+ .topbar {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ nav {
+ gap: 1rem;
+ }
+
+ .form-grid,
+ .add-link,
+ .link-row {
+ grid-template-columns: 1fr;
+ }
+
+ .qr-panel {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/apps/web/src/pages/DashboardPage.tsx b/apps/web/src/pages/DashboardPage.tsx
new file mode 100644
index 00000000..bb5c49e1
--- /dev/null
+++ b/apps/web/src/pages/DashboardPage.tsx
@@ -0,0 +1,393 @@
+import { useEffect, useState } from 'react';
+import { useNavigate, Link } from 'react-router-dom';
+import { PLATFORMS, type PlatformLink, type User } from '../shared';
+import { apiFetch } from '../lib/api';
+import { clearStoredToken, getStoredToken } from '../lib/auth';
+import './DashboardPage.css';
+
+type DashboardProfile = User & {
+ defaultCardId: string | null;
+ platformLinks: PlatformLink[];
+};
+
+type EditableLink = PlatformLink & {
+ saving?: boolean;
+};
+
+const API_BASE_URL = import.meta.env.VITE_API_URL ?? '';
+
+const platformOptions = Object.values(PLATFORMS).filter((platform) => platform.id !== 'discord');
+
+const PLACEHOLDERS: Record = {
+ github: 'Username (e.g. octocat)',
+ linkedin: 'Username (e.g. john-doe)',
+ twitter: 'Username (e.g. jack)',
+ gitlab: 'Username',
+ devfolio: 'Username',
+ npm: 'Username',
+ devto: 'Username',
+ hashnode: 'Username',
+ medium: 'Username',
+ leetcode: 'Username',
+ hackerrank: 'Username',
+ stackoverflow: 'User ID (e.g. 12345)',
+ telegram: 'Username',
+ email: 'Email address (e.g. hello@example.com)',
+ portfolio: 'Full URL (e.g. https://mywebsite.com)',
+ custom: 'Full URL (e.g. https://example.com)',
+};
+
+export default function DashboardPage() {
+ const navigate = useNavigate();
+
+ const [profile, setProfile] = useState(null);
+ const [links, setLinks] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [savingProfile, setSavingProfile] = useState(false);
+ const [savingLink, setSavingLink] = useState(false);
+ const [error, setError] = useState('');
+ const [success, setSuccess] = useState('');
+
+ // Profile fields state
+ const [displayName, setDisplayName] = useState('');
+ const [username, setUsername] = useState('');
+ const [bio, setBio] = useState('');
+ const [role, setRole] = useState('');
+ const [company, setCompany] = useState('');
+ const [accentColor, setAccentColor] = useState('#6366f1');
+
+ // New link state
+ const [newPlatform, setNewPlatform] = useState('github');
+ const [newHandle, setNewHandle] = useState('');
+
+ const token = getStoredToken();
+
+ function handleUnauthorized() {
+ clearStoredToken();
+ navigate('/login');
+ }
+
+ function showSuccess(message: string) {
+ setSuccess(message);
+ setError('');
+ setTimeout(() => {
+ setSuccess('');
+ }, 2600);
+ }
+
+ function applyProfile(data: DashboardProfile) {
+ setProfile(data);
+ setLinks(data.platformLinks.map((link) => ({ ...link })));
+ setDisplayName(data.displayName);
+ setUsername(data.username);
+ setBio(data.bio ?? '');
+ setRole(data.role ?? '');
+ setCompany(data.company ?? '');
+ setAccentColor(data.accentColor);
+ }
+
+ useEffect(() => {
+ if (!token) {
+ navigate('/login');
+ return;
+ }
+
+ setLoading(true);
+ setError('');
+
+ apiFetch('/api/profiles/me', {
+ token,
+ onUnauthorized: handleUnauthorized,
+ })
+ .then((data) => {
+ applyProfile(data);
+ })
+ .catch((err) => {
+ setError(err instanceof Error ? err.message : 'Failed to load dashboard.');
+ })
+ .finally(() => {
+ setLoading(false);
+ });
+ }, [token]);
+
+ async function saveProfile() {
+ if (!token) return;
+ setSavingProfile(true);
+ setError('');
+
+ try {
+ const updated = await apiFetch('/api/profiles/me', {
+ method: 'PUT',
+ token,
+ body: {
+ displayName,
+ username,
+ bio: bio || null,
+ role: role || null,
+ company: company || null,
+ accentColor,
+ },
+ onUnauthorized: handleUnauthorized,
+ });
+
+ if (profile) {
+ applyProfile({ ...profile, ...updated, platformLinks: links });
+ }
+ showSuccess('Profile saved.');
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to save profile.');
+ } finally {
+ setSavingProfile(false);
+ }
+ }
+
+ async function addLink(e: React.FormEvent) {
+ e.preventDefault();
+ if (!token || !newHandle.trim()) return;
+ setSavingLink(true);
+ setError('');
+
+ try {
+ const created = await apiFetch('/api/profiles/me/links', {
+ method: 'POST',
+ token,
+ body: { platform: newPlatform, username: newHandle.trim() },
+ onUnauthorized: handleUnauthorized,
+ });
+ setLinks((prev) => [...prev, { ...created }]);
+ setNewHandle('');
+ showSuccess('Link added.');
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to add link.');
+ } finally {
+ setSavingLink(false);
+ }
+ }
+
+ async function updateLink(link: EditableLink) {
+ if (!token || !link.username.trim()) return;
+
+ // Set loading for this link
+ setLinks((prev) =>
+ prev.map((item) => (item.id === link.id ? { ...item, saving: true } : item))
+ );
+ setError('');
+
+ try {
+ const updated = await apiFetch(`/api/profiles/me/links/${link.id}`, {
+ method: 'PUT',
+ token,
+ body: { platform: link.platform, username: link.username.trim() },
+ onUnauthorized: handleUnauthorized,
+ });
+ setLinks((prev) =>
+ prev.map((item) => (item.id === updated.id ? { ...updated, saving: false } : item))
+ );
+ showSuccess('Link updated.');
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to update link.');
+ setLinks((prev) =>
+ prev.map((item) => (item.id === link.id ? { ...item, saving: false } : item))
+ );
+ }
+ }
+
+ async function deleteLink(linkId: string) {
+ if (!token) return;
+ setError('');
+
+ try {
+ await apiFetch(`/api/profiles/me/links/${linkId}`, {
+ method: 'DELETE',
+ token,
+ onUnauthorized: handleUnauthorized,
+ });
+ setLinks((prev) => prev.filter((link) => link.id !== linkId));
+ showSuccess('Link removed.');
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to remove link.');
+ }
+ }
+
+ const publicUrl = profile ? `${window.location.origin}/u/${profile.username}` : '';
+ const qrUrl = profile ? `${API_BASE_URL}/api/u/${profile.username}/qr?format=svg&size=360` : '';
+
+ async function copyPublicUrl() {
+ if (!publicUrl) return;
+ try {
+ await navigator.clipboard.writeText(publicUrl);
+ showSuccess('Public profile URL copied.');
+ } catch (err) {
+ setError('Failed to copy profile URL to clipboard.');
+ }
+ }
+
+ function handleLogout() {
+ clearStoredToken();
+ navigate('/login');
+ }
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!profile) {
+ return (
+
+
+ {error || 'Dashboard unavailable.'}
+ Log in
+
+
+ );
+ }
+
+ return (
+
+
+ DevCard
+
+ Public profile
+ Log out
+
+
+
+
+
+
Dashboard
+
{profile.displayName}
+
Manage the public developer links shown when someone scans your QR.
+
+
+
+
+
Copy URL
+
+
+
+ {error && (
+ {error}
+ )}
+ {success && (
+ {success}
+ )}
+
+
+
+
+
+
+
Social Links
+ {links.length}
+
+
+
+
+
+ {links.length === 0 ? (
+
Add your first link to make your QR useful.
+ ) : (
+ links.map((link) => (
+
+ {
+ const updatedPlatform = e.target.value;
+ setLinks((prev) =>
+ prev.map((item) => (item.id === link.id ? { ...item, platform: updatedPlatform } : item))
+ );
+ }}
+ aria-label="Platform for link"
+ >
+ {platformOptions.map((platform) => (
+ {platform.name}
+ ))}
+
+ {
+ const updatedUsername = e.target.value;
+ setLinks((prev) =>
+ prev.map((item) => (item.id === link.id ? { ...item, username: updatedUsername } : item))
+ );
+ }}
+ aria-label="Username for link"
+ />
+ updateLink(link)}
+ disabled={link.saving}
+ >
+ {link.saving ? 'Saving...' : 'Update'}
+
+ deleteLink(link.id)}>
+ Remove
+
+
+ ))
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/pages/LandingPage.tsx b/apps/web/src/pages/LandingPage.tsx
index dd5f3324..fa1c61f0 100644
--- a/apps/web/src/pages/LandingPage.tsx
+++ b/apps/web/src/pages/LandingPage.tsx
@@ -41,7 +41,7 @@ export default function LandingPage() {
Twitter, and every other profile with a single NFC tap — beautifully.
-
+
Get Started Free
+
+
+
+ ⚡ DevCard
+ Welcome back
+ Log in to manage your links and QR code.
+
+
+
+ New to DevCard? Create an account
+
+
+ >
+ );
+}
diff --git a/apps/web/src/pages/ProfilePage.css b/apps/web/src/pages/ProfilePage.css
index 412e162f..848b175b 100644
--- a/apps/web/src/pages/ProfilePage.css
+++ b/apps/web/src/pages/ProfilePage.css
@@ -140,7 +140,21 @@
font-size: clamp(2rem, 4vw, 2.5rem);
font-weight: 800;
letter-spacing: -0.5px;
- margin-bottom: 0.75rem;
+ margin-bottom: 0.25rem;
+ color: #ffffff;
+ text-shadow:
+ 0 0 32px var(--accent, #6366f1),
+ 0 0 12px rgba(0, 0, 0, 0.8),
+ 0 2px 4px rgba(0, 0, 0, 0.9);
+}
+
+.username-handle {
+ font-size: 0.92rem;
+ font-weight: 600;
+ color: var(--accent, #6366f1);
+ opacity: 0.82;
+ margin-bottom: 0.9rem;
+ letter-spacing: 0.02em;
}
.role-badge {
diff --git a/apps/web/src/pages/ProfilePage.tsx b/apps/web/src/pages/ProfilePage.tsx
index 94a84f54..ffdea5be 100644
--- a/apps/web/src/pages/ProfilePage.tsx
+++ b/apps/web/src/pages/ProfilePage.tsx
@@ -133,6 +133,7 @@ export default function ProfilePage() {
{profile.displayName}
+ @{profile.username}
{profile.role && (
{profile.role}{profile.company ? ` @ ${profile.company}` : ''}
diff --git a/apps/web/src/pages/SignupPage.css b/apps/web/src/pages/SignupPage.css
new file mode 100644
index 00000000..e57437a2
--- /dev/null
+++ b/apps/web/src/pages/SignupPage.css
@@ -0,0 +1,168 @@
+.auth-page {
+ min-height: 100vh;
+ display: grid;
+ place-items: center;
+ padding: 2rem 1rem;
+}
+
+.auth-panel {
+ width: min(100%, 460px);
+ border-radius: var(--radius-xl);
+ padding: 2.25rem 2rem;
+ background: rgba(255, 255, 255, 0.82);
+}
+
+html.dark .auth-panel {
+ background: rgba(15, 23, 42, 0.88);
+}
+
+.brand {
+ display: inline-flex;
+ font-family: 'Outfit', sans-serif;
+ font-size: 1.1rem;
+ font-weight: 800;
+ margin-bottom: 1.75rem;
+ color: var(--primary);
+ text-decoration: none;
+}
+
+h1 {
+ font-size: 2rem;
+ margin-bottom: 0.4rem;
+ line-height: 1.2;
+}
+
+.lede {
+ color: var(--text-secondary);
+ line-height: 1.6;
+ font-size: 0.95rem;
+ margin-bottom: 0;
+}
+
+form {
+ display: grid;
+ gap: 1.1rem;
+ margin-top: 1.75rem;
+}
+
+label {
+ display: grid;
+ gap: 0.4rem;
+ color: var(--text-secondary);
+ font-size: 0.88rem;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+}
+
+.req { color: #ef4444; }
+
+input {
+ width: 100%;
+ border: 1.5px solid var(--border);
+ border-radius: 10px;
+ background: var(--bg-card);
+ color: var(--text-primary);
+ font: inherit;
+ font-size: 1rem;
+ padding: 0.85rem 1rem;
+ transition: border-color 0.18s ease, outline 0.18s ease;
+ box-sizing: border-box;
+}
+
+input::placeholder { color: var(--text-muted); opacity: 0.7; }
+
+input:focus {
+ border-color: var(--primary);
+ outline: 3px solid rgba(99, 102, 241, 0.18);
+ outline-offset: 0;
+}
+
+input.field-error {
+ border-color: #ef4444;
+ outline: 3px solid rgba(239, 68, 68, 0.12);
+}
+
+input.field-ok {
+ border-color: #22c55e;
+}
+
+.hint {
+ font-size: 0.76rem;
+ font-weight: 500;
+ color: var(--text-muted);
+ line-height: 1.4;
+}
+
+.hint-error { color: #ef4444 !important; }
+
+/* Password strength */
+.strength-bar {
+ height: 4px;
+ border-radius: 99px;
+ background: var(--border);
+ overflow: hidden;
+}
+
+.strength-fill {
+ height: 100%;
+ border-radius: 99px;
+ transition: width 0.3s ease, background 0.3s ease;
+}
+
+/* Error banner */
+.form-error {
+ border-radius: 10px;
+ background: rgba(239, 68, 68, 0.09);
+ border: 1px solid rgba(239, 68, 68, 0.28);
+ color: #b91c1c;
+ padding: 0.85rem 1rem;
+ font-size: 0.88rem;
+ line-height: 1.55;
+ margin: 0;
+}
+
+/* Submit button */
+.btn-primary {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+}
+
+button:disabled {
+ cursor: wait;
+ opacity: 0.7;
+}
+
+/* Spinner */
+.spinner {
+ display: inline-block;
+ width: 16px;
+ height: 16px;
+ border: 2.5px solid rgba(255,255,255,0.35);
+ border-top-color: #fff;
+ border-radius: 50%;
+ animation: spin 0.7s linear infinite;
+ flex-shrink: 0;
+}
+
+@keyframes spin { to { transform: rotate(360deg); } }
+
+.switch {
+ margin-top: 1.5rem;
+ text-align: center;
+ color: var(--text-secondary);
+ font-size: 0.9rem;
+}
+
+.switch a {
+ color: var(--primary);
+ font-weight: 800;
+ text-decoration: none;
+}
+
+.switch a:hover { text-decoration: underline; }
+
+@media (max-width: 520px) {
+ .auth-panel { padding: 1.75rem 1.25rem; }
+}
diff --git a/apps/web/src/pages/SignupPage.tsx b/apps/web/src/pages/SignupPage.tsx
new file mode 100644
index 00000000..f0d24165
--- /dev/null
+++ b/apps/web/src/pages/SignupPage.tsx
@@ -0,0 +1,183 @@
+import { useState } from 'react';
+import { useNavigate, Link } from 'react-router-dom';
+import { signup } from '../lib/auth';
+import './SignupPage.css';
+
+export default function SignupPage() {
+ const navigate = useNavigate();
+
+ const [displayName, setDisplayName] = useState('');
+ const [username, setUsername] = useState('');
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [error, setError] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ // Track interaction to show errors/hints selectively
+ const [touchedUsername, setTouchedUsername] = useState(false);
+ const [touchedPassword, setTouchedPassword] = useState(false);
+
+ // Validation
+ const usernameOk = /^[A-Za-z0-9_-]{3,50}$/.test(username.trim());
+ const passwordOk = password.length >= 8;
+ const emailOk = email.includes('@') && email.includes('.');
+
+ const formValid = displayName.trim().length > 0 && usernameOk && emailOk && passwordOk;
+
+ // Password strength logic
+ const strength = password.length === 0 ? 0 : password.length < 6 ? 1 : password.length < 10 ? 2 : 3;
+ const strengthLabel = ['', 'Weak', 'Fair', 'Strong'][strength];
+ const strengthColor = ['', '#ef4444', '#f59e0b', '#22c55e'][strength];
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ setTouchedUsername(true);
+ setTouchedPassword(true);
+ setError('');
+
+ if (!formValid) {
+ if (!displayName.trim()) {
+ setError('Display name is required.');
+ return;
+ }
+ if (!usernameOk) {
+ setError('Username: 3–50 chars, letters/numbers/_/- only (no spaces).');
+ return;
+ }
+ if (!emailOk) {
+ setError('Please enter a valid email address.');
+ return;
+ }
+ if (!passwordOk) {
+ setError('Password must be at least 8 characters.');
+ return;
+ }
+ return;
+ }
+
+ setLoading(true);
+ try {
+ await signup({
+ displayName: displayName.trim(),
+ username: username.trim(),
+ email: email.trim(),
+ password,
+ });
+ navigate('/dashboard');
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Unable to create your account. Please try again.');
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+ <>
+
+
+
+ ⚡ DevCard
+ Create your DevCard
+ Add your links, get one QR, share everywhere.
+
+
+
+ Already have an account? Log in
+
+
+ >
+ );
+}
diff --git a/apps/web/src/shared/index.ts b/apps/web/src/shared/index.ts
index a1531bcd..36ec6bb3 100644
--- a/apps/web/src/shared/index.ts
+++ b/apps/web/src/shared/index.ts
@@ -1,3 +1,3 @@
export { PLATFORMS, getProfileUrl } from './platforms';
export type { PlatformDef } from './platforms';
-export type { PublicProfile, PublicCard, PlatformLink } from './types';
+export type { PublicProfile, PublicCard, PlatformLink, User, AuthResponse } from './types';
diff --git a/apps/web/src/shared/types.ts b/apps/web/src/shared/types.ts
index 4c8ac276..125aa171 100644
--- a/apps/web/src/shared/types.ts
+++ b/apps/web/src/shared/types.ts
@@ -35,3 +35,22 @@ export interface PublicCard {
};
links: PlatformLink[];
}
+
+export interface User {
+ id: string;
+ email: string;
+ username: string;
+ displayName: string;
+ bio: string | null;
+ pronouns: string | null;
+ role: string | null;
+ company: string | null;
+ avatarUrl: string | null;
+ accentColor: string;
+ createdAt: string;
+}
+
+export interface AuthResponse {
+ token: string;
+ user: User;
+}
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index 7d94ff37..0fb1d60a 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -1,10 +1,18 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
+const BACKEND = process.env.BACKEND_URL || 'http://localhost:3000';
+
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 5174,
+ proxy: {
+ // Proxy all /auth and /api requests to the backend dev server.
+ // This runs only in `vite dev` — no CORS headers needed at all.
+ '/auth': { target: BACKEND, changeOrigin: true },
+ '/api': { target: BACKEND, changeOrigin: true },
+ },
},
})