diff --git a/dashboard/app/api/v1/events/route.ts b/dashboard/app/api/v1/events/route.ts index f97719d..bd02f3d 100644 --- a/dashboard/app/api/v1/events/route.ts +++ b/dashboard/app/api/v1/events/route.ts @@ -12,7 +12,7 @@ import { apiError } from "@/server/api-error"; export const GET = withCors(async (req) => { let user; try { - user = requireUser(req); + user = await requireUser(req); } catch (err) { if (err instanceof UnauthorizedError) { return apiError("UNAUTHORIZED", err.message, 401); @@ -33,7 +33,7 @@ export const GET = withCors(async (req) => { export const POST = withCors(async (req) => { let user; try { - user = requireUser(req); + user = await requireUser(req); } catch (err) { if (err instanceof UnauthorizedError) { return apiError("UNAUTHORIZED", err.message, 401); diff --git a/dashboard/app/api/v1/events/stats/route.ts b/dashboard/app/api/v1/events/stats/route.ts index 949b991..e8e0671 100644 --- a/dashboard/app/api/v1/events/stats/route.ts +++ b/dashboard/app/api/v1/events/stats/route.ts @@ -8,7 +8,7 @@ import { apiError } from "@/server/api-error"; export const GET = withCors(async (req) => { let user; try { - user = requireUser(req); + user = await requireUser(req); } catch (err) { if (err instanceof UnauthorizedError) { return apiError("UNAUTHORIZED", err.message, 401); diff --git a/dashboard/app/api/v1/music/stats/route.ts b/dashboard/app/api/v1/music/stats/route.ts index b36abdc..45f367f 100644 --- a/dashboard/app/api/v1/music/stats/route.ts +++ b/dashboard/app/api/v1/music/stats/route.ts @@ -22,7 +22,7 @@ export const GET = withCors(async (req: NextRequest) => { if (cookie) { userId = cookie.id; } else { - userId = requireUser(req).id; + userId = (await requireUser(req)).id; } } catch (err) { if (err instanceof UnauthorizedError) { diff --git a/dashboard/app/api/v1/reports/generate/route.ts b/dashboard/app/api/v1/reports/generate/route.ts index 46b27e0..ce6803c 100644 --- a/dashboard/app/api/v1/reports/generate/route.ts +++ b/dashboard/app/api/v1/reports/generate/route.ts @@ -16,7 +16,7 @@ import { apiError } from "@/server/api-error"; export const POST = withCors(async (req) => { let user; try { - user = requireUser(req); + user = await requireUser(req); } catch (err) { if (err instanceof UnauthorizedError) { return apiError("UNAUTHORIZED", err.message, 401); diff --git a/dashboard/app/api/v1/sessions/[id]/route.ts b/dashboard/app/api/v1/sessions/[id]/route.ts index fd2afab..e797199 100644 --- a/dashboard/app/api/v1/sessions/[id]/route.ts +++ b/dashboard/app/api/v1/sessions/[id]/route.ts @@ -13,7 +13,7 @@ export function PATCH(req: NextRequest, ctx: RouteContext): Promise { let user; try { - user = requireUser(r); + user = await requireUser(r); } catch (err) { if (err instanceof UnauthorizedError) { return apiError("UNAUTHORIZED", err.message, 401); diff --git a/dashboard/app/api/v1/sessions/route.ts b/dashboard/app/api/v1/sessions/route.ts index 002ce97..8f47a45 100644 --- a/dashboard/app/api/v1/sessions/route.ts +++ b/dashboard/app/api/v1/sessions/route.ts @@ -7,7 +7,7 @@ import { apiError } from "@/server/api-error"; export const POST = withCors(async (req) => { let user; try { - user = requireUser(req); + user = await requireUser(req); } catch (err) { if (err instanceof UnauthorizedError) { return apiError("UNAUTHORIZED", err.message, 401); diff --git a/dashboard/app/api/v1/sessions/top/route.ts b/dashboard/app/api/v1/sessions/top/route.ts index 0b5dc38..f0e1119 100644 --- a/dashboard/app/api/v1/sessions/top/route.ts +++ b/dashboard/app/api/v1/sessions/top/route.ts @@ -7,7 +7,7 @@ import { apiError } from "@/server/api-error"; export const GET = withCors(async (req) => { let user; try { - user = requireUser(req); + user = await requireUser(req); } catch (err) { if (err instanceof UnauthorizedError) { return apiError("UNAUTHORIZED", err.message, 401); diff --git a/dashboard/app/api/v1/tracks/[id]/route.ts b/dashboard/app/api/v1/tracks/[id]/route.ts index 7e6a607..4d9bf39 100644 --- a/dashboard/app/api/v1/tracks/[id]/route.ts +++ b/dashboard/app/api/v1/tracks/[id]/route.ts @@ -13,7 +13,7 @@ export function PATCH(req: NextRequest, ctx: RouteContext): Promise { let user; try { - user = requireUser(r); + user = await requireUser(r); } catch (err) { if (err instanceof UnauthorizedError) { return apiError("UNAUTHORIZED", err.message, 401); diff --git a/dashboard/app/api/v1/tracks/route.ts b/dashboard/app/api/v1/tracks/route.ts index cc8faf6..f25c5a8 100644 --- a/dashboard/app/api/v1/tracks/route.ts +++ b/dashboard/app/api/v1/tracks/route.ts @@ -8,7 +8,7 @@ import { apiError } from "@/server/api-error"; export const POST = withCors(async (req) => { let user; try { - user = requireUser(req); + user = await requireUser(req); } catch (err) { if (err instanceof UnauthorizedError) { return apiError("UNAUTHORIZED", err.message, 401); diff --git a/dashboard/app/api/v1/user/settings/route.ts b/dashboard/app/api/v1/user/settings/route.ts index 6b36ee9..8085a48 100644 --- a/dashboard/app/api/v1/user/settings/route.ts +++ b/dashboard/app/api/v1/user/settings/route.ts @@ -8,7 +8,7 @@ import { apiError } from "@/server/api-error"; export const GET = withCors(async (req) => { let user; try { - user = requireUser(req); + user = await requireUser(req); } catch (err) { if (err instanceof UnauthorizedError) { return apiError("UNAUTHORIZED", err.message, 401); @@ -22,7 +22,7 @@ export const GET = withCors(async (req) => { export const PUT = withCors(async (req) => { let user; try { - user = requireUser(req); + user = await requireUser(req); } catch (err) { if (err instanceof UnauthorizedError) { return apiError("UNAUTHORIZED", err.message, 401); diff --git a/dashboard/app/dashboard/logout-button.tsx b/dashboard/app/dashboard/logout-button.tsx index a77a994..0f3854e 100644 --- a/dashboard/app/dashboard/logout-button.tsx +++ b/dashboard/app/dashboard/logout-button.tsx @@ -2,9 +2,11 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; +import { useQueryClient } from "@tanstack/react-query"; export function LogoutButton() { - const router = useRouter(); + const router = useRouter(); + const queryClient = useQueryClient(); const [pending, setPending] = useState(false); return ( @@ -16,6 +18,7 @@ export function LogoutButton() { try { await fetch("/api/auth/logout", { method: "POST" }); } finally { + queryClient.clear(); router.replace("/login"); router.refresh(); } diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 1d01aa8..7907e85 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -16,7 +16,6 @@ "framer-motion": "^12.38.0", "google-auth-library": "^10.6.2", "jose": "^6.2.3", - "jsonwebtoken": "^9.0.3", "next": "16.2.6", "pg": "^8.20.0", "prisma": "^7.8.0", @@ -30,7 +29,6 @@ "devDependencies": { "@tailwindcss/postcss": "^4", "@types/d3": "^7.4.3", - "@types/jsonwebtoken": "^9.0.10", "@types/node": "^20", "@types/pg": "^8.20.0", "@types/react": "^19", @@ -2509,17 +2507,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/jsonwebtoken": { - "version": "9.0.10", - "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", - "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/ms": "*", - "@types/node": "*" - } - }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -7044,40 +7031,6 @@ "graceful-fs": "^4.1.6" } }, - "node_modules/jsonwebtoken": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", - "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", - "license": "MIT", - "dependencies": { - "jws": "^4.0.1", - "lodash.includes": "^4.3.0", - "lodash.isboolean": "^3.0.3", - "lodash.isinteger": "^4.0.4", - "lodash.isnumber": "^3.0.3", - "lodash.isplainobject": "^4.0.6", - "lodash.isstring": "^4.0.1", - "lodash.once": "^4.0.0", - "ms": "^2.1.1", - "semver": "^7.5.4" - }, - "engines": { - "node": ">=12", - "npm": ">=6" - } - }, - "node_modules/jsonwebtoken/node_modules/semver": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", - "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -7446,42 +7399,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash.includes": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", - "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", - "license": "MIT" - }, - "node_modules/lodash.isboolean": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", - "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", - "license": "MIT" - }, - "node_modules/lodash.isinteger": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", - "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", - "license": "MIT" - }, - "node_modules/lodash.isnumber": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", - "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", - "license": "MIT" - }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "license": "MIT" - }, - "node_modules/lodash.isstring": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", - "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", - "license": "MIT" - }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -7489,12 +7406,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.once": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", - "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", - "license": "MIT" - }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", diff --git a/dashboard/package.json b/dashboard/package.json index 868efa3..d9e9b64 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -17,7 +17,6 @@ "framer-motion": "^12.38.0", "google-auth-library": "^10.6.2", "jose": "^6.2.3", - "jsonwebtoken": "^9.0.3", "next": "16.2.6", "pg": "^8.20.0", "prisma": "^7.8.0", @@ -31,7 +30,6 @@ "devDependencies": { "@tailwindcss/postcss": "^4", "@types/d3": "^7.4.3", - "@types/jsonwebtoken": "^9.0.10", "@types/node": "^20", "@types/pg": "^8.20.0", "@types/react": "^19", diff --git a/dashboard/server/auth.ts b/dashboard/server/auth.ts index 2e4e307..67d8e12 100644 --- a/dashboard/server/auth.ts +++ b/dashboard/server/auth.ts @@ -1,5 +1,5 @@ import { OAuth2Client } from "google-auth-library"; -import jwt from "jsonwebtoken"; +import { SignJWT } from "jose"; import { prisma } from "@/server/db"; function requireEnv(name: string): string { @@ -41,13 +41,14 @@ async function upsertUser(data: { }); } -function issueJWT(userId: string, email: string): string { - const expiresIn = (process.env.JWT_EXPIRES_IN ?? "7d") as jwt.SignOptions["expiresIn"]; - return jwt.sign( - { sub: userId, email }, - requireEnv("JWT_SECRET"), - { expiresIn }, - ); +async function issueJWT(userId: string, email: string): Promise { + const secret = requireEnv("JWT_SECRET"); + const expiresIn = process.env["JWT_EXPIRES_IN"] ?? "7d"; + return new SignJWT({ sub: userId, email }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setExpirationTime(expiresIn) + .sign(new TextEncoder().encode(secret)); } export async function authenticateGoogleUser(googleIdToken: string): Promise { diff --git a/dashboard/server/jwt.ts b/dashboard/server/jwt.ts index 9dbae38..6457e20 100644 --- a/dashboard/server/jwt.ts +++ b/dashboard/server/jwt.ts @@ -1,5 +1,4 @@ import type { NextRequest } from "next/server"; -import jwt from "jsonwebtoken"; import { jwtVerify } from "jose"; import { SESSION_COOKIE } from "@/server/cookies"; @@ -21,13 +20,16 @@ function isPayload(v: unknown): v is JwtPayload { return typeof r["sub"] === "string" && typeof r["email"] === "string"; } -export async function verifyJwt(token: string): Promise { - const secret = process.env.JWT_SECRET; +function getSecretKey(): Uint8Array { + const secret = process.env["JWT_SECRET"]; if (!secret) throw new Error("JWT_SECRET is not set"); + return new TextEncoder().encode(secret); +} +export async function verifyJwt(token: string): Promise { let payload: unknown; try { - const result = await jwtVerify(token, new TextEncoder().encode(secret)); + const result = await jwtVerify(token, getSecretKey()); payload = result.payload; } catch { throw new UnauthorizedError("Invalid or expired token"); @@ -45,20 +47,9 @@ function extractToken(req: NextRequest): string | null { return req.cookies.get(SESSION_COOKIE)?.value ?? null; } -export function requireUser(req: NextRequest): { id: string; email: string } { +export async function requireUser(req: NextRequest): Promise<{ id: string; email: string }> { const token = extractToken(req); if (!token) throw new UnauthorizedError("Missing authentication credentials"); - - const secret = process.env.JWT_SECRET; - if (!secret) throw new Error("JWT_SECRET is not set"); - - let decoded: unknown; - try { - decoded = jwt.verify(token, secret); - } catch { - throw new UnauthorizedError("Invalid or expired token"); - } - if (!isPayload(decoded)) throw new UnauthorizedError("Invalid token payload"); - - return { id: decoded.sub, email: decoded.email }; + const payload = await verifyJwt(token); + return { id: payload.sub, email: payload.email }; } diff --git a/plans/backlog/email-password-auth.md b/plans/backlog/email-password-auth.md deleted file mode 100644 index 43dd200..0000000 --- a/plans/backlog/email-password-auth.md +++ /dev/null @@ -1,134 +0,0 @@ -# Backlog: Email + Password Auth (альтернатива Google OAuth) - -**Пріоритет:** обов'язково, після Week 2–3 -**Залежить від:** Week 3 AC 1 (middleware + JWT вже є) -**Статус:** не розпочато - ---- - -## Мета - -Дати можливість реєструватись і логінитись через email + пароль без Google акаунту. Один акаунт на один email. Реєстрація підтверджується листом на пошту. - ---- - -## Scope - -### Dashboard (Next.js) - -**Нові Route Handlers:** - -| Endpoint | Що робить | -| -------------------------------- | ---------------------------------------------------------------------------- | -| `POST /api/auth/register` | Реєстрація: email + password → хеш → User у БД → надсилає verification email | -| `POST /api/auth/verify-email` | Приймає токен з листа → позначає email як підтверджений | -| `POST /api/auth/login` | email + password → перевіряє хеш → видає JWT | -| `POST /api/auth/login/extension` | Те саме але для extension (повертає JWT в тілі відповіді) | - -**Зміни в Prisma-схемі:** -```prisma -model User { - // ... існуючі поля - passwordHash String? // null якщо реєстрація через Google - emailVerified Boolean @default(false) - verificationToken String? // одноразовий токен для підтвердження - verificationExp DateTime? // термін дії токену -} -``` -→ потрібна міграція - -**Нові server-модулі:** -- `dashboard/server/password.ts` — `hashPassword`, `verifyPassword` (bcrypt або argon2) -- `dashboard/server/mailer.ts` — SMTP клієнт (nodemailer), `sendVerificationEmail` - -### Extension - -Альтернативний UI в popup: форма email + password замість (або поруч з) "Sign in with Google". - ---- - -## Технічний стек - -| Що | Пакет | -| ----------------- | ------------------------------------------- | -| Хешування паролів | `bcryptjs` (pure JS, без native deps) | -| SMTP | `nodemailer` | -| Email-провайдер | Mailtrap (dev) / Resend або SendGrid (prod) | - ---- - -## SMTP — що треба налаштувати - -**Dev (Mailtrap):** -```env -SMTP_HOST=sandbox.smtp.mailtrap.io -SMTP_PORT=2525 -SMTP_USER= -SMTP_PASS= -SMTP_FROM=noreply@worktrace.dev -``` -Mailtrap перехоплює всі листи — не відправляє реальним юзерам. Безпечно для розробки. - -**Prod (Resend — безкоштовний tier: 3000 листів/міс):** -```env -SMTP_HOST=smtp.resend.com -SMTP_PORT=465 -SMTP_USER=resend -SMTP_PASS= -``` - ---- - -## Сценарій: реєстрація з підтвердженням email - -``` -1. Юзер вводить email + password → POST /api/auth/register -2. Сервер: bcrypt.hash(password) → зберігає User { emailVerified: false, verificationToken: uuid, verificationExp: now+24h } -3. Сервер: nodemailer надсилає лист з посиланням: - https://worktrace.app/api/auth/verify-email?token= -4. Юзер клікає посилання → GET /api/auth/verify-email?token=... -5. Сервер: перевіряє token + exp → User.emailVerified = true, видаляє token -6. Юзер логіниться: POST /api/auth/login → email + password → bcrypt.compare → JWT -``` - ---- - -## Сценарій: логін в extension - -``` -1. Popup показує форму: email + password -2. Юзер вводить → popup надсилає NOTE_ADD (або новий тип) до background -3. Background: POST /api/auth/login/extension { email, password } -4. Dashboard: верифікує → повертає { token: JWT } -5. Background: зберігає JWT в chrome.storage.local (той самий формат що і Google OAuth) -``` - ---- - -## Зміни в popup - -Додати вкладки або toggle: -``` -[Google] [Email] -───────────────── -Email: [__________] -Password: [__________] - [Sign In] - [Register] -``` - ---- - -## Out of scope для цього backlog-item - -- Forgot password / reset (окремий backlog) -- 2FA -- OAuth провайдери крім Google -- Rate limiting на auth endpoints (хоча бажано) - ---- - -## Коли робити - -Після Week 3 (коли middleware і JWT flow вже стабільні). Не блокує Week 2–3. -Орієнтовно: між Week 3 і Week 4, або як окремий PR після Week 4. diff --git a/plans/backlog/post-week3-tech-debt.md b/plans/backlog/post-week3-tech-debt.md deleted file mode 100644 index f83df6f..0000000 --- a/plans/backlog/post-week3-tech-debt.md +++ /dev/null @@ -1,75 +0,0 @@ -# Backlog: Tech debt from Week 3 AC 2 review - -**Пріоритет:** low/medium, ергономіка та чистота коду -**Залежить від:** Week 3 AC 2 (merged) -**Статус:** не розпочато - ---- - -## Контекст - -Під час self-review гілки `feature/dashboard-event-feed` виявлено два пункти, які доцільно зробити **поза цією гілкою** — бо вони зачіпають код, що написаний раніше, або міняють контракт відразу в кількох роутах. Свідомо відкладено, щоб PR залишився сфокусованим на AC 2. - ---- - -## 1. JWT-bibліотека уніфікація — `jose` всюди - -### Зараз -- `dashboard/server/jwt.ts` має **дві** функції верифікації: - - `verifyJwt(token)` — на `jose`, async, працює і в edge (middleware), і у Node (Server Components) - - `requireUser(req)` — на `jsonwebtoken`, sync, Node-only, використовується у всіх Route Handler'ах (`/api/v1/events`, `/api/v1/sessions`, `/api/v1/tracks`) -- Один і той самий HS256 секрет валідується двома різними бібліотеками. Це працює (HMAC-SHA256 — стандарт), але: - - `jsonwebtoken` + `@types/jsonwebtoken` — зайва runtime + типова залежність - - дві шляхи верифікації — два місця де треба поправити при зміні алгоритму чи rotation logic - - issue/sign все одно на `jsonwebtoken` (`server/auth.ts:issueJWT`) — половинчасто - -### Бажано -- `requireUser(req)` стає `async`, всередині викликає `verifyJwt(token)` з `jose` -- Усі route handlers додають `await` (вони вже `async`) -- `issueJWT` переписується на `new SignJWT(...)` з `jose` -- `jsonwebtoken` + `@types/jsonwebtoken` видаляються з `package.json` - -### Ризик -- Низький: підпис байт-у-байт сумісний (HS256 над тим самим секретом) -- Існуючий extension з валідним токеном залишається працювати — нічого не ротуємо -- Тестується через існуючий `POST /api/v1/events` з Bearer токеном extension'а - -### Декомпозиція -``` -1. refactor(server): port issueJWT and requireUser to jose -2. chore(deps): remove jsonwebtoken and @types/jsonwebtoken -3. test: regression — Bearer-flow from extension, cookie-flow from dashboard -``` - ---- - -## 2. QueryClient `.clear()` після logout - -### Зараз -- `dashboard/app/dashboard/logout-button.tsx` шле `POST /api/auth/logout` → `router.replace("/login")` + `router.refresh()` -- `QueryClient` живе у `dashboard/app/providers.tsx` через `useState(() => new QueryClient(...))` — це singleton поки tab live -- Якщо user A вийшов, user B зайшов **у тій самій вкладці без перезавантаження** — TanStack кеш не очищується. user B на момент першого пасивного рендеру може побачити кеш від user A (доки `staleTime` не вийшов) - -На практиці `router.refresh()` робить full Server Components re-render → новий prefetch → новий hydrate → старий кеш переписується. Але: -- Перед першим прибуттям свіжих даних може спалахнути stale UI (на 50-200 мс) -- TanStack-кеш для `["top-sessions"]` теж зберігається, бо queryKey не залежить від userId - -### Бажано -- У `LogoutButton.onClick` додати `queryClient.clear()` (через `useQueryClient()`) перед `router.replace` -- Або (стійкіше): мати у QueryClient subscriber на `wt_session` cookie change і автоматично clear - -### Ризик -- Дуже низький — це фронт-only -- Один новий рядок коду - -### Декомпозиція -``` -1. fix(dashboard): clear tanstack cache on logout -``` - ---- - -## Чому не зробили зараз - -- **JWT**: зачіпає 4+ route handlers поза scope AC 2. PR має бути сфокусованим на стрічці подій. Окремий PR `chore: jwt unification` рев'юється легше і не блокує feature merge. -- **QueryClient clear**: код у `logout-button.tsx` фактично не змінений у цій гілці (тільки стиль кнопки). Логіка logout — це AC 1 territory. Краще додати окремою fix-гілкою щоб не розмивати focus AC 2. diff --git a/plans/week4/jwt-unification-and-logout-cache.md b/plans/week4/jwt-unification-and-logout-cache.md new file mode 100644 index 0000000..381ee36 --- /dev/null +++ b/plans/week4/jwt-unification-and-logout-cache.md @@ -0,0 +1,79 @@ +# Week 4: JWT unification + logout cache clear + +**Пріоритет:** medium +**Залежить від:** Week 3 AC 1 (auth middleware + JWT), Week 3 AC 2 (event feed, logout button) +**Статус:** не розпочато +**Гілка:** `chore/jwt-unification` + +--- + +## Контекст + +Під час self-review гілки `feature/dashboard-event-feed` виявлено два пункти, свідомо відкладені щоб PR залишився сфокусованим. Обидва зачіпають код за межами тієї гілки. + +--- + +## 1. JWT уніфікація — `jose` всюди + +### Зараз + +`dashboard/server/jwt.ts` має **дві** функції верифікації: +- `verifyJwt(token)` — на `jose`, async, працює в edge (middleware) і Node +- `requireUser(req)` — на `jsonwebtoken`, sync, Node-only, використовується у всіх Route Handler'ах + +`issueJWT` у `server/auth.ts` теж на `jsonwebtoken`. Результат: один HS256 секрет обслуговується двома різними бібліотеками. + +**Проблеми:** +- `jsonwebtoken` + `@types/jsonwebtoken` — зайві залежності +- два місця де треба правити при зміні алгоритму або rotation logic + +### Після рефактору + +- `issueJWT` → `new SignJWT(...).sign(...)` з `jose` +- `requireUser(req)` → async, викликає існуючий `verifyJwt(token)` з `jose` +- Усі Route Handlers де є `requireUser` — вже `async`, просто додається `await` +- `jsonwebtoken` + `@types/jsonwebtoken` видаляються + +**Ризик:** низький. HS256 підпис байт-у-байт сумісний між бібліотеками. Існуючі токени в extension залишаються валідними — нічого не ротуємо. + +--- + +## 2. `queryClient.clear()` після logout + +### Зараз + +`LogoutButton` → `POST /api/auth/logout` → `router.replace("/login")` + `router.refresh()`. + +`QueryClient` — singleton поки tab жива. Якщо user A вийшов і user B зайшов у тій самій вкладці — TanStack кеш не очищується. До першого рефетчу user B може побачити дані user A (50–200 мс спалах). + +### Після фіксу + +`queryClient.clear()` через `useQueryClient()` перед `router.replace` — один рядок. + +**Ризик:** дуже низький, фронт-only. + +--- + +## Декомпозиція комітів + +``` +1. docs(plans): move post-week3 tech debt plan to week4 +2. refactor(server): port issueJWT and requireUser to jose +3. chore(deps): remove jsonwebtoken and @types/jsonwebtoken +4. fix(dashboard): clear TanStack cache on logout +5. fix: post-manual-test corrections (після ручного тесту від юзера) +``` + +**Коміт 1** — тільки цей план, без коду. +**Коміти 2–4** — реалізація, typecheck і lint чисті перед кожним. +**Коміт 5** — резервний після ручного тесту: Bearer flow з extension, cookie flow з дашборду, logout з двох акаунтів у одній вкладці. + +--- + +## Ручний тест (перед комітом 5) + +1. Extension: відправити `POST /api/v1/events` з Bearer токеном → `201` +2. Dashboard: зайти через Google → відкрити `/dashboard` → дані є +3. Dashboard: натиснути Logout → зайти іншим акаунтом → переконатись що старих даних не видно +4. `npx tsc --noEmit` (dashboard) — чисто +5. `npm run lint` (dashboard) — чисто