Skip to content
Open
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
16 changes: 11 additions & 5 deletions apps/backend/src/__tests__/cards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,21 @@
async (callback: (tx: typeof mockPrisma) => Promise<unknown>) => callback(mockPrisma),
);
}

async function buildApp():Promise<FastifyInstance> {
async function buildApp(): Promise<FastifyInstance> {
const app = Fastify({ logger: false });
app.decorate('prisma', mockPrisma as unknown as PrismaClient);
app.decorate('authenticate', async (request: any) => {
request.user = { id: USER_ID };
});

app.decorate(
'authenticate',
async (request: FastifyRequest & { user?: { id: string } }) => {

Check warning on line 60 in apps/backend/src/__tests__/cards.test.ts

View workflow job for this annotation

GitHub Actions / backend-ci

'FastifyRequest' is an 'error' type that acts as 'any' and overrides all other types in this intersection type
request.user = { id: USER_ID };
}
);

app.register(cardRoutes, { prefix: '/api/cards' });

await app.ready();

return app;
}

Expand Down
10 changes: 6 additions & 4 deletions apps/backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,13 @@ export async function buildApp():Promise<FastifyInstance> {
done();
});

await app.register(cookie);

// ─── Core Plugins ───
await app.register(cors, {
origin: process.env.PUBLIC_APP_URL || 'http://localhost:5173',
credentials: true,
});
app.register(cors, {
origin: 'http://localhost:5174',
credentials: true,
});

