Skip to content

Commit 17a3047

Browse files
author
Amrit
committed
feat: email/password auth + QR-based developer profile sharing (#Dev-Card)
## 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
1 parent 02320c0 commit 17a3047

17 files changed

Lines changed: 1702 additions & 42 deletions

File tree

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
-- Add nullable password storage for email/password accounts.
2+
-- Existing OAuth and seeded users keep working without a password hash.
3+
ALTER TABLE "users" ADD COLUMN "password_hash" TEXT;

apps/backend/prisma/schema.prisma

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ model User {
2020
accentColor String @default("#6366f1") @map("accent_color")
2121
provider String
2222
providerId String @map("provider_id")
23+
passwordHash String? @map("password_hash")
2324
createdAt DateTime @default(now()) @map("created_at")
2425
updatedAt DateTime @updatedAt @map("updated_at")
2526
@@ -194,4 +195,4 @@ model TeamMember{
194195
@@unique([userId, teamId])
195196
@@index([userId])
196197
@@map("team_members")
197-
}
198+
}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { describe, it, expect, beforeEach, vi } from 'vitest';
2+
import Fastify from 'fastify';
3+
import cookie from '@fastify/cookie';
4+
import jwt from '@fastify/jwt';
5+
import { authRoutes } from '../routes/auth.js';
6+
import type { PrismaClient } from '@prisma/client';
7+
8+
const mockSafeUser = {
9+
id: 'user-123',
10+
email: 'test@example.com',
11+
username: 'testuser',
12+
displayName: 'Test User',
13+
bio: null,
14+
pronouns: null,
15+
role: null,
16+
company: null,
17+
avatarUrl: null,
18+
accentColor: '#6366f1',
19+
createdAt: new Date('2026-01-01T00:00:00.000Z'),
20+
};
21+
22+
const mockPrisma = {
23+
user: {
24+
findFirst: vi.fn(),
25+
findUnique: vi.fn(),
26+
create: vi.fn(),
27+
},
28+
card: {
29+
create: vi.fn(),
30+
},
31+
$transaction: vi.fn(async (callback: (tx: any) => Promise<unknown>) => callback(mockPrisma)),
32+
};
33+
34+
async function buildApp() {
35+
const app = Fastify();
36+
await app.register(cookie);
37+
await app.register(jwt, { secret: 'test-secret-for-unit-tests-only' });
38+
app.decorate('prisma', mockPrisma as unknown as PrismaClient);
39+
await app.register(authRoutes, { prefix: '/auth' });
40+
await app.ready();
41+
return app;
42+
}
43+
44+
describe('email/password auth', () => {
45+
beforeEach(() => {
46+
vi.clearAllMocks();
47+
mockPrisma.$transaction.mockImplementation(async (callback: (tx: any) => Promise<unknown>) => callback(mockPrisma));
48+
});
49+
50+
it('registers a user, creates a default card, and returns a token', async () => {
51+
mockPrisma.user.findFirst.mockResolvedValue(null);
52+
mockPrisma.user.create.mockResolvedValue(mockSafeUser);
53+
mockPrisma.card.create.mockResolvedValue({ id: 'card-123' });
54+
55+
const app = await buildApp();
56+
const res = await app.inject({
57+
method: 'POST',
58+
url: '/auth/signup',
59+
payload: {
60+
email: 'Test@Example.com',
61+
password: 'strong-password',
62+
username: 'testuser',
63+
displayName: 'Test User',
64+
},
65+
});
66+
67+
expect(res.statusCode).toBe(201);
68+
const body = res.json();
69+
expect(body.token).toEqual(expect.any(String));
70+
expect(body.user.email).toBe('test@example.com');
71+
expect(body.user.passwordHash).toBeUndefined();
72+
expect(mockPrisma.user.create).toHaveBeenCalledWith(expect.objectContaining({
73+
data: expect.objectContaining({
74+
email: 'test@example.com',
75+
provider: 'password',
76+
passwordHash: expect.stringMatching(/^scrypt:/),
77+
}),
78+
}));
79+
expect(mockPrisma.card.create).toHaveBeenCalledWith(expect.objectContaining({
80+
data: expect.objectContaining({
81+
userId: 'user-123',
82+
title: 'Main DevCard',
83+
isDefault: true,
84+
}),
85+
}));
86+
});
87+
88+
it('rejects duplicate usernames during signup', async () => {
89+
mockPrisma.user.findFirst.mockResolvedValue({ email: 'other@example.com', username: 'testuser' });
90+
91+
const app = await buildApp();
92+
const res = await app.inject({
93+
method: 'POST',
94+
url: '/auth/signup',
95+
payload: {
96+
email: 'test@example.com',
97+
password: 'strong-password',
98+
username: 'testuser',
99+
displayName: 'Test User',
100+
},
101+
});
102+
103+
expect(res.statusCode).toBe(409);
104+
expect(res.json().error).toBe('Username already taken');
105+
expect(mockPrisma.user.create).not.toHaveBeenCalled();
106+
});
107+
108+
it('logs in with valid credentials', async () => {
109+
mockPrisma.user.findFirst.mockResolvedValue(null);
110+
mockPrisma.user.create.mockResolvedValue(mockSafeUser);
111+
mockPrisma.card.create.mockResolvedValue({ id: 'card-123' });
112+
113+
const app = await buildApp();
114+
const signup = await app.inject({
115+
method: 'POST',
116+
url: '/auth/signup',
117+
payload: {
118+
email: 'test@example.com',
119+
password: 'strong-password',
120+
username: 'testuser',
121+
displayName: 'Test User',
122+
},
123+
});
124+
const createdHash = mockPrisma.user.create.mock.calls[0][0].data.passwordHash;
125+
126+
mockPrisma.user.findUnique.mockResolvedValue({ ...mockSafeUser, passwordHash: createdHash });
127+
128+
const login = await app.inject({
129+
method: 'POST',
130+
url: '/auth/login',
131+
payload: { email: 'test@example.com', password: 'strong-password' },
132+
});
133+
134+
expect(signup.statusCode).toBe(201);
135+
expect(login.statusCode).toBe(200);
136+
expect(login.json().token).toEqual(expect.any(String));
137+
expect(login.json().user.passwordHash).toBeUndefined();
138+
});
139+
140+
it('rejects invalid login credentials', async () => {
141+
mockPrisma.user.findUnique.mockResolvedValue(null);
142+
143+
const app = await buildApp();
144+
const res = await app.inject({
145+
method: 'POST',
146+
url: '/auth/login',
147+
payload: { email: 'test@example.com', password: 'wrong-password' },
148+
});
149+
150+
expect(res.statusCode).toBe(401);
151+
expect(res.json().error).toBe('Invalid email or password');
152+
});
153+
});

apps/backend/src/__tests__/profiles.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const mockUser = {
1818
cards: [],
1919
provider: 'github',
2020
providerId: 'gh-123',
21+
passwordHash: 'scrypt:salt:hash',
2122
};
2223

2324
const mockPrisma: Pick<PrismaClient, 'user'> = {
@@ -52,6 +53,7 @@ describe('GET /api/profiles/me', () => {
5253
expect(body.email).toBe('test@example.com');
5354
expect(body.provider).toBeUndefined();
5455
expect(body.providerId).toBeUndefined();
56+
expect(body.passwordHash).toBeUndefined();
5557
});
5658

5759
it('should return 404 if user not found', async () => {
@@ -147,4 +149,4 @@ describe('PUT /api/profiles/me', () => {
147149
expect(res.statusCode).toBe(200);
148150
expect(mockPrisma.user.findFirst).not.toHaveBeenCalled();
149151
});
150-
});
152+
});

