Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
3 changes: 2 additions & 1 deletion apps/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -194,4 +195,4 @@ model TeamMember{
@@unique([userId, teamId])
@@index([userId])
@@map("team_members")
}
}
161 changes: 161 additions & 0 deletions apps/backend/src/__tests__/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';

Check failure on line 1 in apps/backend/src/__tests__/auth.test.ts

View workflow job for this annotation

GitHub Actions / backend-ci

`vitest` import should occur after import of `@fastify/jwt`
import Fastify from 'fastify';

Check failure on line 2 in apps/backend/src/__tests__/auth.test.ts

View workflow job for this annotation

GitHub Actions / backend-ci

`fastify` import should occur after import of `@fastify/jwt`
import cookie from '@fastify/cookie';
import jwt from '@fastify/jwt';

Check failure on line 4 in apps/backend/src/__tests__/auth.test.ts

View workflow job for this annotation

GitHub Actions / backend-ci

There should be at least one empty line between import groups
import { authRoutes } from '../routes/auth.js';

Check failure on line 5 in apps/backend/src/__tests__/auth.test.ts

View workflow job for this annotation

GitHub Actions / backend-ci

There should be at least one empty line between import groups
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<unknown>) => callback(mockPrisma)),
};

async function buildApp() {

Check warning on line 34 in apps/backend/src/__tests__/auth.test.ts

View workflow job for this annotation

GitHub Actions / backend-ci

Missing return type on function
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) {

Check failure on line 43 in apps/backend/src/__tests__/auth.test.ts

View workflow job for this annotation

GitHub Actions / backend-ci

'err' is defined but never used. Allowed unused caught errors must match /^_/u
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<unknown>) => 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');
});
});
4 changes: 3 additions & 1 deletion apps/backend/src/__tests__/profiles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const mockUser = {
cards: [],
provider: 'github',
providerId: 'gh-123',
passwordHash: 'scrypt:salt:hash',
};

const mockPrisma = {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -149,4 +151,4 @@ describe('PUT /api/profiles/me', () => {
expect(res.statusCode).toBe(200);
expect(mockPrisma.user.findFirst).not.toHaveBeenCalled();
});
});
});
134 changes: 120 additions & 14 deletions apps/backend/src/routes/auth.ts
Original file line number Diff line number Diff line change
@@ -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';

Check failure on line 5 in apps/backend/src/routes/auth.ts

View workflow job for this annotation

GitHub Actions / backend-ci

`../utils/password.js` import should occur before import of `../utils/validators.js`

import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';

Expand All @@ -16,7 +18,123 @@
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) {

Check warning on line 35 in apps/backend/src/routes/auth.ts

View workflow job for this annotation

GitHub Actions / backend-ci

Missing return type on function
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<void> {
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;

Check failure on line 132 in apps/backend/src/routes/auth.ts

View workflow job for this annotation

GitHub Actions / backend-ci

'passwordHash' is assigned a value but never used. Allowed unused vars must match /^_/u
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) => {
Expand Down Expand Up @@ -137,13 +255,7 @@
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) {
Expand Down Expand Up @@ -239,13 +351,7 @@
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) {
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/src/services/profileService.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { FastifyInstance } from 'fastify'

Check failure on line 1 in apps/backend/src/services/profileService.ts

View workflow job for this annotation

GitHub Actions / backend-ci

`fastify` type import should occur after import of `../utils/error.util.js`

Check failure on line 1 in apps/backend/src/services/profileService.ts

View workflow job for this annotation

GitHub Actions / backend-ci

There should be at least one empty line between import groups
import { getProfileUrl } from '@devcard/shared'

Check failure on line 2 in apps/backend/src/services/profileService.ts

View workflow job for this annotation

GitHub Actions / backend-ci

There should be at least one empty line between import groups
import type { PlatformLink } from '@devcard/shared'
import { getErrorMessage } from '../utils/error.util.js'

export async function getOwnProfile(app: FastifyInstance, userId: string) {

Check warning on line 6 in apps/backend/src/services/profileService.ts

View workflow job for this annotation

GitHub Actions / backend-ci

Missing return type on function
const user = await app.prisma.user.findUnique({
where: { id: userId },
include: {
Expand All @@ -14,11 +14,11 @@

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 }
}

export async function updateProfile(app: FastifyInstance, userId: string, data: any) {

Check warning on line 21 in apps/backend/src/services/profileService.ts

View workflow job for this annotation

GitHub Actions / backend-ci

Missing return type on function
// Fast-path uniqueness check
if (data.username) {
const existing = await app.prisma.user.findFirst({
Expand Down Expand Up @@ -48,27 +48,27 @@
}
}

export async function createPlatformLink(app: FastifyInstance, userId: string, linkData: any) {

Check warning on line 51 in apps/backend/src/services/profileService.ts

View workflow job for this annotation

GitHub Actions / backend-ci

Missing return type on function
const url = linkData.url || getProfileUrl(linkData.platform, linkData.username)
const maxOrder = await app.prisma.platformLink.aggregate({ where: { userId }, _max: { displayOrder: true } })
return app.prisma.platformLink.create({ data: { userId, platform: linkData.platform, username: linkData.username, url, displayOrder: (maxOrder._max.displayOrder ?? -1) + 1 } })
}

export async function updatePlatformLink(app: FastifyInstance, userId: string, id: string, linkData: any) {

Check warning on line 57 in apps/backend/src/services/profileService.ts

View workflow job for this annotation

GitHub Actions / backend-ci

Missing return type on function
const existing = await app.prisma.platformLink.findFirst({ where: { id, userId } })
if (!existing) return null
const url = linkData.url || getProfileUrl(linkData.platform, linkData.username)
return app.prisma.platformLink.update({ where: { id }, data: { platform: linkData.platform, username: linkData.username, url } })
}

export async function deletePlatformLink(app: FastifyInstance, userId: string, id: string) {

Check warning on line 64 in apps/backend/src/services/profileService.ts

View workflow job for this annotation

GitHub Actions / backend-ci

Missing return type on function
const existing = await app.prisma.platformLink.findFirst({ where: { id, userId } })
if (!existing) return false
await app.prisma.platformLink.delete({ where: { id } })
return true
}

export async function reorderLinks(app: FastifyInstance, userId: string, links: Array<{ id: string; displayOrder: number }>) {

Check warning on line 71 in apps/backend/src/services/profileService.ts

View workflow job for this annotation

GitHub Actions / backend-ci

Missing return type on function
await app.prisma.$transaction(links.map((link) => app.prisma.platformLink.updateMany({ where: { id: link.id, userId }, data: { displayOrder: link.displayOrder } })))
return { message: 'Links reordered' }
}
Loading
Loading