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.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 (
+
+ );
+}
+
+const inputStyle: React.CSSProperties = {
+ padding: '14px',
+ borderRadius: '12px',
+ border: '1px solid #334155',
+ background: '#0f172a',
+ color: 'white',
+ fontSize: '16px',
+ outline: 'none',
+};
+
+const textareaStyle: React.CSSProperties = {
+ ...inputStyle,
+ resize: 'none',
+};
\ No newline at end of file
diff --git a/apps/web/src/pages/LandingPage.tsx b/apps/web/src/pages/LandingPage.tsx
index dd5f3324..9632f6ae 100644
--- a/apps/web/src/pages/LandingPage.tsx
+++ b/apps/web/src/pages/LandingPage.tsx
@@ -41,7 +41,7 @@ export default function LandingPage() {
Twitter, and every other profile with a single NFC tap — beautifully.
-
+
Get Started Free
+
+
🚀 DevCard Auth
+
+
Welcome to DevCard
+
+
+ Connect your developer identity and start sharing your profile instantly.
+
+
+
+
+
+
+
+
+
+ Developer networking made simple ⚡
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/web/src/pages/ProfilePage.tsx b/apps/web/src/pages/ProfilePage.tsx
index 94a84f54..91d00411 100644
--- a/apps/web/src/pages/ProfilePage.tsx
+++ b/apps/web/src/pages/ProfilePage.tsx
@@ -18,17 +18,14 @@ export default function ProfilePage() {
const [profile, setProfile] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
- const [mounted, setMounted] = useState(false);
+
const [copyMessage, setCopyMessage] = useState('');
const [copyStatus, setCopyStatus] = useState<'success' | 'error'>('success');
- useEffect(() => {
- setMounted(true);
- }, []);
-
+
useEffect(() => {
if (!username) return;
- setLoading(true);
+
apiFetch(`/api/u/${username}?source=web`)
.then((data) => {
setProfile(data);
@@ -107,7 +104,7 @@ export default function ProfilePage() {
className="bg-gradient"
style={{ '--accent': profile.accentColor || '#6366f1' } as React.CSSProperties}
/>
-
+
Want a card like this?
-
+
Create your DevCard ⚡