apps/backend/src/routes/auth.ts

Lines changed: 121 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
22
import { encrypt } from '../utils/encryption.js';
33
import { buildOAuthState, getMobileRedirectUri } from '../services/authService.js';
4+
import { loginSchema, signupSchema } from '../utils/validators.js';
5+
import { hashPassword, verifyPassword } from '../utils/password.js';
46

57
const GITHUB_AUTH_URL = 'https://github.com/login/oauth/authorize';
68
const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token';
@@ -14,7 +16,124 @@ interface OAuthCallbackQuery {
1416
state?: string;
1517
}
1618

19+
const authUserSelect = {
20+
id: true,
21+
email: true,
22+
username: true,
23+
displayName: true,
24+
bio: true,
25+
pronouns: true,
26+
role: true,
27+
company: true,
28+
avatarUrl: true,
29+
accentColor: true,
30+
createdAt: true,
31+
};
32+
33+
function setAuthCookie(reply: FastifyReply, token: string) {
34+
reply.setCookie('token', token, {
35+
httpOnly: true,
36+
secure: process.env.NODE_ENV === 'production',
37+
sameSite: 'lax',
38+
path: '/',
39+
maxAge: 30 * 24 * 60 * 60,
40+
});
41+
}
42+
43+
function getDuplicateAuthError(error: any): string {
44+
const fields = Array.isArray(error?.meta?.target) ? error.meta.target : [];
45+
if (fields.includes('username')) {
46+
return 'Username already taken';
47+
}
48+
return 'Email already registered';
49+
}
50+
1751
export async function authRoutes(app: FastifyInstance) {
52+
app.post('/signup', async (request: FastifyRequest, reply: FastifyReply) => {
53+
const parsed = signupSchema.safeParse(request.body);
54+
if (!parsed.success) {
55+
return reply.status(400).send({ error: 'Validation failed', details: parsed.error.flatten() });
56+
}
57+
58+
const { email, password, username, displayName } = parsed.data;
59+
60+
const existing = await app.prisma.user.findFirst({
61+
where: { OR: [{ email }, { username }] },
62+
select: { email: true, username: true },
63+
});
64+
65+
if (existing?.email === email) {
66+
return reply.status(409).send({ error: 'Email already registered' });
67+
}
68+
69+
if (existing?.username === username) {
70+
return reply.status(409).send({ error: 'Username already taken' });
71+
}
72+
73+
try {
74+
const passwordHash = await hashPassword(password);
75+
const user = await app.prisma.$transaction(async (tx) => {
76+
const createdUser = await tx.user.create({
77+
data: {
78+
email,
79+
username,
80+
displayName,
81+
provider: 'password',
82+
providerId: email,
83+
passwordHash,
84+
},
85+
select: authUserSelect,
86+
});
87+
88+
await tx.card.create({
89+
data: {
90+
userId: createdUser.id,
91+
title: 'Main DevCard',
92+
isDefault: true,
93+
},
94+
});
95+
96+
return createdUser;
97+
});
98+
99+
const token = app.jwt.sign({ id: user.id, username: user.username }, { expiresIn: '30d' });
100+
setAuthCookie(reply, token);
101+
102+
return reply.status(201).send({ token, user });
103+
} catch (error: any) {
104+
if (error?.code === 'P2002') {
105+
return reply.status(409).send({ error: getDuplicateAuthError(error) });
106+
}
107+
108+
app.log.error({ error }, 'Email signup failed');
109+
return reply.status(500).send({ error: 'Signup failed' });
110+
}
111+
});
112+
113+
app.post('/login', async (request: FastifyRequest, reply: FastifyReply) => {
114+
const parsed = loginSchema.safeParse(request.body);
115+
if (!parsed.success) {
116+
return reply.status(400).send({ error: 'Validation failed', details: parsed.error.flatten() });
117+
}
118+
119+
const { email, password } = parsed.data;
120+
const user = await app.prisma.user.findUnique({
121+
where: { email },
122+
select: { ...authUserSelect, passwordHash: true },
123+
});
124+
125+
const passwordMatches = await verifyPassword(password, user?.passwordHash ?? null);
126+
if (!user || !passwordMatches) {
127+
return reply.status(401).send({ error: 'Invalid email or password' });
128+
}
129+
130+
const { passwordHash, ...safeUser } = user;
131+
const token = app.jwt.sign({ id: user.id, username: user.username }, { expiresIn: '30d' });
132+
setAuthCookie(reply, token);
133+
134+
return { token, user: safeUser };
135+
});
136+
18137
// Developer login bypass (development only)
19138
if (process.env.NODE_ENV !== 'production') {
20139
app.post('/dev-login', async (request: FastifyRequest, reply: FastifyReply) => {
@@ -136,13 +255,7 @@ export async function authRoutes(app: FastifyInstance) {
136255
return reply.redirect(`${mobileRedirect}#token=${token}`);
137256
}
138257

139-
reply.setCookie('token', token, {
140-
httpOnly: true,
141-
secure: process.env.NODE_ENV === 'production',
142-
sameSite: 'lax',
143-
path: '/',
144-
maxAge: 30 * 24 * 60 * 60,
145-
});
258+
setAuthCookie(reply, token);
146259

147260
return reply.redirect(`${process.env.PUBLIC_APP_URL}/dashboard`);
148261
} catch (error) {
@@ -237,13 +350,7 @@ export async function authRoutes(app: FastifyInstance) {
237350
return reply.redirect(`${mobileRedirect}#token=${token}`);
238351
}
239352

240-
reply.setCookie('token', token, {
241-
httpOnly: true,
242-
secure: process.env.NODE_ENV === 'production',
243-
sameSite: 'lax',
244-
path: '/',
245-
maxAge: 30 * 24 * 60 * 60,
246-
});
353+
setAuthCookie(reply, token);
247354

248355
return reply.redirect(`${process.env.PUBLIC_APP_URL}/dashboard`);
249356
} catch (error) {

apps/backend/src/services/profileService.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export async function getOwnProfile(app: FastifyInstance, userId: string) {
1414

1515
if (!user) return null
1616

17-
const { provider, providerId, ...profileData } = user as any
17+
const { provider, providerId, passwordHash, ...profileData } = user as any
1818
return { ...profileData, defaultCardId: user.cards[0]?.id || null }
1919
}
2020

0 commit comments

Comments
 (0)