await app.register(helmet, {
contentSecurityPolicy: {
Expand Down
115 changes: 79 additions & 36 deletions apps/backend/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,24 +100,38 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
email = primary?.email || emails[0]?.email;
}

const user = await app.prisma.user.upsert({
where: { provider_providerId: { provider: 'github', providerId: String(githubUser.id) } },
update: {
email: email || `${githubUser.login}@github.local`,
displayName: githubUser.name || githubUser.login,
avatarUrl: githubUser.avatar_url,
},
create: {
email: email || `${githubUser.login}@github.local`,
username: githubUser.login,
displayName: githubUser.name || githubUser.login,
bio: githubUser.bio,
company: githubUser.company,
avatarUrl: githubUser.avatar_url,
provider: 'github',
providerId: String(githubUser.id),
},
});
let user = await app.prisma.user.findUnique({
where: {
email: email || `${githubUser.login}@github.local`,
},
});

if (user) {
user = await app.prisma.user.update({
where: {
email: email || `${githubUser.login}@github.local`,
},
data: {
provider: 'github',
providerId: String(githubUser.id),
displayName: githubUser.name || githubUser.login,
avatarUrl: githubUser.avatar_url,
},
});
} else {
user = await app.prisma.user.create({
data: {
email: email || `${githubUser.login}@github.local`,
username: githubUser.login,
displayName: githubUser.name || githubUser.login,
bio: githubUser.bio,
company: githubUser.company,
avatarUrl: githubUser.avatar_url,
provider: 'github',
providerId: String(githubUser.id),
},
});
}

try {
const encryptedToken = encrypt(tokenData.access_token);
Expand Down Expand Up @@ -217,20 +231,42 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
const userRes = await fetch(GOOGLE_USER_URL, { headers: { Authorization: `Bearer ${tokenData.access_token}` } });
const googleUser = (await userRes.json()) as any;

const baseUsername = googleUser.email.split('@')[0].replace(/[^a-zA-Z0-9_-]/g, '');

const user = await app.prisma.user.upsert({
where: { provider_providerId: { provider: 'google', providerId: googleUser.id } },
update: { email: googleUser.email, displayName: googleUser.name || baseUsername, avatarUrl: googleUser.picture },
create: {
email: googleUser.email,
username: `${baseUsername}_${Date.now().toString(36)}`,
displayName: googleUser.name || baseUsername,
avatarUrl: googleUser.picture,
provider: 'google',
providerId: googleUser.id,
},
});
const baseUsername = googleUser.email
.split('@')[0]
.replace(/[^a-zA-Z0-9_-]/g, '');

const existingUser = await app.prisma.user.findUnique({
where: {
email: googleUser.email,
},
});

let user;

if (existingUser) {
user = await app.prisma.user.update({
where: {
email: googleUser.email,
},
data: {
provider: 'google',
providerId: googleUser.id,
displayName: googleUser.name || baseUsername,
avatarUrl: googleUser.picture,
},
});
} else {
user = await app.prisma.user.create({
data: {
email: googleUser.email,
username: `${baseUsername}_${Date.now().toString(36)}`,
displayName: googleUser.name || baseUsername,
avatarUrl: googleUser.picture,
provider: 'google',
providerId: googleUser.id,
},
});
}

const token = app.jwt.sign({ id: user.id, username: user.username }, { expiresIn: '30d' });

Expand Down Expand Up @@ -260,6 +296,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
preHandler: [app.authenticate],
}, async (request: FastifyRequest, reply: FastifyReply) => {
const userId = (request.user as any).id;

const user = await app.prisma.user.findUnique({
where: { id: userId },
select: {
Expand All @@ -274,17 +311,23 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
avatarUrl: true,
accentColor: true,
createdAt: true,
oauthTokens: { select: { platform: true, scopes: true, createdAt: true } },
oauthTokens: {
select: {
platform: true,
scopes: true,
createdAt: true,
},
},
},
});

if (!user) {
return reply.status(404).send({ error: 'User not found' });
return reply.status(404).send({
error: 'User not found',
});
}

const { oauthTokens, ...userData } = user;
return { ...userData, connectedPlatforms: oauthTokens };
});

// Legacy endpoint kept for backward compatibility with existing clients.
// Cookie-only logout — use DELETE /auth/logout for token revocation.
Expand Down
10 changes: 7 additions & 3 deletions apps/backend/src/routes/cards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
import { handleDbError } from '../utils/error.util.js';
import { createCardSchema, updateCardSchema } from '../utils/validators.js';

import type { CardResponse } from '../services/cardService';
import type { CardResponse } from '../services/cardService.js';
import type { Card } from '@devcard/shared';

Check failure on line 6 in apps/backend/src/routes/cards.ts

View workflow job for this annotation

GitHub Actions / backend-ci

'Card' is defined but never used. Allowed unused vars must match /^_/u
import type { Prisma } from '@prisma/client';

Check failure on line 7 in apps/backend/src/routes/cards.ts

View workflow job for this annotation

GitHub Actions / backend-ci

'Prisma' is defined but never used. Allowed unused vars must match /^_/u
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';

interface CreateCardBody {
Expand Down Expand Up @@ -69,7 +70,7 @@

// ─── Create Card ───

app.post('/', async (request: FastifyRequest<{ Body: CreateCardBody }>, reply: FastifyReply): Promise<Card | void> => {
app.post('/', async (request: FastifyRequest<{ Body: CreateCardBody }>, reply: FastifyReply): Promise<CardResponse | void> => {
const userId = (request.user as { id: string }).id;
const parsed = createCardSchema.safeParse(request.body);

Expand All @@ -88,7 +89,10 @@

// ─── Update Card ───

app.put('/:id', async (request: FastifyRequest<{ Params: CardParams; Body: UpdateCardBody }>, reply: FastifyReply): Promise<CardResponse> => {
app.put('/:id', async (
request: FastifyRequest<{ Params: CardParams; Body: UpdateCardBody }>,
reply: FastifyReply,
): Promise<CardResponse | void> => {
const userId = (request.user as { id: string }).id;
const { id } = request.params;

Expand Down
7 changes: 6 additions & 1 deletion apps/backend/src/services/cardService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import type { FastifyInstance } from 'fastify';

type CardLinkResponse = { platformLink: unknown };
type RawCard = { id: string; title: string; isDefault: boolean; cardLinks: CardLinkResponse[] };
export type CardResponse = { id: string; title: string; isDefault: boolean; links: unknown[] };
export type CardResponse = {
id: string;
title: string;
isDefault: boolean;
links: unknown[];
};

function mapCard(card: RawCard): CardResponse {
return {
Expand Down
11 changes: 10 additions & 1 deletion apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,23 @@ import LandingPage from './pages/LandingPage';
import ProfilePage from './pages/ProfilePage';
import CardPage from './pages/CardPage';
import NotFound from './pages/NotFound';

import CreatePage from './pages/CreatePage';
import LoginPage from './pages/LoginPage';
import DashboardPage from './pages/DashboardPage';
import EditProfilePage from './pages/EditProfilePage';
import PublicProfilePage from './pages/PublicProfilePage';
export default function App() {
return (
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/u/:username" element={<ProfilePage />} />
<Route path="/devcard/:id" element={<CardPage />} />
<Route path="*" element={<NotFound />} />
<Route path="/create" element={<CreatePage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/edit-profile" element={<EditProfilePage />} />
<Route path="/u/:username" element={<PublicProfilePage />} />
</Routes>
);
}
18 changes: 14 additions & 4 deletions apps/web/src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,26 @@
const API_BASE_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000';

export async function apiFetch<T>(endpoint: string): Promise<T> {
export async function apiFetch<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
headers: { 'Content-Type': 'application/json' },
...options,
credentials: 'include', // IMPORTANT
headers: {
'Content-Type': 'application/json',
...(options.headers || {}),
},
});

if (!response.ok) {
const error = await response.json().catch(() => ({}));

throw new Error(
(error as Record<string, string>)?.message ?? `Request failed: ${response.status}`
(error as Record<string, string>)?.message ??
`Request failed: ${response.status}`
);
}

return response.json() as Promise<T>;
}
}
11 changes: 11 additions & 0 deletions apps/web/src/lib/theme.context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { createContext } from 'react';

import type { Theme } from './theme.utils';

export interface ThemeContextValue {
theme: Theme;
toggleTheme: () => void;
}

export const ThemeContext =
createContext<ThemeContextValue | null>(null);
61 changes: 31 additions & 30 deletions apps/web/src/lib/theme.tsx
Original file line number Diff line number Diff line change
@@ -1,47 +1,48 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';

type Theme = 'light' | 'dark';

interface ThemeContextValue {
theme: Theme;
toggleTheme: () => void;
}

const ThemeContext = createContext<ThemeContextValue | null>(null);

function getInitialTheme(): Theme {
if (typeof window === 'undefined') return 'dark';

const stored = localStorage.getItem('devcard-theme');
if (stored === 'light' || stored === 'dark') return stored;

return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}

export function ThemeProvider({ children }: { children: ReactNode }) {
import {
useEffect,
useState,
type ReactNode,
} from 'react';

import {
ThemeContext,
} from './theme.context';

import {
getInitialTheme,
type Theme,
} from './theme.utils';

export function ThemeProvider({
children,
}: {
children: ReactNode;
}) {
const [theme, setTheme] = useState<Theme>(getInitialTheme);

useEffect(() => {
const root = document.documentElement;

if (theme === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}

localStorage.setItem('devcard-theme', theme);
}, [theme]);

const toggleTheme = () => setTheme((t) => (t === 'dark' ? 'light' : 'dark'));
const toggleTheme = () => {
setTheme((prev) => (
prev === 'dark' ? 'light' : 'dark'
));
};

return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
<ThemeContext.Provider
value={{ theme, toggleTheme }}
>
{children}
</ThemeContext.Provider>
);
}

export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
return ctx;
}
}
Loading
Loading