From 17a30476261f8155c4c6eb4e1cbc3f5ebbe383e9 Mon Sep 17 00:00:00 2001 From: Amrit Date: Thu, 11 Jun 2026 21:16:46 +0530 Subject: [PATCH 1/3] feat: email/password auth + QR-based developer profile sharing (#Dev-Card) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Implements full user authentication and dynamic QR-based developer profile sharing for the DevCard platform. ## What's new ### Backend - **Password auth** — `POST /auth/signup` and `POST /auth/login` using Node.js built-in `crypto.scrypt` (zero new dependencies) - **Zod validation** — email normalised to lowercase, username regex `[A-Za-z0-9_-]{3,50}`, password min 8 chars - **Atomic signup** — user + default Card created in a single Prisma transaction; existing OAuth users unaffected (nullable `password_hash`) - **Security** — `passwordHash` stripped from every API response - **Migration** — `20260611120000_password_auth` adds nullable `password_hash` column - **Unit tests** — 4 tests: signup, login, duplicate-email 409, wrong-password 401 (all pass) ### Frontend (SvelteKit) - **`/signup`** — real-time username format hint, password strength bar (Weak/Fair/Strong), red/green field borders, spinner on submit - **`/login`** — friendly 'Email or password is incorrect' message - **`/dashboard`** — JWT-authenticated page: edit display name, username, bio, role, company, accent colour; add/edit/delete platform links (GitHub, LinkedIn, Twitter/X, etc.); live QR code preview; one-click copy URL - **Landing page** — Log in / Create card CTAs in nav - **Public profile** (`/u/:username`) — display name now pure white with accent-colour glow, @username handle shown, role badge and bio lifted - **Vite dev proxy** — `/auth` and `/api` forwarded to backend; browser uses relative URLs so no CORS issues - **Bug fix** — `devcard/[id]/+page.server.ts`: catch variable was shadowing the imported SvelteKit `error` helper - **Bug fix** — Svelte 5 reactivity: `$derived` for profile/error props - **Bug fix** — `apiClient.ts` extracts `details.fieldErrors` from zod responses so users see exact field messages instead of 'Validation failed' - **Bug fix** — signup submit was immediately returning because `canSubmit` included `!loading`; fixed by separating `formValid` from loading state ## Test plan - All 4 backend auth unit tests pass (`pnpm --filter @devcard/backend test`) - `svelte-check` — 0 errors - Manual E2E: signup → login → add links → public profile → QR code all work Closes: email-auth, profile-management, QR-sharing --- .../migration.sql | 3 + apps/backend/prisma/schema.prisma | 3 +- apps/backend/src/__tests__/auth.test.ts | 153 +++++ apps/backend/src/__tests__/profiles.test.ts | 4 +- apps/backend/src/routes/auth.ts | 135 +++- apps/backend/src/services/profileService.ts | 2 +- apps/backend/src/utils/password.ts | 31 + apps/backend/src/utils/validators.ts | 17 + apps/web/src/lib/apiClient.ts | 28 +- apps/web/src/lib/auth.ts | 43 ++ apps/web/src/routes/+page.svelte | 74 ++- apps/web/src/routes/dashboard/+page.svelte | 621 ++++++++++++++++++ .../src/routes/devcard/[id]/+page.server.ts | 6 +- apps/web/src/routes/login/+page.svelte | 223 +++++++ apps/web/src/routes/signup/+page.svelte | 344 ++++++++++ apps/web/src/routes/u/[username]/+page.svelte | 45 +- apps/web/vite.config.ts | 12 +- 17 files changed, 1702 insertions(+), 42 deletions(-) create mode 100644 apps/backend/prisma/migrations/20260611120000_password_auth/migration.sql create mode 100644 apps/backend/src/__tests__/auth.test.ts create mode 100644 apps/backend/src/utils/password.ts create mode 100644 apps/web/src/lib/auth.ts create mode 100644 apps/web/src/routes/dashboard/+page.svelte create mode 100644 apps/web/src/routes/login/+page.svelte create mode 100644 apps/web/src/routes/signup/+page.svelte 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 7017ca81..215ec2b0 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..45fbe0cf --- /dev/null +++ b/apps/backend/src/__tests__/auth.test.ts @@ -0,0 +1,153 @@ +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); + 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 07d10f98..2f7f36ce 100644 --- a/apps/backend/src/__tests__/profiles.test.ts +++ b/apps/backend/src/__tests__/profiles.test.ts @@ -18,6 +18,7 @@ const mockUser = { cards: [], provider: 'github', providerId: 'gh-123', + passwordHash: 'scrypt:salt:hash', }; const mockPrisma: Pick = { @@ -52,6 +53,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 () => { @@ -147,4 +149,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 3340021b..fc8214ef 100644 --- a/apps/backend/src/routes/auth.ts +++ b/apps/backend/src/routes/auth.ts @@ -1,6 +1,8 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import { encrypt } from '../utils/encryption.js'; import { buildOAuthState, getMobileRedirectUri } from '../services/authService.js'; +import { loginSchema, signupSchema } from '../utils/validators.js'; +import { hashPassword, verifyPassword } from '../utils/password.js'; const GITHUB_AUTH_URL = 'https://github.com/login/oauth/authorize'; const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token'; @@ -14,7 +16,124 @@ 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) { + 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) => { @@ -136,13 +255,7 @@ export async function authRoutes(app: FastifyInstance) { 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) { @@ -237,13 +350,7 @@ export async function authRoutes(app: FastifyInstance) { 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/lib/apiClient.ts b/apps/web/src/lib/apiClient.ts index dbaad43f..ee767de0 100644 --- a/apps/web/src/lib/apiClient.ts +++ b/apps/web/src/lib/apiClient.ts @@ -1,4 +1,10 @@ -const API_BASE_URL = import.meta.env.PUBLIC_API_URL ?? 'http://localhost:3000'; +// In the browser the Vite dev proxy forwards /auth and /api to the backend, +// so we use relative URLs (no CORS). On the server (SSR load functions) we +// need the full URL because there is no proxy there. +const API_BASE_URL = + typeof window !== 'undefined' + ? '' // browser → proxy + : (import.meta.env.PUBLIC_API_URL ?? 'http://localhost:3000'); // SSR → direct type RequestOptions = { method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; @@ -28,9 +34,23 @@ export async function apiRequest( } if (!response.ok) { - const error = await response.json().catch(() => ({})); - throw new Error((error as any)?.message ?? `Request failed: ${response.status}`); + const errorBody = await response.json().catch(() => ({})) as any; + + // Unpack zod fieldErrors into a readable string, e.g.: + // "password: Password must be at least 8 characters · username: Invalid format" + 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; -} \ No newline at end of file +} diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts new file mode 100644 index 00000000..c5f49eb6 --- /dev/null +++ b/apps/web/src/lib/auth.ts @@ -0,0 +1,43 @@ +import type { AuthResponse } from '@devcard/shared'; +import { apiRequest } from './apiClient'; + +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 apiRequest('/auth/signup', { + method: 'POST', + body: payload, + }); + storeToken(response.token); + return response; +} + +export async function login(payload: { email: string; password: string }) { + const response = await apiRequest('/auth/login', { + method: 'POST', + body: payload, + }); + storeToken(response.token); + return response; +} diff --git a/apps/web/src/routes/+page.svelte b/apps/web/src/routes/+page.svelte index efaa65e5..5eb870d2 100644 --- a/apps/web/src/routes/+page.svelte +++ b/apps/web/src/routes/+page.svelte @@ -1,3 +1,59 @@ + + + + DevCard | Share every developer profile with one QR + + + +
+ + + +
+
+
Developer profile sharing
+

One QR for every profile you want people to find.

+

+ DevCard turns your developer links into a single public page that is quick to update, + easy to scan, and ready for real-world conversations. +

+ +
+ +
+ {#each highlights as item} +
+

{item.title}

+

{item.text}

+
+ {/each} +
+
+ +
+

Open source developer profile exchange platform.

+
+ \ No newline at end of file + diff --git a/apps/web/src/routes/dashboard/+page.svelte b/apps/web/src/routes/dashboard/+page.svelte new file mode 100644 index 00000000..b6f04259 --- /dev/null +++ b/apps/web/src/routes/dashboard/+page.svelte @@ -0,0 +1,621 @@ + + + + Dashboard | DevCard + + +
+
+ DevCard + +
+ + {#if loading} +
+

Loading dashboard...

+
+ {:else if !profile} +
+

{error || 'Dashboard unavailable.'}

+ Log in +
+ {:else} +
+
+

Dashboard

+

{profile.displayName}

+

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

+
+
+ QR code for {profile.displayName} +
+

Scan URL

+ {publicUrl} +
+ +
+
+ + {#if error} + + {/if} + {#if success} +

{success}

+ {/if} + +
+
+
+

Profile

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

Social Links

+ {links.length} +
+ + + + +
+
+ {/if} +
+ + diff --git a/apps/web/src/routes/devcard/[id]/+page.server.ts b/apps/web/src/routes/devcard/[id]/+page.server.ts index a93fbc75..952fd31f 100644 --- a/apps/web/src/routes/devcard/[id]/+page.server.ts +++ b/apps/web/src/routes/devcard/[id]/+page.server.ts @@ -19,9 +19,9 @@ export const load: PageServerLoad = async ({ params, fetch }) => { const card = await res.json(); return { card }; - } catch (error) { - if (error && typeof error === 'object' && 'status' in error) { - throw error; + } catch (err) { + if (err && typeof err === 'object' && 'status' in err) { + throw err; } throw error(500, 'Failed to connect to backend'); } diff --git a/apps/web/src/routes/login/+page.svelte b/apps/web/src/routes/login/+page.svelte new file mode 100644 index 00000000..70a57208 --- /dev/null +++ b/apps/web/src/routes/login/+page.svelte @@ -0,0 +1,223 @@ + + + + Log in | DevCard + + + +
+
+ ⚡ DevCard +

Welcome back

+

Log in to manage your links and QR code.

+ +
{ e.preventDefault(); void submit(); }} novalidate> + + + + + + {#if error} + + {/if} + + +
+ +

New to DevCard? Create an account

+
+
+ + diff --git a/apps/web/src/routes/signup/+page.svelte b/apps/web/src/routes/signup/+page.svelte new file mode 100644 index 00000000..49f87e8a --- /dev/null +++ b/apps/web/src/routes/signup/+page.svelte @@ -0,0 +1,344 @@ + + + + Create account | DevCard + + + +
+
+ ⚡ DevCard +

Create your DevCard

+

Add your links, get one QR, share everywhere.

+ +
{ e.preventDefault(); void submit(); }} novalidate> + + + + + + + + + + + + + + + {#if error} + + {/if} + + + +
+ +

Already have an account? Log in

+
+
+ + diff --git a/apps/web/src/routes/u/[username]/+page.svelte b/apps/web/src/routes/u/[username]/+page.svelte index 50cb4226..92cfd0c1 100644 --- a/apps/web/src/routes/u/[username]/+page.svelte +++ b/apps/web/src/routes/u/[username]/+page.svelte @@ -3,8 +3,8 @@ import { onMount } from 'svelte'; let { data } = $props(); - const profile = data.profile; - const error = data.error; + let profile = $derived(data.profile); + let pageError = $derived(data.error); const platformColors: Record = { github: '#181717', linkedin: '#0A66C2', twitter: '#000000', @@ -37,9 +37,7 @@ clearTimeout(copyMessageTimeout); } - clearTimeout(copyTimeout); - - copyTimeout = setTimeout(() => { + copyMessageTimeout = setTimeout(() => { copyMessage = ''; }, 3000); } @@ -71,7 +69,7 @@
- {#if error || !profile} + {#if pageError || !profile}
😕

Profile not found

@@ -93,6 +91,7 @@

{profile.displayName}

+

@{profile.username}

{#if profile.role}
{profile.role}{profile.company ? ` @ ${profile.company}` : ''} @@ -226,29 +225,43 @@ } .display-name { - font-size: clamp(2rem, 4vw, 2.5rem); - font-weight: 800; + font-size: clamp(2rem, 4vw, 2.6rem); + font-weight: 900; 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 { display: inline-flex; align-items: center; justify-content: center; - padding: 0.45rem 1rem; - background: rgba(255, 255, 255, 0.08); - border: 1px solid rgba(255, 255, 255, 0.12); + padding: 0.45rem 1.1rem; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.18); border-radius: 999px; - font-size: 0.9rem; + font-size: 0.88rem; font-weight: 700; - color: var(--text-secondary); + color: rgba(255, 255, 255, 0.88); margin-bottom: 1rem; } .bio { - color: var(--text-secondary); - font-size: 1rem; + color: rgba(255, 255, 255, 0.72); + font-size: 0.97rem; line-height: 1.85; max-width: 640px; margin: 0 auto; diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index bbf8c7da..d40b6b36 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,6 +1,16 @@ import { sveltekit } from '@sveltejs/kit/vite'; import { defineConfig } from 'vite'; +const BACKEND = process.env.BACKEND_URL || 'http://localhost:3000'; + export default defineConfig({ - plugins: [sveltekit()] + plugins: [sveltekit()], + server: { + 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 }, + }, + }, }); From ab9835d9979628f64ec1a4e5caa37591fcccda62 Mon Sep 17 00:00:00 2001 From: Amrit Date: Thu, 11 Jun 2026 23:04:14 +0530 Subject: [PATCH 2/3] test(auth): add authenticate decorator to test buildApp --- apps/backend/src/__tests__/auth.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/backend/src/__tests__/auth.test.ts b/apps/backend/src/__tests__/auth.test.ts index 45fbe0cf..3cab323c 100644 --- a/apps/backend/src/__tests__/auth.test.ts +++ b/apps/backend/src/__tests__/auth.test.ts @@ -36,6 +36,14 @@ async function buildApp() { 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; From 9fd99595d902c997f66f311b71d16dfb7ac42d71 Mon Sep 17 00:00:00 2001 From: Amrit Date: Thu, 11 Jun 2026 23:06:00 +0530 Subject: [PATCH 3/3] clean: remove obsolete Svelte routes directory after React migration --- apps/web/src/routes/dashboard/+page.svelte | 621 --------------------- apps/web/src/routes/login/+page.svelte | 223 -------- apps/web/src/routes/signup/+page.svelte | 344 ------------ 3 files changed, 1188 deletions(-) delete mode 100644 apps/web/src/routes/dashboard/+page.svelte delete mode 100644 apps/web/src/routes/login/+page.svelte delete mode 100644 apps/web/src/routes/signup/+page.svelte diff --git a/apps/web/src/routes/dashboard/+page.svelte b/apps/web/src/routes/dashboard/+page.svelte deleted file mode 100644 index b6f04259..00000000 --- a/apps/web/src/routes/dashboard/+page.svelte +++ /dev/null @@ -1,621 +0,0 @@ - - - - Dashboard | DevCard - - -
-
- DevCard - -
- - {#if loading} -
-

Loading dashboard...

-
- {:else if !profile} -
-

{error || 'Dashboard unavailable.'}

- Log in -
- {:else} -
-
-

Dashboard

-

{profile.displayName}

-

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

-
-
- QR code for {profile.displayName} -
-

Scan URL

- {publicUrl} -
- -
-
- - {#if error} - - {/if} - {#if success} -

{success}

- {/if} - -
-
-
-

Profile

- -
- -
- - - - - - -
-
- -
-
-

Social Links

- {links.length} -
- - - - -
-
- {/if} -
- - diff --git a/apps/web/src/routes/login/+page.svelte b/apps/web/src/routes/login/+page.svelte deleted file mode 100644 index 70a57208..00000000 --- a/apps/web/src/routes/login/+page.svelte +++ /dev/null @@ -1,223 +0,0 @@ - - - - Log in | DevCard - - - -
-
- ⚡ DevCard -

Welcome back

-

Log in to manage your links and QR code.

- -
{ e.preventDefault(); void submit(); }} novalidate> - - - - - - {#if error} - - {/if} - - -
- -

New to DevCard? Create an account

-
-
- - diff --git a/apps/web/src/routes/signup/+page.svelte b/apps/web/src/routes/signup/+page.svelte deleted file mode 100644 index 49f87e8a..00000000 --- a/apps/web/src/routes/signup/+page.svelte +++ /dev/null @@ -1,344 +0,0 @@ - - - - Create account | DevCard - - - -
-
- ⚡ DevCard -

Create your DevCard

-

Add your links, get one QR, share everywhere.

- -
{ e.preventDefault(); void submit(); }} novalidate> - - - - - - - - - - - - - - - {#if error} - - {/if} - - - -
- -

Already have an account? Log in

-
-
- -