From c94e8767a62baeaa4e7da63f3f79b3d7829156b5 Mon Sep 17 00:00:00 2001 From: anirudhagarwal-dev Date: Tue, 9 Jun 2026 17:40:10 +0530 Subject: [PATCH 1/2] fix: remove github_follow token when disconnecting github account --- apps/backend/package-lock.json | 19 ++++ apps/backend/package.json | 3 +- apps/backend/src/__tests__/connect.test.ts | 113 +++++++++++++++++++++ apps/backend/src/routes/connect.ts | 28 +++-- apps/web/package-lock.json | 16 +++ apps/web/package.json | 3 +- packages/shared/package-lock.json | 18 ++++ packages/shared/package.json | 3 + 8 files changed, 194 insertions(+), 9 deletions(-) diff --git a/apps/backend/package-lock.json b/apps/backend/package-lock.json index 832b4eee..db5b8bd0 100644 --- a/apps/backend/package-lock.json +++ b/apps/backend/package-lock.json @@ -18,6 +18,7 @@ "@fastify/rate-limit": "^10.3.0", "@fastify/static": "^8.0.0", "@prisma/client": "^6.0.0", + "devcard": "file:../..", "dotenv": "^16.4.0", "fastify": "^5.0.0", "fastify-plugin": "^5.0.0", @@ -43,9 +44,23 @@ "vitest": "^2.0.0" } }, + "../..": { + "name": "devcard", + "version": "1.0.0", + "license": "Apache-2.0", + "devDependencies": { + "concurrently": "^9.2.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, "../../packages/shared": { "name": "@devcard/shared", "version": "1.0.0", + "dependencies": { + "devcard": "file:../.." + }, "devDependencies": { "typescript": "^5.4.0", "vitest": "^2.0.0" @@ -2920,6 +2935,10 @@ "devOptional": true, "license": "MIT" }, + "node_modules/devcard": { + "resolved": "../..", + "link": true + }, "node_modules/dijkstrajs": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", diff --git a/apps/backend/package.json b/apps/backend/package.json index d71b0777..62e45208 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -28,6 +28,7 @@ "@fastify/rate-limit": "^10.3.0", "@fastify/static": "^8.0.0", "@prisma/client": "^6.0.0", + "devcard": "file:../..", "dotenv": "^16.4.0", "fastify": "^5.0.0", "fastify-plugin": "^5.0.0", @@ -52,4 +53,4 @@ "typescript-eslint": "^8.59.3", "vitest": "^2.0.0" } -} \ No newline at end of file +} diff --git a/apps/backend/src/__tests__/connect.test.ts b/apps/backend/src/__tests__/connect.test.ts index 2b39535b..e099c995 100644 --- a/apps/backend/src/__tests__/connect.test.ts +++ b/apps/backend/src/__tests__/connect.test.ts @@ -22,6 +22,7 @@ const mockPrisma = { findMany: vi.fn(), upsert: vi.fn(), delete: vi.fn(), + deleteMany: vi.fn(), }, }; @@ -184,4 +185,116 @@ describe('GET /api/connect/github/callback', () => { expect(res.statusCode).toBe(302); expect(res.headers.location).toBe('http://localhost:3000/settings?error=connect_failed'); }); +}); + +describe('DELETE /api/connect/:platform', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('successfully deletes github and github_follow when platform is github', async () => { + mockPrisma.oAuthToken.deleteMany.mockResolvedValue({ count: 2 }); + const app = await buildApp(); + + const token = app.jwt.sign({ id: 'user-1' }); + const res = await app.inject({ + method: 'DELETE', + url: '/api/connect/github', + headers: { + Authorization: `Bearer ${token}` + } + }); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body)).toEqual({ success: true }); + expect(mockPrisma.oAuthToken.deleteMany).toHaveBeenCalledWith({ + where: { + userId: 'user-1', + platform: { in: ['github', 'github_follow'] } + } + }); + }); + + it('returns 404 if no github tokens are found', async () => { + mockPrisma.oAuthToken.deleteMany.mockResolvedValue({ count: 0 }); + const app = await buildApp(); + + const token = app.jwt.sign({ id: 'user-1' }); + const res = await app.inject({ + method: 'DELETE', + url: '/api/connect/github', + headers: { + Authorization: `Bearer ${token}` + } + }); + + expect(res.statusCode).toBe(404); + expect(JSON.parse(res.body)).toEqual({ error: 'Connection not found' }); + }); + + it('successfully deletes other platforms using delete', async () => { + mockPrisma.oAuthToken.delete.mockResolvedValue({}); + const app = await buildApp(); + + const token = app.jwt.sign({ id: 'user-1' }); + const res = await app.inject({ + method: 'DELETE', + url: '/api/connect/linkedin', + headers: { + Authorization: `Bearer ${token}` + } + }); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body)).toEqual({ success: true }); + expect(mockPrisma.oAuthToken.delete).toHaveBeenCalledWith({ + where: { + userId_platform: { + userId: 'user-1', + platform: 'linkedin' + } + } + }); + }); + + it('allows direct deletion of github_follow', async () => { + mockPrisma.oAuthToken.delete.mockResolvedValue({}); + const app = await buildApp(); + + const token = app.jwt.sign({ id: 'user-1' }); + const res = await app.inject({ + method: 'DELETE', + url: '/api/connect/github_follow', + headers: { + Authorization: `Bearer ${token}` + } + }); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body)).toEqual({ success: true }); + expect(mockPrisma.oAuthToken.delete).toHaveBeenCalledWith({ + where: { + userId_platform: { + userId: 'user-1', + platform: 'github_follow' + } + } + }); + }); + + it('returns 400 for unsupported platforms', async () => { + const app = await buildApp(); + + const token = app.jwt.sign({ id: 'user-1' }); + const res = await app.inject({ + method: 'DELETE', + url: '/api/connect/unsupported', + headers: { + Authorization: `Bearer ${token}` + } + }); + + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).error).toContain('Unsupported platform'); + }); }); \ No newline at end of file diff --git a/apps/backend/src/routes/connect.ts b/apps/backend/src/routes/connect.ts index bb04194d..01e163ad 100644 --- a/apps/backend/src/routes/connect.ts +++ b/apps/backend/src/routes/connect.ts @@ -181,20 +181,34 @@ export async function connectRoutes(app: FastifyInstance) { const userId = (request.user as any).id; const { platform } = request.params; - const SUPPORTED_PLATFORMS = ['github', 'google', 'twitter', 'linkedin']; + const SUPPORTED_PLATFORMS = ['github', 'google', 'twitter', 'linkedin', GITHUB_FOLLOW_PLATFORM]; if (!SUPPORTED_PLATFORMS.includes(platform)) { return reply.status(400).send({ error: `Unsupported platform: ${platform}` }); } try { - await app.prisma.oAuthToken.delete({ - where: { - userId_platform: { + if (platform === 'github') { + // When disconnecting GitHub, also remove the follow-capable token if it exists + const result = await app.prisma.oAuthToken.deleteMany({ + where: { userId, - platform, + platform: { in: ['github', GITHUB_FOLLOW_PLATFORM] }, }, - }, - }); + }); + + if (result.count === 0) { + return reply.status(404).send({ error: 'Connection not found' }); + } + } else { + await app.prisma.oAuthToken.delete({ + where: { + userId_platform: { + userId, + platform, + }, + }, + }); + } return { success: true }; } catch (error) { return reply.status(404).send({ error: 'Connection not found' }); diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json index 80ef714d..5caaa2af 100644 --- a/apps/web/package-lock.json +++ b/apps/web/package-lock.json @@ -8,6 +8,7 @@ "name": "@devcard/web", "version": "0.1.0", "dependencies": { + "devcard": "file:../..", "react": "^19.2.6", "react-dom": "^19.2.6", "react-router-dom": "^7.6.2" @@ -27,6 +28,17 @@ "vite": "^8.0.12" } }, + "../..": { + "name": "devcard", + "version": "1.0.0", + "license": "Apache-2.0", + "devDependencies": { + "concurrently": "^9.2.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -1377,6 +1389,10 @@ "node": ">=8" } }, + "node_modules/devcard": { + "resolved": "../..", + "link": true + }, "node_modules/electron-to-chromium": { "version": "1.5.366", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.366.tgz", diff --git a/apps/web/package.json b/apps/web/package.json index 8df03ce6..828784fe 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "devcard": "file:../..", "react": "^19.2.6", "react-dom": "^19.2.6", "react-router-dom": "^7.6.2" @@ -28,4 +29,4 @@ "typescript-eslint": "^8.59.2", "vite": "^8.0.12" } -} \ No newline at end of file +} diff --git a/packages/shared/package-lock.json b/packages/shared/package-lock.json index 5374ca4b..d992f194 100644 --- a/packages/shared/package-lock.json +++ b/packages/shared/package-lock.json @@ -7,11 +7,25 @@ "": { "name": "@devcard/shared", "version": "1.0.0", + "dependencies": { + "devcard": "file:../.." + }, "devDependencies": { "typescript": "^5.4.0", "vitest": "^2.0.0" } }, + "../..": { + "name": "devcard", + "version": "1.0.0", + "license": "Apache-2.0", + "devDependencies": { + "concurrently": "^9.2.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -955,6 +969,10 @@ "node": ">=6" } }, + "node_modules/devcard": { + "resolved": "../..", + "link": true + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", diff --git a/packages/shared/package.json b/packages/shared/package.json index b3b3ac7e..f188ee4d 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -13,5 +13,8 @@ "devDependencies": { "typescript": "^5.4.0", "vitest": "^2.0.0" + }, + "dependencies": { + "devcard": "file:../.." } } From 321ae4bca29ecc1f19030e77ce58e8623cfd9a03 Mon Sep 17 00:00:00 2001 From: anirudhagarwal-dev Date: Tue, 9 Jun 2026 17:48:40 +0530 Subject: [PATCH 2/2] fix: resolve linting and code quality issues in backend and web apps --- apps/backend/src/__tests__/connect.test.ts | 4 +-- apps/backend/src/routes/connect.ts | 30 +++++++++-------- apps/web/src/lib/theme.tsx | 37 +++------------------ apps/web/src/lib/themeContext.tsx | 28 ++++++++++++++++ apps/web/src/main.tsx | 2 +- apps/web/src/pages/CardPage.tsx | 38 +++++++++++++++------- apps/web/src/pages/ProfilePage.tsx | 27 ++++++++++----- 7 files changed, 98 insertions(+), 68 deletions(-) create mode 100644 apps/web/src/lib/themeContext.tsx diff --git a/apps/backend/src/__tests__/connect.test.ts b/apps/backend/src/__tests__/connect.test.ts index e099c995..e2d5d50c 100644 --- a/apps/backend/src/__tests__/connect.test.ts +++ b/apps/backend/src/__tests__/connect.test.ts @@ -1,6 +1,6 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; import Fastify from 'fastify'; import jwt from '@fastify/jwt'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { connectRoutes } from '../routes/connect.js'; import type { PrismaClient } from '@prisma/client'; @@ -37,7 +37,7 @@ async function buildApp() { app.decorate('authenticate', async (request: any, reply: any) => { try { await request.jwtVerify(); - } catch (err) { + } catch (_err) { reply.status(401).send({ error: 'Unauthorized' }); } }); diff --git a/apps/backend/src/routes/connect.ts b/apps/backend/src/routes/connect.ts index 01e163ad..31cf5a7e 100644 --- a/apps/backend/src/routes/connect.ts +++ b/apps/backend/src/routes/connect.ts @@ -1,5 +1,7 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; -import { randomBytes } from 'crypto'; + +import { randomBytes } from 'node:crypto'; + import { encrypt } from '../utils/encryption.js'; const GITHUB_AUTH_URL = 'https://github.com/login/oauth/authorize'; @@ -30,9 +32,9 @@ export async function connectRoutes(app: FastifyInstance) { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } - try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } + try { await request.jwtVerify() } catch (_e) { reply.status(401).send({ error: 'Unauthorized' }) } }], - }, async (request: FastifyRequest, reply: FastifyReply) => { + }, async (request: FastifyRequest, _reply: FastifyReply) => { const userId = (request.user as any).id; const tokens = await app.prisma.oAuthToken.findMany({ @@ -50,7 +52,7 @@ export async function connectRoutes(app: FastifyInstance) { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } - try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } + try { await request.jwtVerify() } catch (_e) { reply.status(401).send({ error: 'Unauthorized' }) } }], }, async (request: FastifyRequest, reply: FastifyReply) => { const userId = (request.user as any).id; @@ -160,9 +162,9 @@ export async function connectRoutes(app: FastifyInstance) { return reply.redirect(`${process.env.PUBLIC_APP_URL}/settings?connected=github`); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - app.log.error({ error, message }, 'GitHub connect error'); + } catch (_error) { + const message = _error instanceof Error ? _error.message : String(_error); + app.log.error({ error: _error, message }, 'GitHub connect error'); return reply.redirect(`${process.env.PUBLIC_APP_URL}/settings?error=server_error`); } }); @@ -175,15 +177,15 @@ export async function connectRoutes(app: FastifyInstance) { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } - try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } + try { await request.jwtVerify() } catch (_e) { reply.status(401).send({ error: 'Unauthorized' }) } }], - }, async (request: FastifyRequest<{ Params: { platform: string } }>, reply: FastifyReply) => { + }, async (request: FastifyRequest<{ Params: { platform: string } }>, _reply: FastifyReply) => { const userId = (request.user as any).id; const { platform } = request.params; const SUPPORTED_PLATFORMS = ['github', 'google', 'twitter', 'linkedin', GITHUB_FOLLOW_PLATFORM]; if (!SUPPORTED_PLATFORMS.includes(platform)) { - return reply.status(400).send({ error: `Unsupported platform: ${platform}` }); + return _reply.status(400).send({ error: `Unsupported platform: ${platform}` }); } try { @@ -197,7 +199,7 @@ export async function connectRoutes(app: FastifyInstance) { }); if (result.count === 0) { - return reply.status(404).send({ error: 'Connection not found' }); + return _reply.status(404).send({ error: 'Connection not found' }); } } else { await app.prisma.oAuthToken.delete({ @@ -210,8 +212,8 @@ export async function connectRoutes(app: FastifyInstance) { }); } return { success: true }; - } catch (error) { - return reply.status(404).send({ error: 'Connection not found' }); + } catch (_error) { + return _reply.status(404).send({ error: 'Connection not found' }); } }); } @@ -225,7 +227,7 @@ function parseOAuthState(state: string): ParsedOAuthState | null { return null; } return decoded; - } catch { + } catch (_e) { return null; } } diff --git a/apps/web/src/lib/theme.tsx b/apps/web/src/lib/theme.tsx index 7beda8bd..a5d04ad1 100644 --- a/apps/web/src/lib/theme.tsx +++ b/apps/web/src/lib/theme.tsx @@ -1,25 +1,10 @@ -import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'; +import { useEffect } from 'react'; +import { useTheme as useThemeContext } from './themeContext'; 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 }) { - const [theme, setTheme] = useState(getInitialTheme); +export function useTheme() { + const { theme, toggleTheme } = useThemeContext(); useEffect(() => { const root = document.documentElement; @@ -31,17 +16,5 @@ export function ThemeProvider({ children }: { children: ReactNode }) { localStorage.setItem('devcard-theme', theme); }, [theme]); - const toggleTheme = () => setTheme((t) => (t === '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; + return { theme, toggleTheme }; } diff --git a/apps/web/src/lib/themeContext.tsx b/apps/web/src/lib/themeContext.tsx new file mode 100644 index 00000000..99b3568f --- /dev/null +++ b/apps/web/src/lib/themeContext.tsx @@ -0,0 +1,28 @@ +import { createContext, useContext, useState, type ReactNode } from 'react'; + +type Theme = 'light' | 'dark'; + +interface ThemeContextValue { + theme: Theme; + toggleTheme: () => void; +} + +const ThemeContext = createContext(null); + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [theme, setTheme] = useState('dark'); + + const toggleTheme = () => setTheme((t) => (t === '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; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 4bd26893..f6eb2d50 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,7 +1,7 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; -import { ThemeProvider } from './lib/theme'; +import { ThemeProvider } from './lib/themeContext'; import App from './App'; import './index.css'; diff --git a/apps/web/src/pages/CardPage.tsx b/apps/web/src/pages/CardPage.tsx index 690ce574..6a6f1263 100644 --- a/apps/web/src/pages/CardPage.tsx +++ b/apps/web/src/pages/CardPage.tsx @@ -24,17 +24,33 @@ export default function CardPage() { useEffect(() => { if (!id) return; - setLoading(true); - apiFetch(`/api/u/card/${id}`) - .then((data) => { - setCard(data); - setError(null); - }) - .catch(() => { - setCard(null); - setError('Card not found'); - }) - .finally(() => setLoading(false)); + + let isMounted = true; + + const loadCard = async () => { + try { + const data = await apiFetch(`/api/u/card/${id}`); + if (isMounted) { + setCard(data); + setError(null); + } + } catch { + if (isMounted) { + setCard(null); + setError('Card not found'); + } + } finally { + if (isMounted) { + setLoading(false); + } + } + }; + + loadCard(); + + return () => { + isMounted = false; + }; }, [id]); // Update document title diff --git a/apps/web/src/pages/ProfilePage.tsx b/apps/web/src/pages/ProfilePage.tsx index 94a84f54..b10e208d 100644 --- a/apps/web/src/pages/ProfilePage.tsx +++ b/apps/web/src/pages/ProfilePage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useRef } from 'react'; import { useParams, Link } from 'react-router-dom'; import { PLATFORMS, getProfileUrl } from '../shared'; import type { PublicProfile } from '../shared'; @@ -18,12 +18,15 @@ 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 [mounted] = useState(true); const [copyMessage, setCopyMessage] = useState(''); const [copyStatus, setCopyStatus] = useState<'success' | 'error'>('success'); + const mountedRef = useRef(true); useEffect(() => { - setMounted(true); + return () => { + mountedRef.current = false; + }; }, []); useEffect(() => { @@ -31,14 +34,22 @@ export default function ProfilePage() { setLoading(true); apiFetch(`/api/u/${username}?source=web`) .then((data) => { - setProfile(data); - setError(null); + if (mountedRef.current) { + setProfile(data); + setError(null); + } }) .catch(() => { - setProfile(null); - setError('User not found'); + if (mountedRef.current) { + setProfile(null); + setError('User not found'); + } }) - .finally(() => setLoading(false)); + .finally(() => { + if (mountedRef.current) { + setLoading(false); + } + }); }, [username]); async function copyProfileUrl() {