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 ( ); 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 ( +
+
+

Loading dashboard...

+
+
+ ); + } + + if (!profile) { + return ( +
+
+

{error || 'Dashboard unavailable.'}

+ Log in +
+
+ ); + } + + return ( +
+
+ DevCard + +
+ +
+
+

Dashboard

+

{profile.displayName}

+

Manage the public developer links shown when someone scans your QR.

+
+
+ {`QR +
+

Scan URL

+ {publicUrl} +
+ +
+
+ + {error && ( +

{error}

+ )} + {success && ( +

{success}

+ )} + +
+
+
+

Profile

+ +
+ +
+ + + + + + +
+
+ +
+
+

Social Links

+ {links.length} +
+ +
+ + setNewHandle(e.target.value)} + placeholder={PLACEHOLDERS[newPlatform] ?? 'Username or URL'} + aria-label="Username or URL" + /> + +
+ +
+ {links.length === 0 ? ( +

Add your first link to make your QR useful.

+ ) : ( + links.map((link) => ( +
+ + { + const updatedUsername = e.target.value; + setLinks((prev) => + prev.map((item) => (item.id === link.id ? { ...item, username: updatedUsername } : item)) + ); + }} + aria-label="Username for link" + /> + + +
+ )) + )} +
+
+
+
+ ); +} 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.

+ +
+ + + + + {error && ( +

⚠ {error}

+ )} + + +
+ +

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.

+ +
+ {/* Display name */} + + + {/* Username */} + + + {/* Email */} + + + {/* Password */} + + + {/* Error banner */} + {error && ( +

⚠ {error}

+ )} + + {/* Submit */} + +
+ +

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 }, + }, }, })