diff --git a/apps/backend/src/__tests__/cards.test.ts b/apps/backend/src/__tests__/cards.test.ts index 3542a539..0e562e34 100644 --- a/apps/backend/src/__tests__/cards.test.ts +++ b/apps/backend/src/__tests__/cards.test.ts @@ -51,15 +51,21 @@ function wireTransaction(): void { async (callback: (tx: typeof mockPrisma) => Promise) => callback(mockPrisma), ); } - -async function buildApp():Promise { +async function buildApp(): Promise { 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 } }) => { + request.user = { id: USER_ID }; + } + ); + app.register(cardRoutes, { prefix: '/api/cards' }); + await app.ready(); + return app; } diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 6116b91b..6e325477 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -48,11 +48,13 @@ export async function buildApp():Promise { 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: { diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts index cffebea7..7094e1f9 100644 --- a/apps/backend/src/routes/auth.ts +++ b/apps/backend/src/routes/auth.ts @@ -100,24 +100,38 @@ export async function authRoutes(app: FastifyInstance): Promise { 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); @@ -217,20 +231,42 @@ export async function authRoutes(app: FastifyInstance): Promise { 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' }); @@ -260,6 +296,7 @@ export async function authRoutes(app: FastifyInstance): Promise { 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: { @@ -274,17 +311,23 @@ export async function authRoutes(app: FastifyInstance): Promise { 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. diff --git a/apps/backend/src/routes/cards.ts b/apps/backend/src/routes/cards.ts index e5f98762..4d389910 100644 --- a/apps/backend/src/routes/cards.ts +++ b/apps/backend/src/routes/cards.ts @@ -2,8 +2,9 @@ import * as cardService from '../services/cardService' 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'; +import type { Prisma } from '@prisma/client'; import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; interface CreateCardBody { @@ -69,7 +70,7 @@ export async function cardRoutes(app: FastifyInstance): Promise { // ─── Create Card ─── - app.post('/', async (request: FastifyRequest<{ Body: CreateCardBody }>, reply: FastifyReply): Promise => { + app.post('/', async (request: FastifyRequest<{ Body: CreateCardBody }>, reply: FastifyReply): Promise => { const userId = (request.user as { id: string }).id; const parsed = createCardSchema.safeParse(request.body); @@ -88,7 +89,10 @@ export async function cardRoutes(app: FastifyInstance): Promise { // ─── Update Card ─── - app.put('/:id', async (request: FastifyRequest<{ Params: CardParams; Body: UpdateCardBody }>, reply: FastifyReply): Promise => { +app.put('/:id', async ( + request: FastifyRequest<{ Params: CardParams; Body: UpdateCardBody }>, + reply: FastifyReply, +): Promise => { const userId = (request.user as { id: string }).id; const { id } = request.params; diff --git a/apps/backend/src/services/cardService.ts b/apps/backend/src/services/cardService.ts index fd3b9903..461f94eb 100644 --- a/apps/backend/src/services/cardService.ts +++ b/apps/backend/src/services/cardService.ts @@ -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 { diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 49c29037..e2072bfc 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -3,7 +3,11 @@ 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 ( @@ -11,6 +15,11 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> + } /> + } /> ); } diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index be6afdd8..b85056c1 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1,16 +1,26 @@ const API_BASE_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000'; -export async function apiFetch(endpoint: string): Promise { +export async function apiFetch( + endpoint: string, + options: RequestInit = {} +): Promise { 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)?.message ?? `Request failed: ${response.status}` + (error as Record)?.message ?? + `Request failed: ${response.status}` ); } return response.json() as Promise; -} +} \ No newline at end of file diff --git a/apps/web/src/lib/theme.context.ts b/apps/web/src/lib/theme.context.ts new file mode 100644 index 00000000..bf092284 --- /dev/null +++ b/apps/web/src/lib/theme.context.ts @@ -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(null); \ No newline at end of file diff --git a/apps/web/src/lib/theme.tsx b/apps/web/src/lib/theme.tsx index 7beda8bd..eb3270ce 100644 --- a/apps/web/src/lib/theme.tsx +++ b/apps/web/src/lib/theme.tsx @@ -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(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(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 ( - + {children} ); -} - -export function useTheme(): ThemeContextValue { - const ctx = useContext(ThemeContext); - if (!ctx) throw new Error('useTheme must be used within ThemeProvider'); - return ctx; -} +} \ No newline at end of file diff --git a/apps/web/src/lib/theme.utils.ts b/apps/web/src/lib/theme.utils.ts new file mode 100644 index 00000000..a244a5e6 --- /dev/null +++ b/apps/web/src/lib/theme.utils.ts @@ -0,0 +1,15 @@ +export type Theme = 'light' | 'dark'; + +export 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'; +} \ No newline at end of file diff --git a/apps/web/src/lib/useTheme.ts b/apps/web/src/lib/useTheme.ts new file mode 100644 index 00000000..4ae998d2 --- /dev/null +++ b/apps/web/src/lib/useTheme.ts @@ -0,0 +1,15 @@ +import { useContext } from 'react'; + +import { ThemeContext } from './theme.context'; + +import type { ThemeContextValue } from './theme.context'; + +export function useTheme(): ThemeContextValue { + const ctx = useContext(ThemeContext); + + if (!ctx) { + throw new Error('useTheme must be used within ThemeProvider'); + } + + return ctx; +} \ No newline at end of file diff --git a/apps/web/src/pages/CardPage.tsx b/apps/web/src/pages/CardPage.tsx index 690ce574..31aad24e 100644 --- a/apps/web/src/pages/CardPage.tsx +++ b/apps/web/src/pages/CardPage.tsx @@ -24,7 +24,7 @@ export default function CardPage() { useEffect(() => { if (!id) return; - setLoading(true); + apiFetch(`/api/u/card/${id}`) .then((data) => { setCard(data); diff --git a/apps/web/src/pages/CreatePage.tsx b/apps/web/src/pages/CreatePage.tsx new file mode 100644 index 00000000..4b0e7f59 --- /dev/null +++ b/apps/web/src/pages/CreatePage.tsx @@ -0,0 +1,19 @@ +export default function CreatePage() { + return ( +
+

Create Your DevCard 🚀

+

This page is under development.

+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/pages/DashboardPage.css b/apps/web/src/pages/DashboardPage.css new file mode 100644 index 00000000..4d20b695 --- /dev/null +++ b/apps/web/src/pages/DashboardPage.css @@ -0,0 +1,55 @@ +.dashboard-page { + min-height: 100vh; + background: #0f172a; + display: flex; + justify-content: center; + align-items: center; + padding: 2rem; +} + +.dashboard-card { + width: 100%; + max-width: 500px; + background: rgba(255,255,255,0.06); + border: 1px solid rgba(255,255,255,0.08); + backdrop-filter: blur(20px); + border-radius: 24px; + padding: 2.5rem; + color: white; + text-align: center; +} + +.dashboard-avatar { + width: 110px; + height: 110px; + border-radius: 50%; + object-fit: cover; + margin-bottom: 1rem; +} + +.username { + color: #a5b4fc; + margin-top: 0.5rem; +} + +.email { + color: #cbd5e1; + margin-top: 0.5rem; +} + +.bio { + margin-top: 1rem; + color: #e2e8f0; + line-height: 1.6; +} + +.logout-btn { + margin-top: 2rem; + border: none; + background: #ef4444; + color: white; + padding: 0.9rem 1.4rem; + border-radius: 12px; + cursor: pointer; + font-weight: 600; +} \ No newline at end of file diff --git a/apps/web/src/pages/DashboardPage.tsx b/apps/web/src/pages/DashboardPage.tsx new file mode 100644 index 00000000..b0a40533 --- /dev/null +++ b/apps/web/src/pages/DashboardPage.tsx @@ -0,0 +1,115 @@ +import { useEffect, useState } from 'react'; +import './DashboardPage.css'; +import { useNavigate } from 'react-router-dom'; + +interface User { + id: string; + username: string; + displayName: string; + email: string; + bio?: string; + avatarUrl?: string; +} + +const BACKEND_URL = 'http://localhost:3000'; + +export default function DashboardPage() { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + const navigate = useNavigate(); + + useEffect(() => { + fetch(`${BACKEND_URL}/auth/me`, { + credentials: 'include', + }) + .then(async (res) => { + if (!res.ok) { + throw new Error('Unauthorized'); + } + + const data = await res.json(); + setUser(data); + }) + .catch(() => { + setUser(null); + }) + .finally(() => { + setLoading(false); + }); + }, []); + + async function handleLogout() { + await fetch(`${BACKEND_URL}/auth/logout`, { + method: 'POST', + credentials: 'include', + }); + + window.location.href = '/login'; + } + + if (loading) { + return ( +
+

Loading dashboard...

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

Unauthorized

+

Please login first.

+
+
+ ); + } + + return ( +
+
+ {user.avatarUrl && ( + {user.displayName} + )} + +

{user.displayName}

+ +

@{user.username}

+ +

{user.email}

+ + {user.bio && ( +

{user.bio}

+ )} + + + + +
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/pages/EditProfilePage.tsx b/apps/web/src/pages/EditProfilePage.tsx new file mode 100644 index 00000000..8e41856e --- /dev/null +++ b/apps/web/src/pages/EditProfilePage.tsx @@ -0,0 +1,332 @@ +import { useEffect, useState } from 'react'; + +const API_BASE_URL = + import.meta.env.VITE_API_URL ?? 'http://localhost:3000'; + +type UserProfile = { + displayName: string; + username: string; + email: string; + bio: string; + role: string; + company: string; + accentColor: string; + avatarUrl: string; + github: string; +linkedin: string; +twitter: string; +website: string; +}; + +export default function EditProfilePage() { + const [profile, setProfile] = useState({ + displayName: '', + username: '', + email: '', + bio: '', + role: '', + company: '', + accentColor: '#6366f1', + avatarUrl: '', + github: '', +linkedin: '', +twitter: '', +website: '', + }); + + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState(''); + + // Fetch logged-in user + useEffect(() => { + async function fetchProfile() { + try { + const response = await fetch(`${API_BASE_URL}/auth/me`, { + credentials: 'include', + }); + + const data = await response.json(); + + setProfile({ + displayName: data.displayName || '', + username: data.username || '', + email: data.email || '', + bio: data.bio || '', + role: data.role || '', + company: data.company || '', + accentColor: data.accentColor || '#6366f1', + avatarUrl: data.avatarUrl || '', + github: data.github || '', +linkedin: data.linkedin || '', +twitter: data.twitter || '', +website: data.website || '', + }); + } catch (error) { + console.error(error); + } finally { + setLoading(false); + } + } + + fetchProfile(); + }, []); + + // Handle form input + const handleChange = ( + e: React.ChangeEvent + ) => { + setProfile({ + ...profile, + [e.target.name]: e.target.value, + }); + }; + + // Save profile + const handleSave = async () => { + try { + setSaving(true); + setMessage(''); + + const response = await fetch( + `${API_BASE_URL}/api/profiles/me`, + { + method: 'PUT', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(profile), + } + ); + + if (!response.ok) { + throw new Error('Failed to update profile'); + } + + setMessage('Profile updated successfully!'); + } catch (error) { + console.error(error); + setMessage('Failed to update profile'); + } finally { + setSaving(false); + } + }; + + if (loading) { + return ( +
+ Loading... +
+ ); + } + + return ( +
+
+

+ Edit Profile +

+ +
+ + + + + + +