From 2cd5e134a49f32bb146d04ca13a26f23cbbf75b5 Mon Sep 17 00:00:00 2001 From: Bohdan Date: Wed, 20 May 2026 22:42:02 +0300 Subject: [PATCH 01/18] chore(prisma): add AuthIdentity, EmailToken, emailVerified Split provider/providerId into AuthIdentity for multi-provider support. Add EmailToken for verification flows. Backfill existing users as verified. --- .../migration.sql | 54 +++++++++++++++++++ apps/backend/prisma/schema.prisma | 44 +++++++++++---- 2 files changed, 89 insertions(+), 9 deletions(-) create mode 100644 apps/backend/prisma/migrations/20260520120000_add_auth_identity_and_email_verification/migration.sql diff --git a/apps/backend/prisma/migrations/20260520120000_add_auth_identity_and_email_verification/migration.sql b/apps/backend/prisma/migrations/20260520120000_add_auth_identity_and_email_verification/migration.sql new file mode 100644 index 0000000..97e4edd --- /dev/null +++ b/apps/backend/prisma/migrations/20260520120000_add_auth_identity_and_email_verification/migration.sql @@ -0,0 +1,54 @@ +-- Ensure gen_random_uuid() is available for the backfill below (no-op on PG >=13) +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +-- AlterTable: add emailVerified to User +ALTER TABLE "User" ADD COLUMN "emailVerified" BOOLEAN NOT NULL DEFAULT false; + +-- CreateTable: AuthIdentity +CREATE TABLE "AuthIdentity" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "providerId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AuthIdentity_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "AuthIdentity_provider_providerId_key" ON "AuthIdentity"("provider", "providerId"); +CREATE UNIQUE INDEX "AuthIdentity_userId_provider_key" ON "AuthIdentity"("userId", "provider"); +CREATE INDEX "AuthIdentity_userId_idx" ON "AuthIdentity"("userId"); + +ALTER TABLE "AuthIdentity" ADD CONSTRAINT "AuthIdentity_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- CreateTable: EmailToken +CREATE TABLE "EmailToken" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "tokenHash" TEXT NOT NULL, + "purpose" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "consumedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "EmailToken_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "EmailToken_tokenHash_key" ON "EmailToken"("tokenHash"); +CREATE INDEX "EmailToken_userId_purpose_idx" ON "EmailToken"("userId", "purpose"); + +ALTER TABLE "EmailToken" ADD CONSTRAINT "EmailToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Backfill: copy existing User.provider/providerId into AuthIdentity +INSERT INTO "AuthIdentity" ("id", "userId", "provider", "providerId", "createdAt") +SELECT gen_random_uuid(), "id", "provider", "providerId", CURRENT_TIMESTAMP +FROM "User"; + +-- Backfill: mark all pre-existing users with an email as verified +-- (legacy data is trusted; new registrations will require explicit verification) +UPDATE "User" SET "emailVerified" = true WHERE "email" IS NOT NULL; + +-- Drop legacy provider columns and their index (data already migrated above) +DROP INDEX "User_providerId_key"; +ALTER TABLE "User" DROP COLUMN "provider"; +ALTER TABLE "User" DROP COLUMN "providerId"; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index c03786a..02521ea 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -48,14 +48,41 @@ model Wallet { } model User { - id String @id @default(uuid()) - login String @unique - password String? - email String? @unique - provider String @default("local") - providerId String? @unique - wallets Wallet[] - refreshTokens RefreshToken[] + id String @id @default(uuid()) + login String @unique + password String? + email String? @unique + emailVerified Boolean @default(false) + wallets Wallet[] + refreshTokens RefreshToken[] + authIdentities AuthIdentity[] + emailTokens EmailToken[] +} + +model AuthIdentity { + id String @id @default(uuid()) + userId String + provider String + providerId String? + createdAt DateTime @default(now()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([provider, providerId]) + @@unique([userId, provider]) + @@index([userId]) +} + +model EmailToken { + id String @id @default(uuid()) + userId String + tokenHash String @unique + purpose String + expiresAt DateTime + consumedAt DateTime? + createdAt DateTime @default(now()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, purpose]) } model RefreshToken { @@ -80,4 +107,3 @@ model SwapSettings { stableCoins String[] @default(["usdt", "usdc"]) wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade) } - From 820cf3c6745aabb640ef0e1832ae6983cb2f7c9f Mon Sep 17 00:00:00 2001 From: Bohdan Date: Wed, 20 May 2026 22:56:51 +0300 Subject: [PATCH 02/18] chore(backend): add nodemailer email service with templates Lazy SMTP via env vars, jsonTransport fallback when credentials are missing. In-memory capture for tests; jest.setup resets it between tests. --- apps/backend/.env.example | 7 + apps/backend/package.json | 2 + apps/backend/src/services/emailService.ts | 208 ++++++++++++++++++++++ apps/backend/tests/setup/jest.setup.cjs | 5 + package-lock.json | 20 +++ 5 files changed, 242 insertions(+) create mode 100644 apps/backend/src/services/emailService.ts diff --git a/apps/backend/.env.example b/apps/backend/.env.example index 08bf2a8..146f710 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -26,3 +26,10 @@ COOKIE_SAME_SITE=lax GOOGLE_CLIENT_ID=your_google_client_id.apps.googleusercontent.com GOOGLE_CLIENT_SECRET=your_google_client_secret GOOGLE_REDIRECT_URI=http://localhost:4000/api/auth/google/callback + +# SMTP — email verification (Gmail example) +SMTP_HOST="smtp.gmail.com" +SMTP_PORT="587" +SMTP_USER="your@gmail.com" +SMTP_PASS="your_app_password" #https://myaccount.google.com/apppasswords +SMTP_FROM="CoinRadar " \ No newline at end of file diff --git a/apps/backend/package.json b/apps/backend/package.json index e6b2fe7..c4342d3 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -33,10 +33,12 @@ "@types/express": "^5.0.5", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^24.10.0", + "@types/nodemailer": "^8.0.0", "bcrypt": "^6.0.0", "cors": "^2.8.5", "express": "^5.1.0", "jsonwebtoken": "^9.0.2", + "nodemailer": "^8.0.7", "pg": "^8.21.0", "prisma": "^7.8.0", "typescript": "^5.9.3", diff --git a/apps/backend/src/services/emailService.ts b/apps/backend/src/services/emailService.ts new file mode 100644 index 0000000..ce52b4b --- /dev/null +++ b/apps/backend/src/services/emailService.ts @@ -0,0 +1,208 @@ +import nodemailer, { type Transporter } from "nodemailer"; + +const SMTP_HOST = process.env.SMTP_HOST; +const SMTP_PORT = Number(process.env.SMTP_PORT || 587); +const SMTP_USER = process.env.SMTP_USER; +const SMTP_PASS = process.env.SMTP_PASS; +const SMTP_FROM = + process.env.SMTP_FROM || "CoinRadar "; +const API_PUBLIC_URL = process.env.API_PUBLIC_URL || "http://localhost:4000"; +const FRONTEND_URL = process.env.FRONTEND_URL || "http://localhost:5173"; + +export type EmailPurpose = + | "verify_email" + | "merge_google" + | "delete_account" + | "one_time_password"; + +export interface SentEmailRecord { + to: string; + subject: string; + purpose: EmailPurpose; + token?: string; + otp?: string; + text: string; + html: string; + sentAt: Date; +} + +const captured: SentEmailRecord[] = []; + +let transporter: Transporter | null = null; + +const getTransporter = (): Transporter => { + if (transporter) return transporter; + + if (SMTP_HOST && SMTP_USER && SMTP_PASS) { + transporter = nodemailer.createTransport({ + host: SMTP_HOST, + port: SMTP_PORT, + secure: SMTP_PORT === 465, + auth: { user: SMTP_USER, pass: SMTP_PASS }, + }); + } else { + transporter = nodemailer.createTransport({ jsonTransport: true }); + if (process.env.NODE_ENV !== "test") { + console.warn( + "[emailService] SMTP_* env vars not set — using jsonTransport. Emails will be logged, not delivered.", + ); + } + } + + return transporter; +}; + +interface DispatchArgs { + to: string; + subject: string; + purpose: EmailPurpose; + text: string; + html: string; + token?: string; + otp?: string; +} + +const dispatch = async (args: DispatchArgs) => { + const { to, subject, purpose, text, html, token, otp } = args; + const info = await getTransporter().sendMail({ + from: SMTP_FROM, + to, + subject, + text, + html, + }); + + if (process.env.NODE_ENV === "test") { + captured.push({ + to, + subject, + purpose, + text, + html, + sentAt: new Date(), + ...(token !== undefined && { token }), + ...(otp !== undefined && { otp }), + }); + } else if (!SMTP_HOST) { + console.info(`[emailService][${purpose}] -> ${to}\n${text}`); + } + + return info; +}; + +const wrapHtml = (title: string, body: string): string => ` + +

${title}

+${body} +
+

CoinRadar — automated message. Do not reply.

+`; + +export const sendVerificationEmail = async (to: string, token: string) => { + const link = `${API_PUBLIC_URL}/api/auth/verify-email?token=${encodeURIComponent(token)}`; + const subject = "Confirm your CoinRadar email"; + const text = `Welcome to CoinRadar! + +Click the link to confirm your email and activate your account: + +${link} + +The link expires in 24 hours. If you did not register, ignore this email.`; + const html = wrapHtml( + "Confirm your email", + `

Welcome to CoinRadar!

+

Click the button to confirm your email and activate your account:

+

Confirm email

+

Or open this link:
${link}

+

The link expires in 24 hours. If you did not register, ignore this email.

`, + ); + + return dispatch({ to, subject, purpose: "verify_email", text, html, token }); +}; + +export const sendGoogleMergeConfirmationEmail = async ( + to: string, + token: string, +) => { + const link = `${API_PUBLIC_URL}/api/auth/verify-merge?token=${encodeURIComponent(token)}`; + const subject = "Confirm linking Google to your CoinRadar account"; + const text = `Someone tried to sign in with Google using this email address. + +If that was you, confirm the link to attach Google to your existing CoinRadar account: + +${link} + +The link expires in 1 hour. If it was not you, ignore this email — nothing will change.`; + const html = wrapHtml( + "Link Google to your CoinRadar account", + `

Someone tried to sign in with Google using this email address.

+

If that was you, confirm to attach Google to your existing CoinRadar account. You will still be able to sign in with your password.

+

Link Google

+

Or open this link:
${link}

+

The link expires in 1 hour. If it was not you, ignore this email — nothing will change.

`, + ); + + return dispatch({ to, subject, purpose: "merge_google", text, html, token }); +}; + +export const sendAccountDeletionEmail = async (to: string, token: string) => { + const link = `${FRONTEND_URL}?auth=delete_confirm&token=${encodeURIComponent(token)}`; + const subject = "Confirm deletion of your CoinRadar account"; + const text = `You requested permanent deletion of your CoinRadar account. + +Confirm by opening the link below. This is irreversible — all wallets and transactions will be removed: + +${link} + +The link expires in 1 hour. If you did not request deletion, ignore this email and your account stays intact.`; + const html = wrapHtml( + "Confirm account deletion", + `

You requested permanent deletion of your CoinRadar account.

+

This is irreversible. All wallets and transactions will be removed.

+

Confirm deletion

+

Or open this link:
${link}

+

The link expires in 1 hour. If you did not request deletion, ignore this email and your account stays intact.

`, + ); + + return dispatch({ + to, + subject, + purpose: "delete_account", + text, + html, + token, + }); +}; + +export const sendOneTimePasswordEmail = async (to: string, otp: string) => { + const subject = "Your CoinRadar one-time password"; + const text = `Your temporary CoinRadar password: ${otp} + +Use it once to sign in, then change it from your account settings. If you did not request this, ignore this email and consider rotating your account password.`; + const html = wrapHtml( + "Your one-time password", + `

Your temporary CoinRadar password:

+

${otp}

+

Use it once to sign in, then change it from your account settings.

+

If you did not request this, ignore this email and consider rotating your account password.

`, + ); + + return dispatch({ + to, + subject, + purpose: "one_time_password", + text, + html, + otp, + }); +}; + +// Test helpers — only meaningful when NODE_ENV === "test". +// Kept in production builds because tree-shaking is not configured for the backend. +export const __getCapturedEmails = (): readonly SentEmailRecord[] => captured; +export const __resetCapturedEmails = (): void => { + captured.length = 0; +}; +export const __resetTransporterForTests = (): void => { + transporter = null; +}; diff --git a/apps/backend/tests/setup/jest.setup.cjs b/apps/backend/tests/setup/jest.setup.cjs index 066094b..18accd5 100644 --- a/apps/backend/tests/setup/jest.setup.cjs +++ b/apps/backend/tests/setup/jest.setup.cjs @@ -13,6 +13,11 @@ if (!process.env.DATABASE_URL) { jest.setTimeout(120000); +beforeEach(() => { + const { __resetCapturedEmails } = require("../../src/services/emailService"); + __resetCapturedEmails(); +}); + afterAll(async () => { const prisma = require("../../src/prisma").default; await prisma.$disconnect(); diff --git a/package-lock.json b/package-lock.json index 049ca4a..35e29ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,10 +41,12 @@ "@types/express": "^5.0.5", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^24.10.0", + "@types/nodemailer": "^8.0.0", "bcrypt": "^6.0.0", "cors": "^2.8.5", "express": "^5.1.0", "jsonwebtoken": "^9.0.2", + "nodemailer": "^8.0.7", "pg": "^8.21.0", "prisma": "^7.8.0", "typescript": "^5.9.3", @@ -5094,6 +5096,15 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/nodemailer": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz", + "integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/parse-json": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", @@ -11810,6 +11821,15 @@ "dev": true, "license": "MIT" }, + "node_modules/nodemailer": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz", + "integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nodemon": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", From 1d25eced57ab9251391e97751470a449f1f04182 Mon Sep 17 00:00:00 2001 From: Bohdan Date: Wed, 20 May 2026 23:06:06 +0300 Subject: [PATCH 03/18] feat(auth): require email verification for local register/login Register issues a hashed EmailToken and sends a verify link instead of opening a session. Login rejects unverified accounts with 403. Adds verify-email and resend-verification endpoints. --- .../backend/src/controllers/authController.ts | 246 ++++++++++++++---- apps/backend/src/models/AuthSchema.ts | 7 +- apps/backend/src/router/authRouter.ts | 4 + apps/frontend/src/modules/Auth/AuthPopup.tsx | 12 +- apps/frontend/src/modules/Auth/auth.api.ts | 25 +- apps/frontend/src/modules/Auth/auth.schema.ts | 10 +- 6 files changed, 236 insertions(+), 68 deletions(-) diff --git a/apps/backend/src/controllers/authController.ts b/apps/backend/src/controllers/authController.ts index aa5cf91..c1b9026 100644 --- a/apps/backend/src/controllers/authController.ts +++ b/apps/backend/src/controllers/authController.ts @@ -8,9 +8,13 @@ import prisma from "../prisma.js"; import { RegisterSchema, LoginSchema, + ResendVerificationSchema, UserSchema, } from "../models/AuthSchema.js"; import { handleZodError } from "../utils/helpers.js"; +import { sendVerificationEmail } from "../services/emailService.js"; + +const EMAIL_VERIFY_TTL_MS = 24 * 60 * 60 * 1000; const JWT_SECRET = process.env.JWT_SECRET; const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || JWT_SECRET; @@ -173,6 +177,7 @@ const toSafeUserResponse = (user: UserWithWallets) => { uid: user.id, login: user.login, email: user.email, + emailVerified: user.emailVerified, wallets: user.wallets.map((wallet: WalletListItem) => ({ id: wallet.id, name: wallet.name, @@ -180,6 +185,53 @@ const toSafeUserResponse = (user: UserWithWallets) => { }); }; +const createEmailToken = async ( + userId: string, + purpose: "verify_email" | "merge_google" | "delete_account", + ttlMs: number, +): Promise<{ rawToken: string; expiresAt: Date }> => { + // Invalidate any prior un-consumed tokens of the same purpose for this user + // so each new email link supersedes the previous one. + await prisma.emailToken.deleteMany({ + where: { userId, purpose, consumedAt: null }, + }); + + const rawToken = crypto.randomBytes(32).toString("base64url"); + const tokenHash = hashToken(rawToken); + const expiresAt = new Date(Date.now() + ttlMs); + + await prisma.emailToken.create({ + data: { userId, tokenHash, purpose, expiresAt }, + }); + + return { rawToken, expiresAt }; +}; + +const consumeEmailToken = async ( + rawToken: string, + expectedPurpose: "verify_email" | "merge_google" | "delete_account", +) => { + const tokenHash = hashToken(rawToken); + const token = await prisma.emailToken.findUnique({ + where: { tokenHash }, + }); + + if (!token) return { ok: false as const, reason: "not_found" as const }; + if (token.purpose !== expectedPurpose) + return { ok: false as const, reason: "wrong_purpose" as const }; + if (token.consumedAt) + return { ok: false as const, reason: "already_used" as const }; + if (token.expiresAt.getTime() <= Date.now()) + return { ok: false as const, reason: "expired" as const }; + + await prisma.emailToken.update({ + where: { id: token.id }, + data: { consumedAt: new Date() }, + }); + + return { ok: true as const, userId: token.userId }; +}; + const saveRefreshToken = async ( userId: string, rawRefreshToken: string, @@ -311,26 +363,41 @@ export const registerUser = async (req: Request, res: Response) => { const hashedPassword = await bcrypt.hash(password, saltRounds); - const newUser = await prisma.user.create({ - data: { - login, - password: hashedPassword, - email: email || null, - provider: "local", - }, - include: { - wallets: { - select: { id: true, name: true }, - orderBy: { createdAt: "asc" }, + const newUser = await prisma.$transaction(async (tx) => { + const user = await tx.user.create({ + data: { + login, + password: hashedPassword, + email, + emailVerified: false, }, - }, + }); + + await tx.authIdentity.create({ + data: { userId: user.id, provider: "local" }, + }); + + return user; }); - await createSession(newUser, req, res); + const { rawToken } = await createEmailToken( + newUser.id, + "verify_email", + EMAIL_VERIFY_TTL_MS, + ); + + try { + await sendVerificationEmail(email, rawToken); + } catch (mailError) { + console.error("Failed to send verification email:", mailError); + // Account is created but email failed — user can use /resend-verification. + } return res.status(201).json({ - message: "User successfully registered.", - user: toSafeUserResponse(newUser), + message: + "Account created. Check your inbox to confirm your email before signing in.", + requiresVerification: true, + email, }); } catch (error: any) { if (error.code === "P2002") { @@ -372,6 +439,15 @@ export const loginUser = async (req: Request, res: Response) => { return res.status(401).json({ error: "Invalid credentials." }); } + if (!user.emailVerified) { + return res.status(403).json({ + error: + "Email not confirmed. Check your inbox or request a new verification link.", + requiresVerification: true, + email: user.email, + }); + } + await createSession(user, req, res); return res.status(200).json({ @@ -388,6 +464,67 @@ export const loginUser = async (req: Request, res: Response) => { } }; +export const verifyEmail = async (req: Request, res: Response) => { + const token = typeof req.query.token === "string" ? req.query.token : ""; + if (!token) { + return res.redirect(`${FRONTEND_URL}?auth=verify_error`); + } + + const result = await consumeEmailToken(token, "verify_email"); + if (!result.ok) { + const param = + result.reason === "already_used" ? "already_verified" : "verify_error"; + return res.redirect(`${FRONTEND_URL}?auth=${param}`); + } + + await prisma.user.update({ + where: { id: result.userId }, + data: { emailVerified: true }, + }); + + return res.redirect(`${FRONTEND_URL}?auth=verified`); +}; + +export const resendVerification = async (req: Request, res: Response) => { + try { + const { login } = ResendVerificationSchema.parse(req.body); + + const user = await prisma.user.findFirst({ where: { login } }); + + // Always answer generically — do not leak whether the login exists. + const genericResponse = { + message: + "If an account exists for that login and is unverified, a new verification email is on the way.", + }; + + if (!user || !user.email || user.emailVerified) { + return res.status(200).json(genericResponse); + } + + const { rawToken } = await createEmailToken( + user.id, + "verify_email", + EMAIL_VERIFY_TTL_MS, + ); + + try { + await sendVerificationEmail(user.email, rawToken); + } catch (mailError) { + console.error("Failed to resend verification email:", mailError); + } + + return res.status(200).json(genericResponse); + } catch (error) { + if (error instanceof z.ZodError) { + return handleZodError(res, error); + } + console.error("Resend verification error:", error); + return res + .status(500) + .json({ error: "Server error during verification resend." }); + } +}; + export const startGoogleAuth = async (_req: Request, res: Response) => { try { if (!GOOGLE_CLIENT_ID || !GOOGLE_REDIRECT_URI) { @@ -435,67 +572,70 @@ export const googleAuthCallback = async (req: Request, res: Response) => { const profile = await getGoogleProfileFromCode(code); - let user = await prisma.user.findFirst({ - where: { - provider: "google", - providerId: profile.sub, + const userInclude = { + wallets: { + select: { id: true, name: true }, + orderBy: { createdAt: "asc" as const }, }, - include: { - wallets: { - select: { id: true, name: true }, - orderBy: { createdAt: "asc" }, - }, + }; + + const existingIdentity = await prisma.authIdentity.findUnique({ + where: { + provider_providerId: { provider: "google", providerId: profile.sub }, }, + include: { user: { include: userInclude } }, }); + let user: UserWithWallets | null = existingIdentity?.user ?? null; + if (!user) { const existingByEmail = await prisma.user.findFirst({ where: { email: profile.email }, - include: { - wallets: { - select: { id: true, name: true }, - orderBy: { createdAt: "asc" }, - }, - }, + include: userInclude, }); if (existingByEmail) { - if (!profile.emailVerified) { + // Refuse to auto-link a Google identity into an account whose email + // was never verified — that account may have been planted by an + // attacker. Same refusal if Google itself did not verify the email. + // Commit 4 replaces this with a merge-confirmation email flow. + if (!profile.emailVerified || !existingByEmail.emailVerified) { return res.redirect(`${FRONTEND_URL}?auth=google_error`); } - user = await prisma.user.update({ - where: { id: existingByEmail.id }, + await prisma.authIdentity.create({ data: { + userId: existingByEmail.id, provider: "google", providerId: profile.sub, - email: profile.email, - }, - include: { - wallets: { - select: { id: true, name: true }, - orderBy: { createdAt: "asc" }, - }, }, }); + + user = existingByEmail; } else { const seed = profile.email.split("@")[0] || profile.name; const login = await generateUniqueLogin(seed); - user = await prisma.user.create({ - data: { - login, - email: profile.email, - password: null, - provider: "google", - providerId: profile.sub, - }, - include: { - wallets: { - select: { id: true, name: true }, - orderBy: { createdAt: "asc" }, + user = await prisma.$transaction(async (tx) => { + const created = await tx.user.create({ + data: { + login, + email: profile.email, + password: null, + emailVerified: profile.emailVerified, }, - }, + include: userInclude, + }); + + await tx.authIdentity.create({ + data: { + userId: created.id, + provider: "google", + providerId: profile.sub, + }, + }); + + return created; }); } } diff --git a/apps/backend/src/models/AuthSchema.ts b/apps/backend/src/models/AuthSchema.ts index 6b3b84f..08ca66a 100644 --- a/apps/backend/src/models/AuthSchema.ts +++ b/apps/backend/src/models/AuthSchema.ts @@ -9,6 +9,7 @@ export const UserSchema = z.object({ .min(3, "Login must be at least 3 characters") .max(30), email: z.string().email().nullable().optional(), + emailVerified: z.boolean().optional(), token: z.string().optional(), // Access token @@ -22,10 +23,14 @@ export const RegisterSchema = z.object({ .min(3, "Login must be at least 3 characters") .max(30), password: z.string().min(6, "Password must be at least 6 characters"), - email: z.string().email("Invalid email format").optional().or(z.literal("")), + email: z.string().email("Invalid email format"), }); export const LoginSchema = z.object({ login: z.string().trim().min(3, "Login is required"), password: z.string().min(1, "Password is required"), }); + +export const ResendVerificationSchema = z.object({ + login: z.string().trim().min(3, "Login is required").max(30), +}); diff --git a/apps/backend/src/router/authRouter.ts b/apps/backend/src/router/authRouter.ts index e99e511..d5e5ce1 100644 --- a/apps/backend/src/router/authRouter.ts +++ b/apps/backend/src/router/authRouter.ts @@ -4,6 +4,8 @@ const authRouter = express.Router(); import { registerUser, loginUser, + verifyEmail, + resendVerification, startGoogleAuth, googleAuthCallback, refreshSession, @@ -15,6 +17,8 @@ import { protect } from "../middleware/authMiddleware.js"; authRouter.post("/register", registerUser); authRouter.post("/login", loginUser); +authRouter.get("/verify-email", verifyEmail); +authRouter.post("/resend-verification", resendVerification); authRouter.get("/google/start", startGoogleAuth); authRouter.get("/google/callback", googleAuthCallback); authRouter.post("/refresh", refreshSession); diff --git a/apps/frontend/src/modules/Auth/AuthPopup.tsx b/apps/frontend/src/modules/Auth/AuthPopup.tsx index bbb677c..1ac6064 100644 --- a/apps/frontend/src/modules/Auth/AuthPopup.tsx +++ b/apps/frontend/src/modules/Auth/AuthPopup.tsx @@ -64,7 +64,11 @@ export function AuthPopup() { const handleChange = (e: ChangeEvent) => { const { name, value } = e.target; - setFormData((prev: Login | Register) => ({ ...prev, [name]: value })); + ( + setFormData as ( + updater: (prev: Login | Register) => Login | Register, + ) => void + )((prev) => ({ ...prev, [name]: value }) as Login | Register); if (formErrors[name as CombinedFormKeys]) { setFormErrors((prev) => ({ ...prev, [name]: undefined })); @@ -92,7 +96,11 @@ export function AuthPopup() { } try { - await currentMutation(result.data as Login | Register).unwrap(); + await ( + currentMutation as (arg: Login | Register) => { + unwrap: () => Promise; + } + )(result.data as Login | Register).unwrap(); dispatch(closePopup()); } catch (err) { console.error("API Error:", err); diff --git a/apps/frontend/src/modules/Auth/auth.api.ts b/apps/frontend/src/modules/Auth/auth.api.ts index 7069145..f830bd5 100644 --- a/apps/frontend/src/modules/Auth/auth.api.ts +++ b/apps/frontend/src/modules/Auth/auth.api.ts @@ -4,6 +4,7 @@ import { type AuthResponse, type Login, type Register, + type RegisterResponse, type UserSafe, } from "./auth.schema"; import { setUserData, logout } from "./auth.slice"; @@ -18,25 +19,26 @@ export const authApi = createApi({ baseQuery: baseQueryWithReauth, tagTypes: ["User"], endpoints: (builder) => ({ - registerUser: builder.mutation({ + registerUser: builder.mutation({ query: (credentials) => ({ url: "auth/register", method: "POST", body: credentials, }), - async onQueryStarted(_, { dispatch, queryFulfilled }) { - try { - const { data: responseData } = await queryFulfilled; - const parsedUser: UserSafe = UserSchema.parse(responseData.user); - dispatch(setUserData(parsedUser)); - dispatch(setWalletsList(parsedUser.wallets || [])); - } catch (error) { - console.error("Registration error:", error); - } - }, invalidatesTags: ["User"], }), + resendVerification: builder.mutation< + { message: string }, + { login: string } + >({ + query: (body) => ({ + url: "auth/resend-verification", + method: "POST", + body, + }), + }), + loginUser: builder.mutation({ query: (credentials) => ({ url: "auth/login", @@ -100,5 +102,6 @@ export const { useLogoutUserMutation, useLoginUserMutation, useRegisterUserMutation, + useResendVerificationMutation, useGetCurrentUserQuery, } = authApi; diff --git a/apps/frontend/src/modules/Auth/auth.schema.ts b/apps/frontend/src/modules/Auth/auth.schema.ts index 35768c5..4588dde 100644 --- a/apps/frontend/src/modules/Auth/auth.schema.ts +++ b/apps/frontend/src/modules/Auth/auth.schema.ts @@ -9,6 +9,7 @@ export const UserSchema = z.object({ .min(3, "Login must be at least 3 characters") .max(30), email: z.string().email().nullable().optional(), + emailVerified: z.boolean().optional(), token: z.string().optional(), wallets: z.array(WalletListItemResponseSchema).optional(), @@ -23,7 +24,7 @@ export const RegisterSchema = z.object({ .min(3, "Login must be at least 3 characters") .max(30), password: z.string().min(6, "Password must be at least 6 characters"), - email: z.string().email("Invalid email format").optional().or(z.literal("")), + email: z.string().email("Invalid email format"), }); export const LoginSchema = z.object({ @@ -39,4 +40,11 @@ export const AuthResponseSchema = z.object({ user: UserSchema, }); +export const RegisterResponseSchema = z.object({ + message: z.string(), + requiresVerification: z.literal(true), + email: z.string().email(), +}); + export type AuthResponse = z.infer; +export type RegisterResponse = z.infer; From 0dd8520348dd2be1db6e0a0d02e5b33bdfb225a0 Mon Sep 17 00:00:00 2001 From: Bohdan Date: Wed, 20 May 2026 23:11:51 +0300 Subject: [PATCH 04/18] fix(auth): close google account-takeover with merge-confirmation flow Google callback no longer auto-links on email match. Issues a 1h merge_google token via email; /auth/verify-merge attaches the identity after the user clicks the link. --- .../migration.sql | 2 + apps/backend/prisma/schema.prisma | 1 + .../backend/src/controllers/authController.ts | 125 +++++++++++++++--- apps/backend/src/router/authRouter.ts | 2 + 4 files changed, 112 insertions(+), 18 deletions(-) create mode 100644 apps/backend/prisma/migrations/20260520140000_email_token_metadata/migration.sql diff --git a/apps/backend/prisma/migrations/20260520140000_email_token_metadata/migration.sql b/apps/backend/prisma/migrations/20260520140000_email_token_metadata/migration.sql new file mode 100644 index 0000000..48e3b32 --- /dev/null +++ b/apps/backend/prisma/migrations/20260520140000_email_token_metadata/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable: carry merge-confirmation payload (google sub/email) on the token +ALTER TABLE "EmailToken" ADD COLUMN "metadata" JSONB; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 02521ea..db0660e 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -80,6 +80,7 @@ model EmailToken { expiresAt DateTime consumedAt DateTime? createdAt DateTime @default(now()) + metadata Json? user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId, purpose]) diff --git a/apps/backend/src/controllers/authController.ts b/apps/backend/src/controllers/authController.ts index c1b9026..911b63d 100644 --- a/apps/backend/src/controllers/authController.ts +++ b/apps/backend/src/controllers/authController.ts @@ -12,9 +12,13 @@ import { UserSchema, } from "../models/AuthSchema.js"; import { handleZodError } from "../utils/helpers.js"; -import { sendVerificationEmail } from "../services/emailService.js"; +import { + sendVerificationEmail, + sendGoogleMergeConfirmationEmail, +} from "../services/emailService.js"; const EMAIL_VERIFY_TTL_MS = 24 * 60 * 60 * 1000; +const MERGE_TOKEN_TTL_MS = 60 * 60 * 1000; const JWT_SECRET = process.env.JWT_SECRET; const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || JWT_SECRET; @@ -185,10 +189,13 @@ const toSafeUserResponse = (user: UserWithWallets) => { }); }; +type EmailTokenPurpose = "verify_email" | "merge_google" | "delete_account"; + const createEmailToken = async ( userId: string, - purpose: "verify_email" | "merge_google" | "delete_account", + purpose: EmailTokenPurpose, ttlMs: number, + metadata?: Prisma.InputJsonValue, ): Promise<{ rawToken: string; expiresAt: Date }> => { // Invalidate any prior un-consumed tokens of the same purpose for this user // so each new email link supersedes the previous one. @@ -201,7 +208,13 @@ const createEmailToken = async ( const expiresAt = new Date(Date.now() + ttlMs); await prisma.emailToken.create({ - data: { userId, tokenHash, purpose, expiresAt }, + data: { + userId, + tokenHash, + purpose, + expiresAt, + ...(metadata !== undefined && { metadata }), + }, }); return { rawToken, expiresAt }; @@ -209,7 +222,7 @@ const createEmailToken = async ( const consumeEmailToken = async ( rawToken: string, - expectedPurpose: "verify_email" | "merge_google" | "delete_account", + expectedPurpose: EmailTokenPurpose, ) => { const tokenHash = hashToken(rawToken); const token = await prisma.emailToken.findUnique({ @@ -229,7 +242,11 @@ const consumeEmailToken = async ( data: { consumedAt: new Date() }, }); - return { ok: true as const, userId: token.userId }; + return { + ok: true as const, + userId: token.userId, + metadata: token.metadata, + }; }; const saveRefreshToken = async ( @@ -485,6 +502,66 @@ export const verifyEmail = async (req: Request, res: Response) => { return res.redirect(`${FRONTEND_URL}?auth=verified`); }; +export const verifyGoogleMerge = async (req: Request, res: Response) => { + try { + const token = typeof req.query.token === "string" ? req.query.token : ""; + if (!token) { + return res.redirect(`${FRONTEND_URL}?auth=merge_error`); + } + + const result = await consumeEmailToken(token, "merge_google"); + if (!result.ok) { + const param = + result.reason === "already_used" ? "merge_already_done" : "merge_error"; + return res.redirect(`${FRONTEND_URL}?auth=${param}`); + } + + const metadata = result.metadata as { sub?: string; email?: string } | null; + if (!metadata?.sub) { + return res.redirect(`${FRONTEND_URL}?auth=merge_error`); + } + + // Refuse if this google identity is already attached to a different user. + const conflicting = await prisma.authIdentity.findUnique({ + where: { + provider_providerId: { provider: "google", providerId: metadata.sub }, + }, + }); + if (conflicting && conflicting.userId !== result.userId) { + return res.redirect(`${FRONTEND_URL}?auth=merge_error`); + } + + if (!conflicting) { + await prisma.authIdentity.create({ + data: { + userId: result.userId, + provider: "google", + providerId: metadata.sub, + }, + }); + } + + const user = await prisma.user.findUnique({ + where: { id: result.userId }, + include: { + wallets: { + select: { id: true, name: true }, + orderBy: { createdAt: "asc" }, + }, + }, + }); + if (!user) { + return res.redirect(`${FRONTEND_URL}?auth=merge_error`); + } + + await createSession(user, req, res); + return res.redirect(`${FRONTEND_URL}?auth=merge_confirmed`); + } catch (error) { + console.error("Google merge confirmation error:", error); + return res.redirect(`${FRONTEND_URL}?auth=merge_error`); + } +}; + export const resendVerification = async (req: Request, res: Response) => { try { const { login } = ResendVerificationSchema.parse(req.body); @@ -595,23 +672,35 @@ export const googleAuthCallback = async (req: Request, res: Response) => { }); if (existingByEmail) { - // Refuse to auto-link a Google identity into an account whose email - // was never verified — that account may have been planted by an - // attacker. Same refusal if Google itself did not verify the email. - // Commit 4 replaces this with a merge-confirmation email flow. - if (!profile.emailVerified || !existingByEmail.emailVerified) { + // Never auto-link a Google identity into an existing account, even if + // both sides are verified — proof-of-control over the email right now + // is required. Issue a short-lived merge token and email it to the + // address on file; the user attaches Google only by clicking it. + if (!profile.emailVerified || !existingByEmail.email) { return res.redirect(`${FRONTEND_URL}?auth=google_error`); } - await prisma.authIdentity.create({ - data: { - userId: existingByEmail.id, - provider: "google", - providerId: profile.sub, - }, - }); + const { rawToken } = await createEmailToken( + existingByEmail.id, + "merge_google", + MERGE_TOKEN_TTL_MS, + { sub: profile.sub, email: profile.email }, + ); + + try { + await sendGoogleMergeConfirmationEmail( + existingByEmail.email, + rawToken, + ); + } catch (mailError) { + console.error( + "Failed to send Google merge confirmation email:", + mailError, + ); + return res.redirect(`${FRONTEND_URL}?auth=google_error`); + } - user = existingByEmail; + return res.redirect(`${FRONTEND_URL}?auth=google_pending_merge`); } else { const seed = profile.email.split("@")[0] || profile.name; const login = await generateUniqueLogin(seed); diff --git a/apps/backend/src/router/authRouter.ts b/apps/backend/src/router/authRouter.ts index d5e5ce1..78ee6e4 100644 --- a/apps/backend/src/router/authRouter.ts +++ b/apps/backend/src/router/authRouter.ts @@ -8,6 +8,7 @@ import { resendVerification, startGoogleAuth, googleAuthCallback, + verifyGoogleMerge, refreshSession, logoutUser, logoutAllUserSessions, @@ -21,6 +22,7 @@ authRouter.get("/verify-email", verifyEmail); authRouter.post("/resend-verification", resendVerification); authRouter.get("/google/start", startGoogleAuth); authRouter.get("/google/callback", googleAuthCallback); +authRouter.get("/verify-merge", verifyGoogleMerge); authRouter.post("/refresh", refreshSession); authRouter.post("/logout", logoutUser); authRouter.get("/me", protect, getCurrentUser); From 2eef43aa9394d60942aa86f0144bfae515739b25 Mon Sep 17 00:00:00 2001 From: Bohdan Date: Wed, 20 May 2026 23:16:17 +0300 Subject: [PATCH 05/18] feat(auth): set-password, one-time-password and delete-account endpoints set-password creates a local AuthIdentity on first use and requires old password to change. OTP emails a random password. Account deletion requires password for local users or an email link for google-only. --- .../backend/src/controllers/authController.ts | 228 ++++++++++++++++++ apps/backend/src/models/AuthSchema.ts | 9 + apps/backend/src/router/authRouter.ts | 10 + 3 files changed, 247 insertions(+) diff --git a/apps/backend/src/controllers/authController.ts b/apps/backend/src/controllers/authController.ts index 911b63d..c706b77 100644 --- a/apps/backend/src/controllers/authController.ts +++ b/apps/backend/src/controllers/authController.ts @@ -9,16 +9,22 @@ import { RegisterSchema, LoginSchema, ResendVerificationSchema, + SetPasswordSchema, + DeleteAccountSchema, UserSchema, } from "../models/AuthSchema.js"; import { handleZodError } from "../utils/helpers.js"; import { sendVerificationEmail, sendGoogleMergeConfirmationEmail, + sendOneTimePasswordEmail, + sendAccountDeletionEmail, } from "../services/emailService.js"; const EMAIL_VERIFY_TTL_MS = 24 * 60 * 60 * 1000; const MERGE_TOKEN_TTL_MS = 60 * 60 * 1000; +const DELETE_TOKEN_TTL_MS = 60 * 60 * 1000; +const OTP_BYTES = 12; // ~16 base64url chars const JWT_SECRET = process.env.JWT_SECRET; const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || JWT_SECRET; @@ -917,3 +923,225 @@ export const getCurrentUser = async (req: Request, res: Response) => { .json({ error: "Server error during current user fetch." }); } }; + +export const setPassword = async (req: Request, res: Response) => { + try { + const userId = req.userId; + if (!userId) { + return res.status(401).json({ error: "Unauthorized." }); + } + + const { password, oldPassword } = SetPasswordSchema.parse(req.body); + + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (!user) { + return res.status(404).json({ error: "User not found." }); + } + + if (user.password) { + if (!oldPassword) { + return res + .status(400) + .json({ error: "Current password is required to change it." }); + } + const match = await bcrypt.compare(oldPassword, user.password); + if (!match) { + return res.status(401).json({ error: "Current password is wrong." }); + } + } + + const hashed = await bcrypt.hash(password, saltRounds); + + await prisma.$transaction(async (tx) => { + await tx.user.update({ + where: { id: userId }, + data: { password: hashed }, + }); + await tx.authIdentity.upsert({ + where: { + userId_provider: { userId, provider: "local" }, + }, + create: { userId, provider: "local" }, + update: {}, + }); + }); + + return res.status(200).json({ + message: user.password + ? "Password updated." + : "Password set. You can now sign in with login and password.", + }); + } catch (error) { + if (error instanceof z.ZodError) { + return handleZodError(res, error); + } + console.error("Set password error:", error); + return res + .status(500) + .json({ error: "Server error during password change." }); + } +}; + +export const sendOneTimePassword = async (req: Request, res: Response) => { + try { + const userId = req.userId; + if (!userId) { + return res.status(401).json({ error: "Unauthorized." }); + } + + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (!user) { + return res.status(404).json({ error: "User not found." }); + } + if (!user.email) { + return res + .status(400) + .json({ error: "Account has no email to deliver the password to." }); + } + + const otp = crypto.randomBytes(OTP_BYTES).toString("base64url"); + const hashed = await bcrypt.hash(otp, saltRounds); + + await prisma.$transaction(async (tx) => { + await tx.user.update({ + where: { id: userId }, + data: { password: hashed }, + }); + await tx.authIdentity.upsert({ + where: { userId_provider: { userId, provider: "local" } }, + create: { userId, provider: "local" }, + update: {}, + }); + }); + + try { + await sendOneTimePasswordEmail(user.email, otp); + } catch (mailError) { + console.error("Failed to send one-time password email:", mailError); + return res + .status(502) + .json({ error: "Failed to deliver the one-time password email." }); + } + + return res.status(200).json({ + message: + "A one-time password has been sent to your email. Use it to sign in and change it from your account settings.", + }); + } catch (error) { + console.error("Send OTP error:", error); + return res.status(500).json({ error: "Server error during OTP send." }); + } +}; + +export const deleteAccount = async (req: Request, res: Response) => { + try { + const userId = req.userId; + if (!userId) { + return res.status(401).json({ error: "Unauthorized." }); + } + + const { password } = DeleteAccountSchema.parse(req.body ?? {}); + + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (!user) { + return res.status(404).json({ error: "User not found." }); + } + + if (user.password) { + if (!password) { + return res + .status(400) + .json({ error: "Password is required to delete the account." }); + } + const match = await bcrypt.compare(password, user.password); + if (!match) { + return res.status(401).json({ error: "Password is wrong." }); + } + } else { + return res.status(409).json({ + error: + "This account has no password. Request an email confirmation via /auth/account/request-delete.", + requiresEmailConfirmation: true, + }); + } + + await prisma.user.delete({ where: { id: userId } }); + clearAuthCookies(res); + return res.status(200).json({ message: "Account deleted." }); + } catch (error) { + if (error instanceof z.ZodError) { + return handleZodError(res, error); + } + console.error("Delete account error:", error); + return res + .status(500) + .json({ error: "Server error during account deletion." }); + } +}; + +export const requestDeleteAccount = async (req: Request, res: Response) => { + try { + const userId = req.userId; + if (!userId) { + return res.status(401).json({ error: "Unauthorized." }); + } + + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (!user) { + return res.status(404).json({ error: "User not found." }); + } + if (!user.email) { + return res + .status(400) + .json({ error: "Account has no email to send confirmation to." }); + } + + const { rawToken } = await createEmailToken( + user.id, + "delete_account", + DELETE_TOKEN_TTL_MS, + ); + + try { + await sendAccountDeletionEmail(user.email, rawToken); + } catch (mailError) { + console.error("Failed to send delete confirmation email:", mailError); + return res + .status(502) + .json({ error: "Failed to deliver the confirmation email." }); + } + + return res.status(200).json({ + message: + "Account deletion confirmation has been sent to your email. The link expires in 1 hour.", + }); + } catch (error) { + console.error("Request delete error:", error); + return res + .status(500) + .json({ error: "Server error during delete request." }); + } +}; + +export const confirmDeleteAccount = async (req: Request, res: Response) => { + try { + const token = typeof req.query.token === "string" ? req.query.token : ""; + if (!token) { + return res.redirect(`${FRONTEND_URL}?auth=delete_error`); + } + + const result = await consumeEmailToken(token, "delete_account"); + if (!result.ok) { + const param = + result.reason === "already_used" ? "already_deleted" : "delete_error"; + return res.redirect(`${FRONTEND_URL}?auth=${param}`); + } + + await prisma.user.delete({ where: { id: result.userId } }); + clearAuthCookies(res); + return res.redirect(`${FRONTEND_URL}?auth=account_deleted`); + } catch (error) { + console.error("Confirm delete error:", error); + return res.redirect(`${FRONTEND_URL}?auth=delete_error`); + } +}; diff --git a/apps/backend/src/models/AuthSchema.ts b/apps/backend/src/models/AuthSchema.ts index 08ca66a..6c493a1 100644 --- a/apps/backend/src/models/AuthSchema.ts +++ b/apps/backend/src/models/AuthSchema.ts @@ -34,3 +34,12 @@ export const LoginSchema = z.object({ export const ResendVerificationSchema = z.object({ login: z.string().trim().min(3, "Login is required").max(30), }); + +export const SetPasswordSchema = z.object({ + password: z.string().min(6, "Password must be at least 6 characters"), + oldPassword: z.string().min(1).optional(), +}); + +export const DeleteAccountSchema = z.object({ + password: z.string().min(1).optional(), +}); diff --git a/apps/backend/src/router/authRouter.ts b/apps/backend/src/router/authRouter.ts index 78ee6e4..80258b2 100644 --- a/apps/backend/src/router/authRouter.ts +++ b/apps/backend/src/router/authRouter.ts @@ -13,6 +13,11 @@ import { logoutUser, logoutAllUserSessions, getCurrentUser, + setPassword, + sendOneTimePassword, + deleteAccount, + requestDeleteAccount, + confirmDeleteAccount, } from "../controllers/authController.js"; import { protect } from "../middleware/authMiddleware.js"; @@ -27,5 +32,10 @@ authRouter.post("/refresh", refreshSession); authRouter.post("/logout", logoutUser); authRouter.get("/me", protect, getCurrentUser); authRouter.post("/logout-all", protect, logoutAllUserSessions); +authRouter.post("/set-password", protect, setPassword); +authRouter.post("/send-one-time-password", protect, sendOneTimePassword); +authRouter.delete("/account", protect, deleteAccount); +authRouter.post("/account/request-delete", protect, requestDeleteAccount); +authRouter.get("/account/confirm-delete", confirmDeleteAccount); export default authRouter; From 498930c6c5ee2f74773a81657d478f19a71201eb Mon Sep 17 00:00:00 2001 From: Bohdan Date: Wed, 20 May 2026 23:20:23 +0300 Subject: [PATCH 06/18] chore(prisma/seed,tests): adapt to AuthIdentity + email verification Seed creates a local AuthIdentity per user and marks them verified. registerAndCreateWallet now flips the verified flag and re-logs in since register no longer opens a session. --- apps/backend/prisma/seed.ts | 17 ++++- apps/backend/tests/helpers/testUtils.ts | 26 ++++++- .../integration/auth.refresh.int.test.ts | 74 +++++++++---------- 3 files changed, 71 insertions(+), 46 deletions(-) diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index 7a88865..65d9425 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -27,7 +27,11 @@ function dateDaysAgo(daysAgo: number): Date { async function main() { await prisma.transaction.deleteMany(); + await prisma.swapSettings.deleteMany(); await prisma.wallet.deleteMany(); + await prisma.emailToken.deleteMany(); + await prisma.refreshToken.deleteMany(); + await prisma.authIdentity.deleteMany(); await prisma.user.deleteMany(); const seedPassword = "Test12345"; @@ -39,25 +43,32 @@ async function main() { login: "bohdan", password: passwordHash, email: "bohdan@coinradar.local", - provider: "local", + emailVerified: true, }, { id: randomUUID(), login: "natalia", password: passwordHash, email: "natalia@coinradar.local", - provider: "local", + emailVerified: true, }, { id: randomUUID(), login: "taras", password: passwordHash, email: "taras@coinradar.local", - provider: "local", + emailVerified: true, }, ]; await prisma.user.createMany({ data: users }); + await prisma.authIdentity.createMany({ + data: users.map((user) => ({ + id: randomUUID(), + userId: user.id, + provider: "local", + })), + }); const wallets = users.flatMap((user) => [ { diff --git a/apps/backend/tests/helpers/testUtils.ts b/apps/backend/tests/helpers/testUtils.ts index 2a16a3d..81b29f3 100644 --- a/apps/backend/tests/helpers/testUtils.ts +++ b/apps/backend/tests/helpers/testUtils.ts @@ -10,16 +10,20 @@ export const resetDatabase = async (): Promise => { await prisma.swapSettings.deleteMany(); await prisma.wallet.deleteMany(); await prisma.refreshToken.deleteMany(); + await prisma.emailToken.deleteMany(); + await prisma.authIdentity.deleteMany(); await prisma.user.deleteMany(); }; export const registerAndCreateWallet = async () => { const agent = request.agent(getApp()); const unique = `${Date.now()}-${Math.floor(Math.random() * 100000)}`; + const login = `user_${unique}`; + const password = "password123"; const registerResponse = await agent.post("/api/auth/register").send({ - login: `user_${unique}`, - password: "password123", + login, + password, email: `user_${unique}@mail.com`, }); @@ -29,6 +33,24 @@ export const registerAndCreateWallet = async () => { ); } + // Register no longer opens a session and login is blocked until the email + // is confirmed. Tests can't click the email link, so flip the verified flag + // directly and then log in to get cookies. + await prisma.user.update({ + where: { login }, + data: { emailVerified: true }, + }); + + const loginResponse = await agent + .post("/api/auth/login") + .send({ login, password }); + + if (loginResponse.status !== 200) { + throw new Error( + `Login after verification failed: ${loginResponse.status} ${JSON.stringify(loginResponse.body)}`, + ); + } + const walletResponse = await agent.post("/api/wallets").send({ name: `wallet_${unique}`.slice(0, 20), }); diff --git a/apps/backend/tests/integration/auth.refresh.int.test.ts b/apps/backend/tests/integration/auth.refresh.int.test.ts index c60fec8..a2e5a65 100644 --- a/apps/backend/tests/integration/auth.refresh.int.test.ts +++ b/apps/backend/tests/integration/auth.refresh.int.test.ts @@ -1,30 +1,40 @@ import request from "supertest"; +import prisma from "../../src/prisma.js"; import { getApp, resetDatabase } from "../helpers/testUtils.js"; +const registerVerifyLogin = async (slug: string) => { + const login = `${slug}_${Date.now()}-${Math.floor(Math.random() * 100000)}`; + const password = "password123"; + + const registerResponse = await request(getApp()) + .post("/api/auth/register") + .send({ login, password, email: `${login}@mail.com` }); + expect(registerResponse.status).toBe(201); + expect(registerResponse.body.requiresVerification).toBe(true); + + await prisma.user.update({ + where: { login }, + data: { emailVerified: true }, + }); + + const loginResponse = await request(getApp()) + .post("/api/auth/login") + .send({ login, password }); + expect(loginResponse.status).toBe(200); + + const raw = loginResponse.headers["set-cookie"]; + const cookies = Array.isArray(raw) ? raw : raw ? [raw] : []; + return { login, cookies }; +}; + describe("Auth refresh rotation", () => { beforeEach(async () => { await resetDatabase(); }); it("rotates refresh token and rejects old one", async () => { - const unique = `${Date.now()}-${Math.floor(Math.random() * 100000)}`; - - const registerResponse = await request(getApp()) - .post("/api/auth/register") - .send({ - login: `auth_${unique}`, - password: "password123", - email: `auth_${unique}@mail.com`, - }); + const { cookies: initialCookies } = await registerVerifyLogin("auth"); - expect(registerResponse.status).toBe(201); - - const initialCookiesRaw = registerResponse.headers["set-cookie"]; - const initialCookies = Array.isArray(initialCookiesRaw) - ? initialCookiesRaw - : initialCookiesRaw - ? [initialCookiesRaw] - : []; expect(initialCookies.length).toBeGreaterThan(0); expect(initialCookies.join(";")).toContain("refresh_token="); @@ -33,11 +43,11 @@ describe("Auth refresh rotation", () => { .set("Cookie", initialCookies); expect(refreshResponse.status).toBe(200); - const rotatedCookiesRaw = refreshResponse.headers["set-cookie"]; - const rotatedCookies = Array.isArray(rotatedCookiesRaw) - ? rotatedCookiesRaw - : rotatedCookiesRaw - ? [rotatedCookiesRaw] + const rotatedRaw = refreshResponse.headers["set-cookie"]; + const rotatedCookies = Array.isArray(rotatedRaw) + ? rotatedRaw + : rotatedRaw + ? [rotatedRaw] : []; expect(rotatedCookies.length).toBeGreaterThan(0); expect(rotatedCookies.join(";")).toContain("refresh_token="); @@ -50,25 +60,7 @@ describe("Auth refresh rotation", () => { }); it("returns current user via cookie-authenticated /auth/me", async () => { - const unique = `${Date.now()}-${Math.floor(Math.random() * 100000)}`; - const login = `me_${unique}`; - - const registerResponse = await request(getApp()) - .post("/api/auth/register") - .send({ - login, - password: "password123", - email: `me_${unique}@mail.com`, - }); - - expect(registerResponse.status).toBe(201); - - const cookiesRaw = registerResponse.headers["set-cookie"]; - const cookies = Array.isArray(cookiesRaw) - ? cookiesRaw - : cookiesRaw - ? [cookiesRaw] - : []; + const { login, cookies } = await registerVerifyLogin("me"); const meResponse = await request(getApp()) .get("/api/auth/me") From eba2c8281320bc48e8f350912a4114846ae70d71 Mon Sep 17 00:00:00 2001 From: Bohdan Date: Wed, 20 May 2026 23:50:54 +0300 Subject: [PATCH 07/18] fix(prisma): cascade Wallet on user delete Account deletion was failing with FK violation because Wallet.user had no onDelete rule. Migration drops and re-adds the FK with ON DELETE CASCADE. --- .../migration.sql | 6 ++++++ apps/backend/prisma/schema.prisma | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 apps/backend/prisma/migrations/20260520160000_cascade_wallet_on_user_delete/migration.sql diff --git a/apps/backend/prisma/migrations/20260520160000_cascade_wallet_on_user_delete/migration.sql b/apps/backend/prisma/migrations/20260520160000_cascade_wallet_on_user_delete/migration.sql new file mode 100644 index 0000000..b3434b8 --- /dev/null +++ b/apps/backend/prisma/migrations/20260520160000_cascade_wallet_on_user_delete/migration.sql @@ -0,0 +1,6 @@ +-- Cascade Wallet (and its dependents) when the owning User is deleted, so +-- account deletion works without manual wallet cleanup. +ALTER TABLE "Wallet" DROP CONSTRAINT "Wallet_userId_fkey"; + +ALTER TABLE "Wallet" ADD CONSTRAINT "Wallet_userId_fkey" + FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index db0660e..dd3c5c1 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -39,7 +39,7 @@ model Wallet { name String @default("hot") createdAt DateTime @default(now()) userId String - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) transactions Transaction[] swapSettings SwapSettings? From 6829b9af82dffc9eb2c2e0261ef8fa8600adc8da Mon Sep 17 00:00:00 2001 From: Bohdan Date: Wed, 20 May 2026 23:52:11 +0300 Subject: [PATCH 08/18] fix(auth): let new register replace an unverified squat on login/email Drop any unverified record with the same login or email before creating a new user. Lets the real owner recover and blocks squat attacks. --- apps/backend/src/controllers/authController.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/backend/src/controllers/authController.ts b/apps/backend/src/controllers/authController.ts index c706b77..8b28a78 100644 --- a/apps/backend/src/controllers/authController.ts +++ b/apps/backend/src/controllers/authController.ts @@ -387,6 +387,16 @@ export const registerUser = async (req: Request, res: Response) => { const hashedPassword = await bcrypt.hash(password, saltRounds); const newUser = await prisma.$transaction(async (tx) => { + // Drop any prior unverified records holding the same login or email so + // (a) the legit owner can recover from a typo and (b) attackers cannot + // squat someone else's login by registering and never confirming. + await tx.user.deleteMany({ + where: { + emailVerified: false, + OR: [{ login }, { email }], + }, + }); + const user = await tx.user.create({ data: { login, From a0fb9e033c77e4960345585f43722c12e2323264 Mon Sep 17 00:00:00 2001 From: Bohdan Date: Wed, 20 May 2026 23:53:32 +0300 Subject: [PATCH 09/18] feat(frontend): verification UX, account settings popup, query toasts Auth button covers sign-in/sign-up, verifying stage with resend, and logout/logout-all. Settings popup adds tabs for password, OTP and account deletion. AuthQueryParamToast shows auth= outcomes with auto-dismiss. --- .../backend/src/controllers/authController.ts | 1 + apps/backend/src/models/AuthSchema.ts | 1 + apps/frontend/src/modules/App/App.tsx | 3 + .../src/modules/Auth/AccountSettingsPopup.tsx | 419 ++++++++++++++++++ apps/frontend/src/modules/Auth/AuthPopup.tsx | 246 +++++++--- .../src/modules/Auth/AuthQueryParamToast.tsx | 134 ++++++ apps/frontend/src/modules/Auth/Settings.tsx | 28 ++ apps/frontend/src/modules/Auth/auth.api.ts | 68 +++ apps/frontend/src/modules/Auth/auth.schema.ts | 1 + .../src/modules/FixedHeader/FixedHeader.tsx | 2 + 10 files changed, 845 insertions(+), 58 deletions(-) create mode 100644 apps/frontend/src/modules/Auth/AccountSettingsPopup.tsx create mode 100644 apps/frontend/src/modules/Auth/AuthQueryParamToast.tsx create mode 100644 apps/frontend/src/modules/Auth/Settings.tsx diff --git a/apps/backend/src/controllers/authController.ts b/apps/backend/src/controllers/authController.ts index 8b28a78..435a984 100644 --- a/apps/backend/src/controllers/authController.ts +++ b/apps/backend/src/controllers/authController.ts @@ -188,6 +188,7 @@ const toSafeUserResponse = (user: UserWithWallets) => { login: user.login, email: user.email, emailVerified: user.emailVerified, + hasPassword: user.password !== null, wallets: user.wallets.map((wallet: WalletListItem) => ({ id: wallet.id, name: wallet.name, diff --git a/apps/backend/src/models/AuthSchema.ts b/apps/backend/src/models/AuthSchema.ts index 6c493a1..3a7034c 100644 --- a/apps/backend/src/models/AuthSchema.ts +++ b/apps/backend/src/models/AuthSchema.ts @@ -10,6 +10,7 @@ export const UserSchema = z.object({ .max(30), email: z.string().email().nullable().optional(), emailVerified: z.boolean().optional(), + hasPassword: z.boolean().optional(), token: z.string().optional(), // Access token diff --git a/apps/frontend/src/modules/App/App.tsx b/apps/frontend/src/modules/App/App.tsx index 5528f73..182929a 100644 --- a/apps/frontend/src/modules/App/App.tsx +++ b/apps/frontend/src/modules/App/App.tsx @@ -8,6 +8,7 @@ import { AllCrypto } from "../AllCrypto/AllCrypto"; import { lazy, Suspense } from "react"; import { useOnScreen } from "../../hooks/useOnScreen"; import { useGetCurrentUserQuery } from "../Auth/auth.api"; +import { AuthQueryParamToast } from "../Auth/AuthQueryParamToast"; const Wallet = lazy(() => import("../Wallet/Wallet").then((module) => ({ @@ -44,6 +45,8 @@ export function App() { {/* All PopUps in one portal */} + + ); diff --git a/apps/frontend/src/modules/Auth/AccountSettingsPopup.tsx b/apps/frontend/src/modules/Auth/AccountSettingsPopup.tsx new file mode 100644 index 0000000..25e7d85 --- /dev/null +++ b/apps/frontend/src/modules/Auth/AccountSettingsPopup.tsx @@ -0,0 +1,419 @@ +import { useState, type FormEvent } from "react"; +import { + useSetPasswordMutation, + useSendOneTimePasswordMutation, + useDeleteAccountMutation, + useRequestDeleteAccountMutation, +} from "./auth.api"; +import { useAppDispatch, useAppSelector } from "../../store"; +import { closePopup } from "../../portals/popup.slice"; + +type Section = "password" | "otp" | "delete"; + +const inputClass = + "w-full px-4 py-3 bg-white/10 dark:bg-black/20 border border-white/10 rounded-xl focus:outline-none focus:ring-2 focus:ring-purple-400/50 text-(--color-text) transitioned hover:bg-white/20 dark:hover:bg-black/30 placeholder-gray-400"; + +const primaryButtonClass = + "w-full py-3 rounded-xl font-bold text-white shadow-lg transform active:scale-95 transition-all cursor-pointer hover:shadow-purple-500/30 hover:-translate-y-0.5 disabled:opacity-60 disabled:cursor-not-allowed disabled:hover:translate-y-0"; + +const dangerButtonClass = + "w-full py-3 rounded-xl font-bold text-white shadow-lg transform active:scale-95 transition-all cursor-pointer bg-red-700 hover:bg-red-800 disabled:opacity-60 disabled:cursor-not-allowed"; + +const secondaryButtonClass = + "w-full cursor-pointer py-3 rounded-xl font-semibold text-sm border border-white/20 text-(--color-text) hover:bg-white/10 transition-colors disabled:opacity-60"; + +const tabButtonClass = (active: boolean) => + `flex-1 px-3 py-2 rounded-lg text-sm font-semibold transition-colors cursor-pointer ${ + active + ? "bg-purple-600/30 text-white border border-purple-400/40" + : "text-(--color-text) opacity-70 hover:opacity-100 hover:bg-white/5 border border-transparent" + }`; + +const extractServerError = (error: unknown): string | null => { + if ( + error && + typeof error === "object" && + "data" in error && + error.data && + typeof error.data === "object" && + "error" in error.data + ) { + return String((error.data as { error: unknown }).error); + } + return null; +}; + +export function AccountSettingsPopup() { + const dispatch = useAppDispatch(); + const currentUser = useAppSelector((state) => state.auth.user); + + const [section, setSection] = useState
("password"); + + if (!currentUser) { + return ( +

+ You need to be signed in to manage your account. +

+ ); + } + + const hasPassword = currentUser.hasPassword ?? true; + + return ( +
+
+ Signed in as {currentUser.login} + {currentUser.email && ( + {currentUser.email} + )} +
+ +
+ + + +
+ + {section === "password" && ( + dispatch(closePopup())} + /> + )} + {section === "otp" && } + {section === "delete" && ( + dispatch(closePopup())} + /> + )} +
+ ); +} + +function PasswordSection({ + hasPassword, + onDone, +}: { + hasPassword: boolean; + onDone: () => void; +}) { + const [oldPassword, setOldPassword] = useState(""); + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [formError, setFormError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + const [setPasswordMutation, { isLoading, error, isError }] = + useSetPasswordMutation(); + + const serverError = isError ? extractServerError(error) : null; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setFormError(null); + setSuccessMessage(null); + + if (password.length < 6) { + setFormError("Password must be at least 6 characters."); + return; + } + if (password !== confirm) { + setFormError("Passwords do not match."); + return; + } + if (hasPassword && !oldPassword) { + setFormError("Current password is required."); + return; + } + + try { + const response = await setPasswordMutation( + hasPassword ? { password, oldPassword } : { password }, + ).unwrap(); + setSuccessMessage(response.message); + setOldPassword(""); + setPassword(""); + setConfirm(""); + setTimeout(onDone, 1500); + } catch { + // server error surfaces via `error` + } + }; + + return ( +
+

+ {hasPassword + ? "Change your password. You will stay signed in on this device; revoke other sessions from the Sign-in popup if needed." + : "Set a password so you can also sign in without Google."} +

+ + {successMessage && ( +
+ {successMessage} +
+ )} + {(formError || serverError) && ( +
+ {formError || serverError} +
+ )} + + {hasPassword && ( +
+ + setOldPassword(e.target.value)} + disabled={isLoading} + className={inputClass} + placeholder="Current password" + autoComplete="current-password" + /> +
+ )} + +
+ + setPassword(e.target.value)} + disabled={isLoading} + className={inputClass} + placeholder="At least 6 characters" + autoComplete="new-password" + /> +
+ +
+ + setConfirm(e.target.value)} + disabled={isLoading} + className={inputClass} + placeholder="Repeat new password" + autoComplete="new-password" + /> +
+ + +
+ ); +} + +function OtpSection() { + const [sendOtp, { isLoading, error, isError }] = + useSendOneTimePasswordMutation(); + const [message, setMessage] = useState(null); + + const serverError = isError ? extractServerError(error) : null; + + const handleSend = async () => { + setMessage(null); + try { + const response = await sendOtp().unwrap(); + setMessage(response.message); + } catch { + // surfaced via `error` + } + }; + + return ( +
+

+ Sends a one-time password to your email and replaces your current + password with it. Useful if you signed up with Google and want to log in + manually, or if you forgot your password while signed in here. +

+ + {message && ( +
+ {message} +
+ )} + {serverError && ( +
+ {serverError} +
+ )} + + +
+ ); +} + +function DeleteSection({ + hasPassword, + onDeleted, +}: { + hasPassword: boolean; + onDeleted: () => void; +}) { + const [password, setPassword] = useState(""); + const [confirmation, setConfirmation] = useState(""); + const [formError, setFormError] = useState(null); + const [emailNotice, setEmailNotice] = useState(null); + + const [deleteAccount, { isLoading: isDeleting, error: deleteError }] = + useDeleteAccountMutation(); + const [requestDelete, { isLoading: isRequesting, error: requestError }] = + useRequestDeleteAccountMutation(); + + const serverError = + extractServerError(deleteError) || extractServerError(requestError); + + const handleDelete = async () => { + setFormError(null); + if (confirmation !== "DELETE") { + setFormError('Type "DELETE" in the confirmation box to proceed.'); + return; + } + if (hasPassword && !password) { + setFormError("Password is required to confirm deletion."); + return; + } + try { + await deleteAccount(hasPassword ? { password } : {}).unwrap(); + onDeleted(); + } catch { + // surfaced via `serverError` + } + }; + + const handleRequestEmail = async () => { + setEmailNotice(null); + try { + const response = await requestDelete().unwrap(); + setEmailNotice(response.message); + } catch { + // surfaced via `serverError` + } + }; + + return ( +
+
+ Deleting your account removes all wallets, transactions and sessions. + This cannot be undone. +
+ + {(formError || serverError) && ( +
+ {formError || serverError} +
+ )} + + {emailNotice && ( +
+ {emailNotice} +
+ )} + + {hasPassword ? ( + <> +
+ + setPassword(e.target.value)} + disabled={isDeleting} + className={inputClass} + placeholder="Current password" + autoComplete="current-password" + /> +
+
+ + setConfirmation(e.target.value)} + disabled={isDeleting} + className={inputClass} + placeholder="DELETE" + autoComplete="off" + /> +
+ + + ) : ( + <> +

+ Your account has no password (Google sign-in only). We will email + you a confirmation link — click it to delete. The link expires in 1 + hour. +

+ + + )} +
+ ); +} diff --git a/apps/frontend/src/modules/Auth/AuthPopup.tsx b/apps/frontend/src/modules/Auth/AuthPopup.tsx index 1ac6064..63fa8bd 100644 --- a/apps/frontend/src/modules/Auth/AuthPopup.tsx +++ b/apps/frontend/src/modules/Auth/AuthPopup.tsx @@ -4,6 +4,8 @@ import { useLoginUserMutation, useRegisterUserMutation, useLogoutUserMutation, + useLogoutAllSessionsMutation, + useResendVerificationMutation, } from "./auth.api"; import { LoginSchema, @@ -14,8 +16,32 @@ import { import { useAppDispatch, useAppSelector } from "../../store"; import { closePopup } from "../../portals/popup.slice"; -type CombinedFormKeys = "login" | "password" | "email"; -type FormErrors = Partial>; +type Stage = "signin" | "signup" | "verifying"; +type FormKeys = "login" | "password" | "email"; +type FormErrors = Partial>; + +const inputClass = + "w-full px-4 py-3 bg-white/10 dark:bg-black/20 border border-white/10 rounded-xl focus:outline-none focus:ring-2 focus:ring-purple-400/50 text-(--color-text) transitioned hover:bg-white/20 dark:hover:bg-black/30 placeholder-gray-400"; + +const primaryButtonClass = + "w-full py-4 rounded-xl font-bold text-white text-lg shadow-lg transform active:scale-95 transition-all duration-200 cursor-pointer hover:shadow-purple-500/30 hover:-translate-y-1"; + +const secondaryButtonClass = + "w-full cursor-pointer py-3 rounded-xl font-semibold text-sm border border-white/20 text-(--color-text) hover:bg-white/10 transition-colors disabled:opacity-60"; + +const extractServerError = (error: unknown): string | null => { + if ( + error && + typeof error === "object" && + "data" in error && + error.data && + typeof error.data === "object" && + "error" in error.data + ) { + return String((error.data as { error: unknown }).error); + } + return null; +}; export function AuthPopup() { const dispatch = useAppDispatch(); @@ -23,14 +49,19 @@ export function AuthPopup() { import.meta.env.VITE_API_BASE_URL || "https://coinradar-wmzg.onrender.com/api/"; - const [isLoginMode, setIsLoginMode] = useState(true); + const currentUser = useAppSelector((state) => state.auth.user); + + const [stage, setStage] = useState("signin"); const [showPassword, setShowPassword] = useState(false); - const [logoutUser] = useLogoutUserMutation(); + const [verifyLogin, setVerifyLogin] = useState(""); + const [verifyEmail, setVerifyEmail] = useState(""); + const [resendNotice, setResendNotice] = useState(null); + const [loginData, setLoginData] = useState({ login: "", password: "", }); - const [registerData, setRegister] = useState({ + const [registerData, setRegisterData] = useState({ login: "", password: "", email: "", @@ -50,17 +81,64 @@ export function AuthPopup() { isError: isRegisterError, }, ] = useRegisterUserMutation(); + const [logoutUser, { isLoading: isLogoutLoading }] = useLogoutUserMutation(); + const [logoutAllSessions, { isLoading: isLogoutAllLoading }] = + useLogoutAllSessionsMutation(); + const [resendVerification, { isLoading: isResendLoading }] = + useResendVerificationMutation(); - const currentUser = useAppSelector((state) => state.auth.user); + if (currentUser) { + return ( +
+

+ Signed in +

+
+

+ Logged in as {currentUser.login} +

+ {currentUser.email && ( +

{currentUser.email}

+ )} +
+ + + +
+ ); + } + + const isLoginMode = stage === "signin"; const formData = isLoginMode ? loginData : registerData; - const setFormData = isLoginMode ? setLoginData : setRegister; + const setFormData = isLoginMode ? setLoginData : setRegisterData; const currentSchema = isLoginMode ? LoginSchema : RegisterSchema; - const currentMutation = isLoginMode ? loginUser : registerUser; - const isLoading = isLoginLoading || isRegisterLoading; const isError = isLoginError || isRegisterError; const currentError = isLoginMode ? loginError : registerError; + const serverErrorMessage = isError ? extractServerError(currentError) : null; const handleChange = (e: ChangeEvent) => { const { name, value } = e.target; @@ -70,7 +148,7 @@ export function AuthPopup() { ) => void )((prev) => ({ ...prev, [name]: value }) as Login | Register); - if (formErrors[name as CombinedFormKeys]) { + if (formErrors[name as FormKeys]) { setFormErrors((prev) => ({ ...prev, [name]: undefined })); } }; @@ -80,15 +158,11 @@ export function AuthPopup() { setFormErrors({}); const result = currentSchema.safeParse(formData); - if (!result.success) { const newErrors: FormErrors = {}; for (const issue of result.error.issues) { - if ( - issue.path.length > 0 && - !newErrors[issue.path[0] as CombinedFormKeys] - ) { - newErrors[issue.path[0] as CombinedFormKeys] = issue.message; + if (issue.path.length > 0 && !newErrors[issue.path[0] as FormKeys]) { + newErrors[issue.path[0] as FormKeys] = issue.message; } } setFormErrors(newErrors); @@ -96,28 +170,105 @@ export function AuthPopup() { } try { - await ( - currentMutation as (arg: Login | Register) => { - unwrap: () => Promise; - } - )(result.data as Login | Register).unwrap(); - dispatch(closePopup()); + if (isLoginMode) { + await loginUser(result.data as Login).unwrap(); + dispatch(closePopup()); + } else { + const registered = await registerUser(result.data as Register).unwrap(); + setVerifyLogin((result.data as Register).login); + setVerifyEmail(registered.email); + setStage("verifying"); + } } catch (err) { + // 403 on login means email not verified — switch to the verify screen + // with the right login pre-filled so resend works. + if ( + err && + typeof err === "object" && + "status" in err && + (err as { status: number }).status === 403 && + "data" in err && + err.data && + typeof err.data === "object" && + "requiresVerification" in err.data + ) { + const data = err.data as { email?: string }; + setVerifyLogin((result.data as Login).login); + setVerifyEmail(data.email || ""); + setStage("verifying"); + return; + } console.error("API Error:", err); } }; - const serverErrorMessage = - isError && currentError && "data" in currentError - ? (currentError.data as { error: string })?.error || - "Unknown server error" - : null; - const handleContinueWithGoogle = () => { window.location.href = `${BASE_URL}auth/google/start`; dispatch(closePopup()); }; + const handleResend = async () => { + setResendNotice(null); + try { + const response = await resendVerification({ + login: verifyLogin, + }).unwrap(); + setResendNotice(response.message); + } catch { + setResendNotice( + "Could not resend right now. Please try again in a moment.", + ); + } + }; + + if (stage === "verifying") { + return ( +
+

+ Check your inbox +

+

+ We sent a confirmation link to + {verifyEmail ? ( + <> + {" "} + {verifyEmail}. + + ) : ( + " your email." + )}{" "} + Click it to activate your account, then come back to sign in. +

+ + {resendNotice && ( +
+ {resendNotice} +
+ )} + + + + +
+ ); + } + return (

@@ -141,9 +292,7 @@ export function AuthPopup() { value={formData.login} onChange={handleChange} disabled={isLoading} - className="w-full px-4 py-3 bg-white/10 dark:bg-black/20 border border-white/10 - rounded-xl focus:outline-none focus:ring-2 focus:ring-purple-400/50 - text-(--color-text) transitioned hover:bg-white/20 dark:hover:bg-black/30 placeholder-gray-400" + className={inputClass} placeholder="Enter your login name" /> {formErrors.login && ( @@ -164,9 +313,7 @@ export function AuthPopup() { value={registerData.email} onChange={handleChange} disabled={isLoading} - className="w-full px-4 py-3 bg-white/10 dark:bg-black/20 border border-white/10 - rounded-xl focus:outline-none focus:ring-2 focus:ring-purple-400/50 - text-(--color-text) transitioned hover:bg-white/20 dark:hover:bg-black/30 placeholder-gray-400" + className={inputClass} placeholder="Enter your email address" /> {formErrors.email && ( @@ -187,9 +334,7 @@ export function AuthPopup() { value={formData.password} onChange={handleChange} disabled={isLoading} - className="w-full px-4 py-3 bg-white/10 dark:bg-black/20 border border-white/10 - rounded-xl focus:outline-none focus:ring-2 focus:ring-purple-400/50 - text-(--color-text) transitioned hover:bg-white/20 dark:hover:bg-black/30 placeholder-gray-400" + className={inputClass} placeholder="Enter your password" /> - - {currentUser && isLoginMode && ( - - )}
@@ -287,7 +417,7 @@ export function AuthPopup() { + ); +} diff --git a/apps/frontend/src/modules/Auth/auth.api.ts b/apps/frontend/src/modules/Auth/auth.api.ts index f830bd5..fb66b67 100644 --- a/apps/frontend/src/modules/Auth/auth.api.ts +++ b/apps/frontend/src/modules/Auth/auth.api.ts @@ -95,6 +95,69 @@ export const authApi = createApi({ }, invalidatesTags: ["User"], }), + + logoutAllSessions: builder.mutation<{ message: string }, void>({ + query: () => ({ + url: "auth/logout-all", + method: "POST", + }), + async onQueryStarted(_, { dispatch, queryFulfilled }) { + try { + await queryFulfilled; + } finally { + dispatch(logout()); + dispatch(clearWalletState()); + dispatch(authApi.util.resetApiState()); + } + }, + invalidatesTags: ["User"], + }), + + setPassword: builder.mutation< + { message: string }, + { password: string; oldPassword?: string } + >({ + query: (body) => ({ + url: "auth/set-password", + method: "POST", + body, + }), + invalidatesTags: ["User"], + }), + + sendOneTimePassword: builder.mutation<{ message: string }, void>({ + query: () => ({ + url: "auth/send-one-time-password", + method: "POST", + }), + }), + + deleteAccount: builder.mutation<{ message: string }, { password?: string }>( + { + query: (body) => ({ + url: "auth/account", + method: "DELETE", + body, + }), + async onQueryStarted(_, { dispatch, queryFulfilled }) { + try { + await queryFulfilled; + dispatch(logout()); + dispatch(clearWalletState()); + dispatch(authApi.util.resetApiState()); + } catch (error) { + console.error("Delete account error:", error); + } + }, + }, + ), + + requestDeleteAccount: builder.mutation<{ message: string }, void>({ + query: () => ({ + url: "auth/account/request-delete", + method: "POST", + }), + }), }), }); @@ -104,4 +167,9 @@ export const { useRegisterUserMutation, useResendVerificationMutation, useGetCurrentUserQuery, + useLogoutAllSessionsMutation, + useSetPasswordMutation, + useSendOneTimePasswordMutation, + useDeleteAccountMutation, + useRequestDeleteAccountMutation, } = authApi; diff --git a/apps/frontend/src/modules/Auth/auth.schema.ts b/apps/frontend/src/modules/Auth/auth.schema.ts index 4588dde..27b4601 100644 --- a/apps/frontend/src/modules/Auth/auth.schema.ts +++ b/apps/frontend/src/modules/Auth/auth.schema.ts @@ -10,6 +10,7 @@ export const UserSchema = z.object({ .max(30), email: z.string().email().nullable().optional(), emailVerified: z.boolean().optional(), + hasPassword: z.boolean().optional(), token: z.string().optional(), wallets: z.array(WalletListItemResponseSchema).optional(), diff --git a/apps/frontend/src/modules/FixedHeader/FixedHeader.tsx b/apps/frontend/src/modules/FixedHeader/FixedHeader.tsx index 2f78585..bf8ac62 100644 --- a/apps/frontend/src/modules/FixedHeader/FixedHeader.tsx +++ b/apps/frontend/src/modules/FixedHeader/FixedHeader.tsx @@ -3,6 +3,7 @@ import { menu, type MenuItem } from "./data"; import { useBurgerMenu } from "../../hooks/useBurgerMenu"; import { scrollToSectionById } from "../../utils/functions"; import { Auth } from "../Auth/Auth"; +import { Settings } from "../Auth/Settings"; export function FixedHeader() { const { isBurgerOpen, toggleBurger, isMobile } = useBurgerMenu(); @@ -36,6 +37,7 @@ export function FixedHeader() { ))} + ); }; From b5bcf5f3317c3aa276b8d878e321c8088a60acb3 Mon Sep 17 00:00:00 2001 From: Bohdan Date: Thu, 21 May 2026 00:23:06 +0300 Subject: [PATCH 10/18] fix(auth-ui): drop DELETE typing confirmation and add header settings avatar --- .../migration.sql | 4 + apps/backend/prisma/schema.prisma | 1 + apps/backend/src/app.ts | 3 +- .../backend/src/controllers/authController.ts | 82 ++++++++ apps/backend/src/models/AuthSchema.ts | 16 ++ apps/backend/src/router/authRouter.ts | 2 + .../src/modules/Auth/AccountSettingsPopup.tsx | 192 +++++++++++++++--- apps/frontend/src/modules/Auth/Settings.tsx | 18 +- apps/frontend/src/modules/Auth/auth.api.ts | 22 ++ apps/frontend/src/modules/Auth/auth.schema.ts | 1 + 10 files changed, 315 insertions(+), 26 deletions(-) create mode 100644 apps/backend/prisma/migrations/20260521080000_add_user_photo_url/migration.sql diff --git a/apps/backend/prisma/migrations/20260521080000_add_user_photo_url/migration.sql b/apps/backend/prisma/migrations/20260521080000_add_user_photo_url/migration.sql new file mode 100644 index 0000000..7945313 --- /dev/null +++ b/apps/backend/prisma/migrations/20260521080000_add_user_photo_url/migration.sql @@ -0,0 +1,4 @@ +-- Stores a URL or a data: URL (base64-encoded image) for the user avatar. +-- Populated from Google profile on first Google sign-in; user can override +-- via account settings. +ALTER TABLE "User" ADD COLUMN "photoUrl" TEXT; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index dd3c5c1..cec85ff 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -53,6 +53,7 @@ model User { password String? email String? @unique emailVerified Boolean @default(false) + photoUrl String? wallets Wallet[] refreshTokens RefreshToken[] authIdentities AuthIdentity[] diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index ef21d9c..f783be7 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -17,7 +17,8 @@ const allowedOrigins = ( .map((origin: string) => origin.trim()) .filter(Boolean); -app.use(express.json()); +// 1 MB cap leaves room for base64-encoded avatar uploads without inviting abuse. +app.use(express.json({ limit: "1mb" })); app.use( cors({ origin: allowedOrigins, diff --git a/apps/backend/src/controllers/authController.ts b/apps/backend/src/controllers/authController.ts index 435a984..9b077f0 100644 --- a/apps/backend/src/controllers/authController.ts +++ b/apps/backend/src/controllers/authController.ts @@ -11,6 +11,7 @@ import { ResendVerificationSchema, SetPasswordSchema, DeleteAccountSchema, + UpdateProfileSchema, UserSchema, } from "../models/AuthSchema.js"; import { handleZodError } from "../utils/helpers.js"; @@ -189,6 +190,7 @@ const toSafeUserResponse = (user: UserWithWallets) => { email: user.email, emailVerified: user.emailVerified, hasPassword: user.password !== null, + photoUrl: user.photoUrl, wallets: user.wallets.map((wallet: WalletListItem) => ({ id: wallet.id, name: wallet.name, @@ -362,6 +364,7 @@ const getGoogleProfileFromCode = async (code: string) => { email?: string; email_verified?: string; name?: string; + picture?: string; }; if (info.aud !== GOOGLE_CLIENT_ID) { @@ -377,6 +380,7 @@ const getGoogleProfileFromCode = async (code: string) => { email: info.email, emailVerified: info.email_verified === "true", name: info.name || info.email, + picture: info.picture || null, }; }; @@ -729,6 +733,7 @@ export const googleAuthCallback = async (req: Request, res: Response) => { email: profile.email, password: null, emailVerified: profile.emailVerified, + photoUrl: profile.picture, }, include: userInclude, }); @@ -1134,6 +1139,83 @@ export const requestDeleteAccount = async (req: Request, res: Response) => { } }; +export const updateProfile = async (req: Request, res: Response) => { + try { + const userId = req.userId; + if (!userId) { + return res.status(401).json({ error: "Unauthorized." }); + } + + const parsed = UpdateProfileSchema.parse(req.body ?? {}); + if (parsed.login === undefined && parsed.photoUrl === undefined) { + return res.status(400).json({ error: "Nothing to update." }); + } + + const data: Prisma.UserUpdateInput = {}; + if (parsed.login !== undefined) { + data.login = parsed.login; + } + if (parsed.photoUrl !== undefined) { + // Treat an empty string as "clear the photo". + data.photoUrl = parsed.photoUrl === "" ? null : parsed.photoUrl; + } + + let updated; + try { + updated = await prisma.user.update({ + where: { id: userId }, + data, + include: { + wallets: { + select: { id: true, name: true }, + orderBy: { createdAt: "asc" }, + }, + }, + }); + } catch (e: unknown) { + if ( + e && + typeof e === "object" && + "code" in e && + (e as { code: string }).code === "P2002" + ) { + return res.status(409).json({ error: "Login is already taken." }); + } + throw e; + } + + // Login is encoded in the access JWT — re-sign it so this device picks up + // the new value on its next protected call without waiting for refresh. + if (parsed.login !== undefined && parsed.login !== updated.login) { + // updated.login already reflects the new value; this branch is just a + // belt-and-suspenders check, the actual cookie reset happens below. + } + if (parsed.login !== undefined) { + const newAccess = signAccessToken(updated.id, updated.login); + res.cookie(ACCESS_COOKIE_NAME, newAccess, { + httpOnly: true, + secure: cookieSecure, + sameSite: normalizedSameSite, + maxAge: 15 * 60 * 1000, + path: "/", + }); + } + + return res.status(200).json({ + message: "Profile updated.", + user: toSafeUserResponse(updated), + }); + } catch (error) { + if (error instanceof z.ZodError) { + return handleZodError(res, error); + } + console.error("Update profile error:", error); + return res + .status(500) + .json({ error: "Server error during profile update." }); + } +}; + export const confirmDeleteAccount = async (req: Request, res: Response) => { try { const token = typeof req.query.token === "string" ? req.query.token : ""; diff --git a/apps/backend/src/models/AuthSchema.ts b/apps/backend/src/models/AuthSchema.ts index 3a7034c..9d41009 100644 --- a/apps/backend/src/models/AuthSchema.ts +++ b/apps/backend/src/models/AuthSchema.ts @@ -44,3 +44,19 @@ export const SetPasswordSchema = z.object({ export const DeleteAccountSchema = z.object({ password: z.string().min(1).optional(), }); + +// photoUrl tolerates http(s) URLs and base64 data URLs alike; length caps +// abuse via the 1 MB JSON body limit. +export const UpdateProfileSchema = z.object({ + login: z + .string() + .trim() + .min(3, "Login must be at least 3 characters") + .max(30) + .optional(), + photoUrl: z + .string() + .max(800_000, "Image is too large (max ~600 KB).") + .nullable() + .optional(), +}); diff --git a/apps/backend/src/router/authRouter.ts b/apps/backend/src/router/authRouter.ts index 80258b2..6736363 100644 --- a/apps/backend/src/router/authRouter.ts +++ b/apps/backend/src/router/authRouter.ts @@ -18,6 +18,7 @@ import { deleteAccount, requestDeleteAccount, confirmDeleteAccount, + updateProfile, } from "../controllers/authController.js"; import { protect } from "../middleware/authMiddleware.js"; @@ -34,6 +35,7 @@ authRouter.get("/me", protect, getCurrentUser); authRouter.post("/logout-all", protect, logoutAllUserSessions); authRouter.post("/set-password", protect, setPassword); authRouter.post("/send-one-time-password", protect, sendOneTimePassword); +authRouter.patch("/me", protect, updateProfile); authRouter.delete("/account", protect, deleteAccount); authRouter.post("/account/request-delete", protect, requestDeleteAccount); authRouter.get("/account/confirm-delete", confirmDeleteAccount); diff --git a/apps/frontend/src/modules/Auth/AccountSettingsPopup.tsx b/apps/frontend/src/modules/Auth/AccountSettingsPopup.tsx index 25e7d85..157a788 100644 --- a/apps/frontend/src/modules/Auth/AccountSettingsPopup.tsx +++ b/apps/frontend/src/modules/Auth/AccountSettingsPopup.tsx @@ -1,14 +1,18 @@ -import { useState, type FormEvent } from "react"; +import { useState, useRef, type FormEvent, type ChangeEvent } from "react"; import { useSetPasswordMutation, useSendOneTimePasswordMutation, useDeleteAccountMutation, useRequestDeleteAccountMutation, + useUpdateProfileMutation, } from "./auth.api"; +import type { UserSafe } from "./auth.schema"; import { useAppDispatch, useAppSelector } from "../../store"; import { closePopup } from "../../portals/popup.slice"; -type Section = "password" | "otp" | "delete"; +type Section = "profile" | "password" | "otp" | "delete"; + +const MAX_PHOTO_BYTES = 600 * 1024; const inputClass = "w-full px-4 py-3 bg-white/10 dark:bg-black/20 border border-white/10 rounded-xl focus:outline-none focus:ring-2 focus:ring-purple-400/50 text-(--color-text) transitioned hover:bg-white/20 dark:hover:bg-black/30 placeholder-gray-400"; @@ -47,7 +51,7 @@ export function AccountSettingsPopup() { const dispatch = useAppDispatch(); const currentUser = useAppSelector((state) => state.auth.user); - const [section, setSection] = useState
("password"); + const [section, setSection] = useState
("profile"); if (!currentUser) { return ( @@ -68,7 +72,14 @@ export function AccountSettingsPopup() { )}
-
+
+
+ {section === "profile" && } {section === "password" && ( (null); + const [login, setLogin] = useState(user.login); + const [photoUrl, setPhotoUrl] = useState( + user.photoUrl ?? null, + ); + const [fileError, setFileError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + + const [updateProfile, { isLoading, error, isError }] = + useUpdateProfileMutation(); + const serverError = isError ? extractServerError(error) : null; + + const dirty = login !== user.login || photoUrl !== (user.photoUrl ?? null); + + const handleFile = (e: ChangeEvent) => { + setFileError(null); + const file = e.target.files?.[0]; + if (!file) return; + if (!file.type.startsWith("image/")) { + setFileError("Please pick an image file."); + return; + } + if (file.size > MAX_PHOTO_BYTES) { + setFileError("Image is too large. Max ~600 KB."); + return; + } + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result === "string") setPhotoUrl(reader.result); + }; + reader.onerror = () => setFileError("Could not read the file."); + reader.readAsDataURL(file); + }; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setSuccessMessage(null); + if (!dirty) return; + + const payload: { login?: string; photoUrl?: string | null } = {}; + if (login !== user.login) payload.login = login.trim(); + if (photoUrl !== (user.photoUrl ?? null)) payload.photoUrl = photoUrl; + + try { + const response = await updateProfile(payload).unwrap(); + setSuccessMessage(response.message); + } catch { + // surfaced via `error` + } + }; + + return ( +
+
+
+ {photoUrl ? ( + Avatar setFileError("Could not load that image.")} + /> + ) : ( + + {user.login.slice(0, 1).toUpperCase()} + + )} +
+
+ + +
+ +
+ +
+ + setPhotoUrl(e.target.value || null)} + disabled={isLoading} + className={inputClass} + placeholder="https://example.com/avatar.png" + /> + {photoUrl?.startsWith("data:") && ( +

+ Currently using an uploaded image. Type a URL above to switch. +

+ )} +
+ +
+ + setLogin(e.target.value)} + disabled={isLoading} + className={inputClass} + placeholder="3-30 characters" + maxLength={30} + /> +
+ + {(fileError || serverError) && ( +
+ {fileError || serverError} +
+ )} + {successMessage && ( +
+ {successMessage} +
+ )} + + +
+ ); +} + function PasswordSection({ hasPassword, onDone, @@ -297,7 +462,6 @@ function DeleteSection({ onDeleted: () => void; }) { const [password, setPassword] = useState(""); - const [confirmation, setConfirmation] = useState(""); const [formError, setFormError] = useState(null); const [emailNotice, setEmailNotice] = useState(null); @@ -311,10 +475,6 @@ function DeleteSection({ const handleDelete = async () => { setFormError(null); - if (confirmation !== "DELETE") { - setFormError('Type "DELETE" in the confirmation box to proceed.'); - return; - } if (hasPassword && !password) { setFormError("Password is required to confirm deletion."); return; @@ -372,20 +532,6 @@ function DeleteSection({ autoComplete="current-password" />
-
- - setConfirmation(e.target.value)} - disabled={isDeleting} - className={inputClass} - placeholder="DELETE" - autoComplete="off" - /> -
); } diff --git a/apps/frontend/src/modules/Auth/auth.api.ts b/apps/frontend/src/modules/Auth/auth.api.ts index fb66b67..b806eb0 100644 --- a/apps/frontend/src/modules/Auth/auth.api.ts +++ b/apps/frontend/src/modules/Auth/auth.api.ts @@ -158,6 +158,27 @@ export const authApi = createApi({ method: "POST", }), }), + + updateProfile: builder.mutation< + { message: string; user: UserSafe }, + { login?: string; photoUrl?: string | null } + >({ + query: (body) => ({ + url: "auth/me", + method: "PATCH", + body, + }), + async onQueryStarted(_, { dispatch, queryFulfilled }) { + try { + const { data: responseData } = await queryFulfilled; + const parsedUser: UserSafe = UserSchema.parse(responseData.user); + dispatch(setUserData(parsedUser)); + } catch (error) { + console.error("Update profile error:", error); + } + }, + invalidatesTags: ["User"], + }), }), }); @@ -172,4 +193,5 @@ export const { useSendOneTimePasswordMutation, useDeleteAccountMutation, useRequestDeleteAccountMutation, + useUpdateProfileMutation, } = authApi; diff --git a/apps/frontend/src/modules/Auth/auth.schema.ts b/apps/frontend/src/modules/Auth/auth.schema.ts index 27b4601..efba6cb 100644 --- a/apps/frontend/src/modules/Auth/auth.schema.ts +++ b/apps/frontend/src/modules/Auth/auth.schema.ts @@ -11,6 +11,7 @@ export const UserSchema = z.object({ email: z.string().email().nullable().optional(), emailVerified: z.boolean().optional(), hasPassword: z.boolean().optional(), + photoUrl: z.string().nullable().optional(), token: z.string().optional(), wallets: z.array(WalletListItemResponseSchema).optional(), From 46d6bf987995e2ad9d097b0f920b556ae40e2efd Mon Sep 17 00:00:00 2001 From: Bohdan Date: Thu, 21 May 2026 00:46:25 +0300 Subject: [PATCH 11/18] fix(auth): profile photo persistence, google-link flow, and auth UI copy - return photoUrl in safe user schema - allow Google linking when the same user starts Google auth - update header labels and profile copy - normalize photo URL handling --- apps/backend/src/app.ts | 1 - .../backend/src/controllers/authController.ts | 64 +++++++++++++------ apps/backend/src/models/AuthSchema.ts | 8 +-- .../src/modules/Auth/AccountSettingsPopup.tsx | 33 ++++------ apps/frontend/src/modules/Auth/Auth.tsx | 2 +- apps/frontend/src/modules/Auth/AuthPopup.tsx | 2 - .../src/modules/Auth/AuthQueryParamToast.tsx | 7 -- apps/frontend/src/modules/Auth/Settings.tsx | 2 +- 8 files changed, 62 insertions(+), 57 deletions(-) diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index f783be7..8ca3f85 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -17,7 +17,6 @@ const allowedOrigins = ( .map((origin: string) => origin.trim()) .filter(Boolean); -// 1 MB cap leaves room for base64-encoded avatar uploads without inviting abuse. app.use(express.json({ limit: "1mb" })); app.use( cors({ diff --git a/apps/backend/src/controllers/authController.ts b/apps/backend/src/controllers/authController.ts index 9b077f0..65740fe 100644 --- a/apps/backend/src/controllers/authController.ts +++ b/apps/backend/src/controllers/authController.ts @@ -25,7 +25,7 @@ import { const EMAIL_VERIFY_TTL_MS = 24 * 60 * 60 * 1000; const MERGE_TOKEN_TTL_MS = 60 * 60 * 1000; const DELETE_TOKEN_TTL_MS = 60 * 60 * 1000; -const OTP_BYTES = 12; // ~16 base64url chars +const OTP_BYTES = 12; const JWT_SECRET = process.env.JWT_SECRET; const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || JWT_SECRET; @@ -94,6 +94,19 @@ const parseCookieHeader = (cookieHeader?: string): Record => { }, {}); }; +const getUserIdFromAccessCookie = (req: Request): string | null => { + const cookies = parseCookieHeader(req.headers.cookie); + const accessToken = cookies[ACCESS_COOKIE_NAME]; + if (!accessToken || !JWT_SECRET) return null; + + try { + const decoded = jwt.verify(accessToken, JWT_SECRET) as { userId?: string }; + return decoded.userId ?? null; + } catch { + return null; + } +}; + const hashToken = (token: string): string => { return crypto.createHash("sha256").update(token).digest("hex"); }; @@ -206,8 +219,6 @@ const createEmailToken = async ( ttlMs: number, metadata?: Prisma.InputJsonValue, ): Promise<{ rawToken: string; expiresAt: Date }> => { - // Invalidate any prior un-consumed tokens of the same purpose for this user - // so each new email link supersedes the previous one. await prisma.emailToken.deleteMany({ where: { userId, purpose, consumedAt: null }, }); @@ -392,9 +403,6 @@ export const registerUser = async (req: Request, res: Response) => { const hashedPassword = await bcrypt.hash(password, saltRounds); const newUser = await prisma.$transaction(async (tx) => { - // Drop any prior unverified records holding the same login or email so - // (a) the legit owner can recover from a typo and (b) attackers cannot - // squat someone else's login by registering and never confirming. await tx.user.deleteMany({ where: { emailVerified: false, @@ -428,7 +436,6 @@ export const registerUser = async (req: Request, res: Response) => { await sendVerificationEmail(email, rawToken); } catch (mailError) { console.error("Failed to send verification email:", mailError); - // Account is created but email failed — user can use /resend-verification. } return res.status(201).json({ @@ -542,7 +549,6 @@ export const verifyGoogleMerge = async (req: Request, res: Response) => { return res.redirect(`${FRONTEND_URL}?auth=merge_error`); } - // Refuse if this google identity is already attached to a different user. const conflicting = await prisma.authIdentity.findUnique({ where: { provider_providerId: { provider: "google", providerId: metadata.sub }, @@ -589,7 +595,6 @@ export const resendVerification = async (req: Request, res: Response) => { const user = await prisma.user.findFirst({ where: { login } }); - // Always answer generically — do not leak whether the login exists. const genericResponse = { message: "If an account exists for that login and is unverified, a new verification email is on the way.", @@ -693,10 +698,32 @@ export const googleAuthCallback = async (req: Request, res: Response) => { }); if (existingByEmail) { - // Never auto-link a Google identity into an existing account, even if - // both sides are verified — proof-of-control over the email right now - // is required. Issue a short-lived merge token and email it to the - // address on file; the user attaches Google only by clicking it. + const authUserId = getUserIdFromAccessCookie(req); + const isSameAuthenticatedUser = + authUserId !== null && authUserId === existingByEmail.id; + + if (isSameAuthenticatedUser) { + await prisma.authIdentity.upsert({ + where: { + userId_provider: { + userId: existingByEmail.id, + provider: "google", + }, + }, + create: { + userId: existingByEmail.id, + provider: "google", + providerId: profile.sub, + }, + update: { + providerId: profile.sub, + }, + }); + + user = existingByEmail; + await createSession(user, req, res); + return res.redirect(`${FRONTEND_URL}?auth=google_success`); + } if (!profile.emailVerified || !existingByEmail.email) { return res.redirect(`${FRONTEND_URL}?auth=google_error`); } @@ -1156,8 +1183,9 @@ export const updateProfile = async (req: Request, res: Response) => { data.login = parsed.login; } if (parsed.photoUrl !== undefined) { - // Treat an empty string as "clear the photo". - data.photoUrl = parsed.photoUrl === "" ? null : parsed.photoUrl; + const normalizedPhotoUrl = + parsed.photoUrl === null ? null : parsed.photoUrl.trim(); + data.photoUrl = normalizedPhotoUrl === "" ? null : normalizedPhotoUrl; } let updated; @@ -1184,12 +1212,6 @@ export const updateProfile = async (req: Request, res: Response) => { throw e; } - // Login is encoded in the access JWT — re-sign it so this device picks up - // the new value on its next protected call without waiting for refresh. - if (parsed.login !== undefined && parsed.login !== updated.login) { - // updated.login already reflects the new value; this branch is just a - // belt-and-suspenders check, the actual cookie reset happens below. - } if (parsed.login !== undefined) { const newAccess = signAccessToken(updated.id, updated.login); res.cookie(ACCESS_COOKIE_NAME, newAccess, { diff --git a/apps/backend/src/models/AuthSchema.ts b/apps/backend/src/models/AuthSchema.ts index 9d41009..0d06e8d 100644 --- a/apps/backend/src/models/AuthSchema.ts +++ b/apps/backend/src/models/AuthSchema.ts @@ -11,10 +11,10 @@ export const UserSchema = z.object({ email: z.string().email().nullable().optional(), emailVerified: z.boolean().optional(), hasPassword: z.boolean().optional(), + photoUrl: z.string().nullable().optional(), - token: z.string().optional(), // Access token - - wallets: z.array(WalletListItemResponseSchema).optional(), // Optional (without - from auth service) + token: z.string().optional(), + wallets: z.array(WalletListItemResponseSchema).optional(), }); export const RegisterSchema = z.object({ @@ -45,8 +45,6 @@ export const DeleteAccountSchema = z.object({ password: z.string().min(1).optional(), }); -// photoUrl tolerates http(s) URLs and base64 data URLs alike; length caps -// abuse via the 1 MB JSON body limit. export const UpdateProfileSchema = z.object({ login: z .string() diff --git a/apps/frontend/src/modules/Auth/AccountSettingsPopup.tsx b/apps/frontend/src/modules/Auth/AccountSettingsPopup.tsx index 157a788..da6eccb 100644 --- a/apps/frontend/src/modules/Auth/AccountSettingsPopup.tsx +++ b/apps/frontend/src/modules/Auth/AccountSettingsPopup.tsx @@ -163,14 +163,14 @@ function ProfileSection({ user }: { user: UserSafe }) { const payload: { login?: string; photoUrl?: string | null } = {}; if (login !== user.login) payload.login = login.trim(); - if (photoUrl !== (user.photoUrl ?? null)) payload.photoUrl = photoUrl; + if (photoUrl !== (user.photoUrl ?? null)) { + payload.photoUrl = photoUrl === null ? null : photoUrl.trim(); + } try { const response = await updateProfile(payload).unwrap(); setSuccessMessage(response.message); - } catch { - // surfaced via `error` - } + } catch {} }; return ( @@ -238,7 +238,7 @@ function ProfileSection({ user }: { user: UserSafe }) {
setLogin(e.target.value)} disabled={isLoading} className={inputClass} - placeholder="3-30 characters" + placeholder="Enter a new login (3-30 characters)" maxLength={30} /> +

+ If this login is already taken, you will see a notification after saving. +

{(fileError || serverError) && ( @@ -318,9 +321,7 @@ function PasswordSection({ setPassword(""); setConfirm(""); setTimeout(onDone, 1500); - } catch { - // server error surfaces via `error` - } + } catch {} }; return ( @@ -417,9 +418,7 @@ function OtpSection() { try { const response = await sendOtp().unwrap(); setMessage(response.message); - } catch { - // surfaced via `error` - } + } catch {} }; return ( @@ -482,9 +481,7 @@ function DeleteSection({ try { await deleteAccount(hasPassword ? { password } : {}).unwrap(); onDeleted(); - } catch { - // surfaced via `serverError` - } + } catch {} }; const handleRequestEmail = async () => { @@ -492,9 +489,7 @@ function DeleteSection({ try { const response = await requestDelete().unwrap(); setEmailNotice(response.message); - } catch { - // surfaced via `serverError` - } + } catch {} }; return ( @@ -545,7 +540,7 @@ function DeleteSection({ <>

Your account has no password (Google sign-in only). We will email - you a confirmation link — click it to delete. The link expires in 1 + you a confirmation link - click it to delete. The link expires in 1 hour.

); } diff --git a/apps/frontend/src/modules/Auth/AuthPopup.tsx b/apps/frontend/src/modules/Auth/AuthPopup.tsx index 63fa8bd..e428b31 100644 --- a/apps/frontend/src/modules/Auth/AuthPopup.tsx +++ b/apps/frontend/src/modules/Auth/AuthPopup.tsx @@ -180,8 +180,6 @@ export function AuthPopup() { setStage("verifying"); } } catch (err) { - // 403 on login means email not verified — switch to the verify screen - // with the right login pre-filled so resend works. if ( err && typeof err === "object" && diff --git a/apps/frontend/src/modules/Auth/AuthQueryParamToast.tsx b/apps/frontend/src/modules/Auth/AuthQueryParamToast.tsx index 91c4edd..ab320f0 100644 --- a/apps/frontend/src/modules/Auth/AuthQueryParamToast.tsx +++ b/apps/frontend/src/modules/Auth/AuthQueryParamToast.tsx @@ -80,21 +80,16 @@ export function AuthQueryParamToast() { const matched = MESSAGES[authParam]; if (matched) setNotice(matched); - // After account_deleted the cookies are already cleared on the server — - // wipe local auth state too so the UI immediately reflects sign-out. if (authParam === "account_deleted") { dispatch(logout()); dispatch(clearWalletState()); dispatch(authApi.util.resetApiState()); } - // After merge_confirmed the server opened a session — refetch /me so - // redux picks up the user. if (authParam === "merge_confirmed" || authParam === "google_success") { dispatch(authApi.util.invalidateTags(["User"])); } - // Scrub the param so refresh does not re-trigger the toast. params.delete("auth"); const next = window.location.pathname + @@ -103,8 +98,6 @@ export function AuthQueryParamToast() { window.history.replaceState({}, "", next); }, [dispatch]); - // Auto-dismiss runs off `notice` so StrictMode's unmount/remount does not - // cancel the timer without re-scheduling it. useEffect(() => { if (!notice) return; const timer = setTimeout(() => setNotice(null), 5000); diff --git a/apps/frontend/src/modules/Auth/Settings.tsx b/apps/frontend/src/modules/Auth/Settings.tsx index e048d77..3493766 100644 --- a/apps/frontend/src/modules/Auth/Settings.tsx +++ b/apps/frontend/src/modules/Auth/Settings.tsx @@ -36,7 +36,7 @@ export function Settings() { fallbackLetter )} - Settings + {currentUser.login} ); } From b07404c30012c4dfebf7f1c33186f0d72e52d52d Mon Sep 17 00:00:00 2001 From: Bohdan Date: Thu, 21 May 2026 01:12:26 +0300 Subject: [PATCH 12/18] test(backend): add auth security integration coverage Add auth.security.int.test.ts covering takeover protection, verification states, Google linking, set-password, account deletion and logout-all. --- .../integration/auth.security.int.test.ts | 440 ++++++++++++++++++ apps/backend/tests/setup/jest.setup.cjs | 9 + .../src/modules/Auth/AccountSettingsPopup.tsx | 23 +- 3 files changed, 466 insertions(+), 6 deletions(-) create mode 100644 apps/backend/tests/integration/auth.security.int.test.ts diff --git a/apps/backend/tests/integration/auth.security.int.test.ts b/apps/backend/tests/integration/auth.security.int.test.ts new file mode 100644 index 0000000..2238c32 --- /dev/null +++ b/apps/backend/tests/integration/auth.security.int.test.ts @@ -0,0 +1,440 @@ +import request from "supertest"; +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; +import prisma from "../../src/prisma.js"; +import { getApp, resetDatabase } from "../helpers/testUtils.js"; +import { __getCapturedEmails } from "../../src/services/emailService.js"; + +type MockGoogleProfile = { + sub: string; + email: string; + email_verified?: string; + name?: string; + picture?: string; +}; + +const rand = (prefix: string) => `${prefix}_${Math.random().toString(36).slice(2, 10)}`; +const loginOf = (prefix: string) => rand(prefix).slice(0, 30); + +const mockGoogleFetch = (profile: MockGoogleProfile) => { + type FetchLike = typeof globalThis.fetch; + const fetchMock = jest + .spyOn(globalThis, "fetch") + .mockImplementation(async (...args: Parameters) => { + const [input] = args; + const url = String(input); + if (url.includes("oauth2.googleapis.com/tokeninfo")) { + return { + ok: true, + json: async () => ({ + aud: process.env.GOOGLE_CLIENT_ID, + sub: profile.sub, + email: profile.email, + email_verified: profile.email_verified || "true", + name: profile.name || "Test User", + picture: profile.picture || null, + }), + } as Response; + } + if (url.includes("oauth2.googleapis.com/token")) { + return { + ok: true, + json: async () => ({ id_token: "id-token-test" }), + } as Response; + } + return { + ok: false, + json: async () => ({}), + } as Response; + }); + + return () => fetchMock.mockRestore(); +}; + +const startGoogleAndGetState = async (agent: ReturnType) => { + const startResponse = await agent.get("/api/auth/google/start"); + expect(startResponse.status).toBe(302); + const location = String(startResponse.headers.location || ""); + const state = new URL(location).searchParams.get("state"); + expect(state).toBeTruthy(); + return state as string; +}; + +const registerUser = async (login: string, password: string, email: string) => { + return request(getApp()).post("/api/auth/register").send({ + login, + password, + email, + }); +}; + +const verifyUserDirectly = async (login: string) => { + await prisma.user.update({ + where: { login }, + data: { emailVerified: true }, + }); +}; + +const loginUser = async (login: string, password: string) => { + return request(getApp()).post("/api/auth/login").send({ login, password }); +}; + +describe("Auth security flows", () => { + beforeEach(async () => { + process.env.GOOGLE_CLIENT_ID = + process.env.GOOGLE_CLIENT_ID || "test-google-client-id"; + process.env.GOOGLE_CLIENT_SECRET = + process.env.GOOGLE_CLIENT_SECRET || "test-google-client-secret"; + process.env.GOOGLE_REDIRECT_URI = + process.env.GOOGLE_REDIRECT_URI || + "http://localhost:4000/api/auth/google/callback"; + process.env.API_PUBLIC_URL = + process.env.API_PUBLIC_URL || "http://localhost:4000"; + + await resetDatabase(); + }); + + it("prevents takeover: second register can replace unverified squat on same login/email", async () => { + const login = loginOf("squat"); + const email = `${login}@mail.com`; + + const first = await registerUser(login, "password123", email); + expect(first.status).toBe(201); + + const firstUser = await prisma.user.findUnique({ where: { login } }); + expect(firstUser).toBeTruthy(); + + const second = await registerUser(login, "password456", email); + expect(second.status).toBe(201); + + const users = await prisma.user.findMany({ where: { login } }); + expect(users).toHaveLength(1); + expect(users[0]?.id).not.toBe(firstUser?.id); + }); + + it("requires merge-confirmation email when google sign-in hits an existing email account", async () => { + const login = loginOf("merge"); + const email = `${login}@mail.com`; + const password = "password123"; + + const registerResponse = await registerUser(login, password, email); + expect(registerResponse.status).toBe(201); + await verifyUserDirectly(login); + + const restoreFetch = mockGoogleFetch({ + sub: rand("gsub"), + email, + email_verified: "true", + name: login, + }); + + try { + const agent = request.agent(getApp()); + const state = await startGoogleAndGetState(agent); + const callback = await agent + .get("/api/auth/google/callback") + .query({ code: "test-code", state }); + + expect(callback.status).toBe(302); + expect(String(callback.headers.location)).toContain( + "auth=google_pending_merge", + ); + + const sent = __getCapturedEmails().filter( + (m) => m.purpose === "merge_google" && m.to === email, + ); + expect(sent.length).toBeGreaterThan(0); + expect(sent.at(-1)?.token).toBeTruthy(); + } finally { + restoreFetch(); + } + }); + + it("email verification link supports success, consumed, and expired cases", async () => { + const login = loginOf("verify"); + const email = `${login}@mail.com`; + + const registerResponse = await registerUser(login, "password123", email); + expect(registerResponse.status).toBe(201); + + const verifyMail = __getCapturedEmails() + .filter((m) => m.purpose === "verify_email" && m.to === email) + .at(-1); + expect(verifyMail?.token).toBeTruthy(); + + const success = await request(getApp()) + .get("/api/auth/verify-email") + .query({ token: verifyMail?.token }); + expect(success.status).toBe(302); + expect(String(success.headers.location)).toContain("auth=verified"); + + const consumed = await request(getApp()) + .get("/api/auth/verify-email") + .query({ token: verifyMail?.token }); + expect(consumed.status).toBe(302); + expect(String(consumed.headers.location)).toContain( + "auth=already_verified", + ); + + const secondLogin = loginOf("verifyexp"); + const secondEmail = `${secondLogin}@mail.com`; + const secondRegister = await registerUser( + secondLogin, + "password123", + secondEmail, + ); + expect(secondRegister.status).toBe(201); + + const secondMail = __getCapturedEmails() + .filter((m) => m.purpose === "verify_email" && m.to === secondEmail) + .at(-1); + expect(secondMail?.token).toBeTruthy(); + + await prisma.emailToken.updateMany({ + where: { purpose: "verify_email", consumedAt: null }, + data: { expiresAt: new Date(Date.now() - 1000) }, + }); + + const expired = await request(getApp()) + .get("/api/auth/verify-email") + .query({ token: secondMail?.token }); + expect(expired.status).toBe(302); + expect(String(expired.headers.location)).toContain("auth=verify_error"); + }); + + it("supports account linking both ways: merge-confirm and authenticated auto-link", async () => { + const login = loginOf("link"); + const email = `${login}@mail.com`; + const password = "password123"; + + const registerResponse = await registerUser(login, password, email); + expect(registerResponse.status).toBe(201); + await verifyUserDirectly(login); + + const restoreFetchA = mockGoogleFetch({ + sub: rand("gsubpend"), + email, + email_verified: "true", + }); + try { + const pendingAgent = request.agent(getApp()); + const pendingState = await startGoogleAndGetState(pendingAgent); + const pending = await pendingAgent + .get("/api/auth/google/callback") + .query({ code: "test-code", state: pendingState }); + expect(String(pending.headers.location)).toContain( + "google_pending_merge", + ); + + const mergeToken = __getCapturedEmails() + .filter((m) => m.purpose === "merge_google" && m.to === email) + .at(-1)?.token; + expect(mergeToken).toBeTruthy(); + + const verifyMerge = await pendingAgent + .get("/api/auth/verify-merge") + .query({ token: mergeToken }); + expect(verifyMerge.status).toBe(302); + expect(String(verifyMerge.headers.location)).toContain("merge_confirmed"); + } finally { + restoreFetchA(); + } + + const localOnlyLogin = loginOf("linklocal"); + const localOnlyEmail = `${localOnlyLogin}@mail.com`; + const reg2 = await registerUser(localOnlyLogin, password, localOnlyEmail); + expect(reg2.status).toBe(201); + await verifyUserDirectly(localOnlyLogin); + + const loginResponse = await loginUser(localOnlyLogin, password); + expect(loginResponse.status).toBe(200); + const cookieRaw = loginResponse.headers["set-cookie"]; + const loginCookies = Array.isArray(cookieRaw) + ? cookieRaw + : cookieRaw + ? [cookieRaw] + : []; + + const restoreFetchB = mockGoogleFetch({ + sub: rand("gsubauto"), + email: localOnlyEmail, + email_verified: "true", + }); + try { + const autoAgent = request.agent(getApp()); + const state = await startGoogleAndGetState(autoAgent); + const callback = await autoAgent + .get("/api/auth/google/callback") + .set("Cookie", loginCookies.join("; ")) + .query({ code: "test-code", state }); + + expect(callback.status).toBe(302); + expect(String(callback.headers.location)).toContain( + "auth=google_success", + ); + + const linked = await prisma.authIdentity.findFirst({ + where: { user: { login: localOnlyLogin }, provider: "google" }, + }); + expect(linked).toBeTruthy(); + } finally { + restoreFetchB(); + } + }); + + it("supports set-password and one-time-password flows", async () => { + const restoreFetch = mockGoogleFetch({ + sub: rand("gsubpwd"), + email: `${loginOf("guser")}@mail.com`, + email_verified: "true", + }); + + try { + const agent = request.agent(getApp()); + const state = await startGoogleAndGetState(agent); + const callback = await agent + .get("/api/auth/google/callback") + .query({ code: "test-code", state }); + expect(callback.status).toBe(302); + expect(String(callback.headers.location)).toContain( + "auth=google_success", + ); + + const setPasswordResponse = await agent + .post("/api/auth/set-password") + .send({ password: "new-password-123" }); + expect(setPasswordResponse.status).toBe(200); + + const me = await agent.get("/api/auth/me"); + expect(me.status).toBe(200); + expect(me.body?.user?.hasPassword).toBe(true); + + const otpResponse = await agent.post("/api/auth/send-one-time-password"); + expect(otpResponse.status).toBe(200); + + const meAfterOtp = await agent.get("/api/auth/me"); + expect(meAfterOtp.status).toBe(200); + const userLogin = meAfterOtp.body?.user?.login; + const userEmail = meAfterOtp.body?.user?.email; + expect(userLogin).toBeTruthy(); + expect(userEmail).toBeTruthy(); + + const otpMail = __getCapturedEmails() + .filter((m) => m.purpose === "one_time_password" && m.to === userEmail) + .at(-1); + expect(otpMail?.otp).toBeTruthy(); + + const loginWithOtp = await request(getApp()) + .post("/api/auth/login") + .send({ login: userLogin, password: otpMail?.otp }); + expect(loginWithOtp.status).toBe(200); + } finally { + restoreFetch(); + } + }); + + it("supports account deletion for local-password and google-only email-link cases", async () => { + const localLogin = loginOf("deletelocal"); + const localEmail = `${localLogin}@mail.com`; + const localPassword = "password123"; + + const localRegister = await registerUser( + localLogin, + localPassword, + localEmail, + ); + expect(localRegister.status).toBe(201); + await verifyUserDirectly(localLogin); + + const localAgent = request.agent(getApp()); + const localSignIn = await localAgent + .post("/api/auth/login") + .send({ login: localLogin, password: localPassword }); + expect(localSignIn.status).toBe(200); + + const localDelete = await localAgent + .delete("/api/auth/account") + .send({ password: localPassword }); + expect(localDelete.status).toBe(200); + + const localStillThere = await prisma.user.findUnique({ + where: { login: localLogin }, + }); + expect(localStillThere).toBeNull(); + + const restoreFetch = mockGoogleFetch({ + sub: rand("gsubdel"), + email: `${loginOf("gdel")}@mail.com`, + email_verified: "true", + }); + try { + const googleAgent = request.agent(getApp()); + const state = await startGoogleAndGetState(googleAgent); + const callback = await googleAgent + .get("/api/auth/google/callback") + .query({ code: "test-code", state }); + expect(callback.status).toBe(302); + + const requestDelete = await googleAgent.post( + "/api/auth/account/request-delete", + ); + expect(requestDelete.status).toBe(200); + + const me = await googleAgent.get("/api/auth/me"); + const googleLogin = me.body?.user?.login as string; + expect(googleLogin).toBeTruthy(); + + const deleteToken = __getCapturedEmails() + .filter((m) => m.purpose === "delete_account") + .at(-1)?.token; + expect(deleteToken).toBeTruthy(); + + const confirm = await request(getApp()) + .get("/api/auth/account/confirm-delete") + .query({ token: deleteToken }); + expect(confirm.status).toBe(302); + expect(String(confirm.headers.location)).toContain( + "auth=account_deleted", + ); + + const googleStillThere = await prisma.user.findUnique({ + where: { login: googleLogin }, + }); + expect(googleStillThere).toBeNull(); + } finally { + restoreFetch(); + } + }); + + it("revokes all refresh sessions on logout-all", async () => { + const login = loginOf("logoutall"); + const email = `${login}@mail.com`; + const password = "password123"; + + const registerResponse = await registerUser(login, password, email); + expect(registerResponse.status).toBe(201); + await verifyUserDirectly(login); + + const loginResponse = await request(getApp()) + .post("/api/auth/login") + .send({ login, password }); + expect(loginResponse.status).toBe(200); + + const cookieRaw = loginResponse.headers["set-cookie"]; + const cookies = Array.isArray(cookieRaw) + ? cookieRaw + : cookieRaw + ? [cookieRaw] + : []; + expect(cookies.join(";")).toContain("refresh_token="); + + const logoutAll = await request(getApp()) + .post("/api/auth/logout-all") + .set("Cookie", cookies); + expect(logoutAll.status).toBe(200); + + const refreshAfter = await request(getApp()) + .post("/api/auth/refresh") + .set("Cookie", cookies); + expect(refreshAfter.status).toBe(401); + }); +}); diff --git a/apps/backend/tests/setup/jest.setup.cjs b/apps/backend/tests/setup/jest.setup.cjs index 18accd5..695662c 100644 --- a/apps/backend/tests/setup/jest.setup.cjs +++ b/apps/backend/tests/setup/jest.setup.cjs @@ -6,6 +6,15 @@ process.env.CORS_ALLOWED_ORIGINS = process.env.CORS_ALLOWED_ORIGINS || "http://localhost:5173"; process.env.COOKIE_SAME_SITE = process.env.COOKIE_SAME_SITE || "lax"; process.env.NODE_ENV = "test"; +process.env.GOOGLE_CLIENT_ID = + process.env.GOOGLE_CLIENT_ID || "test-google-client-id"; +process.env.GOOGLE_CLIENT_SECRET = + process.env.GOOGLE_CLIENT_SECRET || "test-google-client-secret"; +process.env.GOOGLE_REDIRECT_URI = + process.env.GOOGLE_REDIRECT_URI || + "http://localhost:4000/api/auth/google/callback"; +process.env.API_PUBLIC_URL = + process.env.API_PUBLIC_URL || "http://localhost:4000"; if (!process.env.DATABASE_URL) { throw new Error("DATABASE_URL is missing in test environment."); diff --git a/apps/frontend/src/modules/Auth/AccountSettingsPopup.tsx b/apps/frontend/src/modules/Auth/AccountSettingsPopup.tsx index da6eccb..7846fb2 100644 --- a/apps/frontend/src/modules/Auth/AccountSettingsPopup.tsx +++ b/apps/frontend/src/modules/Auth/AccountSettingsPopup.tsx @@ -170,7 +170,9 @@ function ProfileSection({ user }: { user: UserSafe }) { try { const response = await updateProfile(payload).unwrap(); setSuccessMessage(response.message); - } catch {} + } catch (error) { + console.error("Update profile failed:", error); + } }; return ( @@ -250,7 +252,8 @@ function ProfileSection({ user }: { user: UserSafe }) { maxLength={30} />

- If this login is already taken, you will see a notification after saving. + If this login is already taken, you will see a notification after + saving.

@@ -321,7 +324,9 @@ function PasswordSection({ setPassword(""); setConfirm(""); setTimeout(onDone, 1500); - } catch {} + } catch (error) { + console.error("Set password failed:", error); + } }; return ( @@ -418,7 +423,9 @@ function OtpSection() { try { const response = await sendOtp().unwrap(); setMessage(response.message); - } catch {} + } catch (error) { + console.error("Send OTP failed:", error); + } }; return ( @@ -481,7 +488,9 @@ function DeleteSection({ try { await deleteAccount(hasPassword ? { password } : {}).unwrap(); onDeleted(); - } catch {} + } catch (error) { + console.error("Delete account failed:", error); + } }; const handleRequestEmail = async () => { @@ -489,7 +498,9 @@ function DeleteSection({ try { const response = await requestDelete().unwrap(); setEmailNotice(response.message); - } catch {} + } catch (error) { + console.error("Request delete email failed:", error); + } }; return ( From b94bf51b343df5d37321a914b5ad0ff64e26dcda Mon Sep 17 00:00:00 2001 From: Bohdan Date: Thu, 21 May 2026 01:17:53 +0300 Subject: [PATCH 13/18] fix(auth): tighten deletion flow and stabilize security tests Block request-delete for password accounts. Fix email template encoding. Stabilize auth security integration tests. --- apps/backend/src/controllers/authController.ts | 6 ++++++ apps/backend/src/services/emailService.ts | 12 ++++++------ .../src/modules/Auth/AuthQueryParamToast.tsx | 2 +- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/apps/backend/src/controllers/authController.ts b/apps/backend/src/controllers/authController.ts index 65740fe..f83e6a0 100644 --- a/apps/backend/src/controllers/authController.ts +++ b/apps/backend/src/controllers/authController.ts @@ -1138,6 +1138,12 @@ export const requestDeleteAccount = async (req: Request, res: Response) => { .status(400) .json({ error: "Account has no email to send confirmation to." }); } + if (user.password) { + return res.status(409).json({ + error: + "This account has a password. Delete it directly with password confirmation.", + }); + } const { rawToken } = await createEmailToken( user.id, diff --git a/apps/backend/src/services/emailService.ts b/apps/backend/src/services/emailService.ts index ce52b4b..0e7d483 100644 --- a/apps/backend/src/services/emailService.ts +++ b/apps/backend/src/services/emailService.ts @@ -44,7 +44,7 @@ const getTransporter = (): Transporter => { transporter = nodemailer.createTransport({ jsonTransport: true }); if (process.env.NODE_ENV !== "test") { console.warn( - "[emailService] SMTP_* env vars not set — using jsonTransport. Emails will be logged, not delivered.", + "[emailService] SMTP_* env vars not set - using jsonTransport. Emails will be logged, not delivered.", ); } } @@ -95,7 +95,7 @@ const wrapHtml = (title: string, body: string): string => `

${title}

${body}
-

CoinRadar — automated message. Do not reply.

+

CoinRadar - automated message. Do not reply.

`; export const sendVerificationEmail = async (to: string, token: string) => { @@ -132,14 +132,14 @@ If that was you, confirm the link to attach Google to your existing CoinRadar ac ${link} -The link expires in 1 hour. If it was not you, ignore this email — nothing will change.`; +The link expires in 1 hour. If it was not you, ignore this email - nothing will change.`; const html = wrapHtml( "Link Google to your CoinRadar account", `

Someone tried to sign in with Google using this email address.

If that was you, confirm to attach Google to your existing CoinRadar account. You will still be able to sign in with your password.

Link Google

Or open this link:
${link}

-

The link expires in 1 hour. If it was not you, ignore this email — nothing will change.

`, +

The link expires in 1 hour. If it was not you, ignore this email - nothing will change.

`, ); return dispatch({ to, subject, purpose: "merge_google", text, html, token }); @@ -150,7 +150,7 @@ export const sendAccountDeletionEmail = async (to: string, token: string) => { const subject = "Confirm deletion of your CoinRadar account"; const text = `You requested permanent deletion of your CoinRadar account. -Confirm by opening the link below. This is irreversible — all wallets and transactions will be removed: +Confirm by opening the link below. This is irreversible - all wallets and transactions will be removed: ${link} @@ -197,7 +197,7 @@ Use it once to sign in, then change it from your account settings. If you did no }); }; -// Test helpers — only meaningful when NODE_ENV === "test". +// Test helpers - only meaningful when NODE_ENV === "test". // Kept in production builds because tree-shaking is not configured for the backend. export const __getCapturedEmails = (): readonly SentEmailRecord[] => captured; export const __resetCapturedEmails = (): void => { diff --git a/apps/frontend/src/modules/Auth/AuthQueryParamToast.tsx b/apps/frontend/src/modules/Auth/AuthQueryParamToast.tsx index ab320f0..f0fecff 100644 --- a/apps/frontend/src/modules/Auth/AuthQueryParamToast.tsx +++ b/apps/frontend/src/modules/Auth/AuthQueryParamToast.tsx @@ -114,7 +114,7 @@ export function AuthQueryParamToast() { exit={{ opacity: 0, y: 20 }} transition={{ duration: 0.25 }} className={ - "fixed bottom-6 right-6 max-w-sm z-[100000] px-5 py-4 rounded-xl border backdrop-blur-md shadow-lg text-sm font-semibold " + + "fixed bottom-6 right-6 max-w-sm z-100000 px-5 py-4 rounded-xl border backdrop-blur-md shadow-lg text-sm font-semibold " + toneClass[notice.tone] } role="status" From fea7f50b5a7c3f1773b91a7e524e4600b5ceaabd Mon Sep 17 00:00:00 2001 From: Bohdan Date: Thu, 21 May 2026 01:20:13 +0300 Subject: [PATCH 14/18] fix(backend): disable real SMTP delivery in test environment --- apps/backend/src/services/emailService.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/services/emailService.ts b/apps/backend/src/services/emailService.ts index 0e7d483..b04b90b 100644 --- a/apps/backend/src/services/emailService.ts +++ b/apps/backend/src/services/emailService.ts @@ -33,7 +33,9 @@ let transporter: Transporter | null = null; const getTransporter = (): Transporter => { if (transporter) return transporter; - if (SMTP_HOST && SMTP_USER && SMTP_PASS) { + if (process.env.NODE_ENV === "test") { + transporter = nodemailer.createTransport({ jsonTransport: true }); + } else if (SMTP_HOST && SMTP_USER && SMTP_PASS) { transporter = nodemailer.createTransport({ host: SMTP_HOST, port: SMTP_PORT, From 17f7cc5e0a969135aaedd73ddf39f79c36e01db0 Mon Sep 17 00:00:00 2001 From: Bohdan Date: Thu, 21 May 2026 13:58:03 +0300 Subject: [PATCH 15/18] refactor(auth): drop OTP + merge-confirm + delete-email; trust google verified emails - remove OTP, merge-confirmation and email-link deletion - deleteAccount now relies on session for google-only users - google sign-in auto-links to verified accounts and clears unverified squats - photoUrl restricted to https/data: URLs; PasswordField extracted for show/hide --- apps/backend/.env.example | 5 + .../backend/src/controllers/authController.ts | 298 ++---------- apps/backend/src/models/AuthSchema.ts | 9 + apps/backend/src/router/authRouter.ts | 8 - apps/backend/src/services/emailService.ts | 101 +--- .../integration/auth.security.int.test.ts | 439 +++++++++--------- apps/backend/tests/setup/globalSetup.cjs | 47 +- .../src/modules/Auth/AccountSettingsPopup.tsx | 216 +++------ apps/frontend/src/modules/Auth/AuthPopup.tsx | 67 +-- .../src/modules/Auth/AuthQueryParamToast.tsx | 38 +- .../src/modules/Auth/PasswordField.tsx | 89 ++++ apps/frontend/src/modules/Auth/auth.api.ts | 16 - .../src/modules/FixedHeader/FixedHeader.tsx | 4 +- 13 files changed, 468 insertions(+), 869 deletions(-) create mode 100644 apps/frontend/src/modules/Auth/PasswordField.tsx diff --git a/apps/backend/.env.example b/apps/backend/.env.example index 146f710..a54b72e 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -27,6 +27,11 @@ GOOGLE_CLIENT_ID=your_google_client_id.apps.googleusercontent.com GOOGLE_CLIENT_SECRET=your_google_client_secret GOOGLE_REDIRECT_URI=http://localhost:4000/api/auth/google/callback +# Public URL of THIS backend, used as the base for verification links inside +# outbound emails (e.g. {API_PUBLIC_URL}/api/auth/verify-email?token=...). +# Defaults to http://localhost:4000 in development. +API_PUBLIC_URL=http://localhost:4000 + # SMTP — email verification (Gmail example) SMTP_HOST="smtp.gmail.com" SMTP_PORT="587" diff --git a/apps/backend/src/controllers/authController.ts b/apps/backend/src/controllers/authController.ts index f83e6a0..3c031cf 100644 --- a/apps/backend/src/controllers/authController.ts +++ b/apps/backend/src/controllers/authController.ts @@ -15,17 +15,9 @@ import { UserSchema, } from "../models/AuthSchema.js"; import { handleZodError } from "../utils/helpers.js"; -import { - sendVerificationEmail, - sendGoogleMergeConfirmationEmail, - sendOneTimePasswordEmail, - sendAccountDeletionEmail, -} from "../services/emailService.js"; +import { sendVerificationEmail } from "../services/emailService.js"; const EMAIL_VERIFY_TTL_MS = 24 * 60 * 60 * 1000; -const MERGE_TOKEN_TTL_MS = 60 * 60 * 1000; -const DELETE_TOKEN_TTL_MS = 60 * 60 * 1000; -const OTP_BYTES = 12; const JWT_SECRET = process.env.JWT_SECRET; const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || JWT_SECRET; @@ -94,19 +86,6 @@ const parseCookieHeader = (cookieHeader?: string): Record => { }, {}); }; -const getUserIdFromAccessCookie = (req: Request): string | null => { - const cookies = parseCookieHeader(req.headers.cookie); - const accessToken = cookies[ACCESS_COOKIE_NAME]; - if (!accessToken || !JWT_SECRET) return null; - - try { - const decoded = jwt.verify(accessToken, JWT_SECRET) as { userId?: string }; - return decoded.userId ?? null; - } catch { - return null; - } -}; - const hashToken = (token: string): string => { return crypto.createHash("sha256").update(token).digest("hex"); }; @@ -211,7 +190,7 @@ const toSafeUserResponse = (user: UserWithWallets) => { }); }; -type EmailTokenPurpose = "verify_email" | "merge_google" | "delete_account"; +type EmailTokenPurpose = "verify_email"; const createEmailToken = async ( userId: string, @@ -447,7 +426,8 @@ export const registerUser = async (req: Request, res: Response) => { } catch (error: any) { if (error.code === "P2002") { return res.status(409).json({ - error: "Login or email is already taken. Please choose a different.", + error: + "Account with this login or email already exists. Try signing in or pick different credentials.", }); } @@ -530,65 +510,6 @@ export const verifyEmail = async (req: Request, res: Response) => { return res.redirect(`${FRONTEND_URL}?auth=verified`); }; -export const verifyGoogleMerge = async (req: Request, res: Response) => { - try { - const token = typeof req.query.token === "string" ? req.query.token : ""; - if (!token) { - return res.redirect(`${FRONTEND_URL}?auth=merge_error`); - } - - const result = await consumeEmailToken(token, "merge_google"); - if (!result.ok) { - const param = - result.reason === "already_used" ? "merge_already_done" : "merge_error"; - return res.redirect(`${FRONTEND_URL}?auth=${param}`); - } - - const metadata = result.metadata as { sub?: string; email?: string } | null; - if (!metadata?.sub) { - return res.redirect(`${FRONTEND_URL}?auth=merge_error`); - } - - const conflicting = await prisma.authIdentity.findUnique({ - where: { - provider_providerId: { provider: "google", providerId: metadata.sub }, - }, - }); - if (conflicting && conflicting.userId !== result.userId) { - return res.redirect(`${FRONTEND_URL}?auth=merge_error`); - } - - if (!conflicting) { - await prisma.authIdentity.create({ - data: { - userId: result.userId, - provider: "google", - providerId: metadata.sub, - }, - }); - } - - const user = await prisma.user.findUnique({ - where: { id: result.userId }, - include: { - wallets: { - select: { id: true, name: true }, - orderBy: { createdAt: "asc" }, - }, - }, - }); - if (!user) { - return res.redirect(`${FRONTEND_URL}?auth=merge_error`); - } - - await createSession(user, req, res); - return res.redirect(`${FRONTEND_URL}?auth=merge_confirmed`); - } catch (error) { - console.error("Google merge confirmation error:", error); - return res.redirect(`${FRONTEND_URL}?auth=merge_error`); - } -}; - export const resendVerification = async (req: Request, res: Response) => { try { const { login } = ResendVerificationSchema.parse(req.body); @@ -692,64 +613,42 @@ export const googleAuthCallback = async (req: Request, res: Response) => { let user: UserWithWallets | null = existingIdentity?.user ?? null; if (!user) { + // Google must vouch for the email; otherwise we refuse to link or create. + if (!profile.emailVerified) { + return res.redirect(`${FRONTEND_URL}?auth=google_error`); + } + const existingByEmail = await prisma.user.findFirst({ where: { email: profile.email }, include: userInclude, }); - if (existingByEmail) { - const authUserId = getUserIdFromAccessCookie(req); - const isSameAuthenticatedUser = - authUserId !== null && authUserId === existingByEmail.id; - - if (isSameAuthenticatedUser) { - await prisma.authIdentity.upsert({ - where: { - userId_provider: { - userId: existingByEmail.id, - provider: "google", - }, - }, - create: { + if (existingByEmail && existingByEmail.emailVerified) { + // Existing account already proved control of this email; attach Google + // (upsert handles the rare case of an old google identity on the same + // user that pointed to a different sub). + await prisma.authIdentity.upsert({ + where: { + userId_provider: { userId: existingByEmail.id, provider: "google", - providerId: profile.sub, }, - update: { - providerId: profile.sub, - }, - }); - - user = existingByEmail; - await createSession(user, req, res); - return res.redirect(`${FRONTEND_URL}?auth=google_success`); - } - if (!profile.emailVerified || !existingByEmail.email) { - return res.redirect(`${FRONTEND_URL}?auth=google_error`); - } - - const { rawToken } = await createEmailToken( - existingByEmail.id, - "merge_google", - MERGE_TOKEN_TTL_MS, - { sub: profile.sub, email: profile.email }, - ); - - try { - await sendGoogleMergeConfirmationEmail( - existingByEmail.email, - rawToken, - ); - } catch (mailError) { - console.error( - "Failed to send Google merge confirmation email:", - mailError, - ); - return res.redirect(`${FRONTEND_URL}?auth=google_error`); - } - - return res.redirect(`${FRONTEND_URL}?auth=google_pending_merge`); + }, + create: { + userId: existingByEmail.id, + provider: "google", + providerId: profile.sub, + }, + update: { providerId: profile.sub }, + }); + user = existingByEmail; } else { + // No verified owner: drop any unverified squat sharing the email and + // create a fresh google user. + await prisma.user.deleteMany({ + where: { emailVerified: false, email: profile.email }, + }); + const seed = profile.email.split("@")[0] || profile.name; const login = await generateUniqueLogin(seed); @@ -759,7 +658,7 @@ export const googleAuthCallback = async (req: Request, res: Response) => { login, email: profile.email, password: null, - emailVerified: profile.emailVerified, + emailVerified: true, photoUrl: profile.picture, }, include: userInclude, @@ -1025,57 +924,6 @@ export const setPassword = async (req: Request, res: Response) => { } }; -export const sendOneTimePassword = async (req: Request, res: Response) => { - try { - const userId = req.userId; - if (!userId) { - return res.status(401).json({ error: "Unauthorized." }); - } - - const user = await prisma.user.findUnique({ where: { id: userId } }); - if (!user) { - return res.status(404).json({ error: "User not found." }); - } - if (!user.email) { - return res - .status(400) - .json({ error: "Account has no email to deliver the password to." }); - } - - const otp = crypto.randomBytes(OTP_BYTES).toString("base64url"); - const hashed = await bcrypt.hash(otp, saltRounds); - - await prisma.$transaction(async (tx) => { - await tx.user.update({ - where: { id: userId }, - data: { password: hashed }, - }); - await tx.authIdentity.upsert({ - where: { userId_provider: { userId, provider: "local" } }, - create: { userId, provider: "local" }, - update: {}, - }); - }); - - try { - await sendOneTimePasswordEmail(user.email, otp); - } catch (mailError) { - console.error("Failed to send one-time password email:", mailError); - return res - .status(502) - .json({ error: "Failed to deliver the one-time password email." }); - } - - return res.status(200).json({ - message: - "A one-time password has been sent to your email. Use it to sign in and change it from your account settings.", - }); - } catch (error) { - console.error("Send OTP error:", error); - return res.status(500).json({ error: "Server error during OTP send." }); - } -}; - export const deleteAccount = async (req: Request, res: Response) => { try { const userId = req.userId; @@ -1090,6 +938,9 @@ export const deleteAccount = async (req: Request, res: Response) => { return res.status(404).json({ error: "User not found." }); } + // Users with a password confirm via that password (sanity check against a + // leftover session). Google-only accounts skip it — the active session is + // proof enough, and there is no second factor we could ask for here. if (user.password) { if (!password) { return res @@ -1100,12 +951,6 @@ export const deleteAccount = async (req: Request, res: Response) => { if (!match) { return res.status(401).json({ error: "Password is wrong." }); } - } else { - return res.status(409).json({ - error: - "This account has no password. Request an email confirmation via /auth/account/request-delete.", - requiresEmailConfirmation: true, - }); } await prisma.user.delete({ where: { id: userId } }); @@ -1122,56 +967,6 @@ export const deleteAccount = async (req: Request, res: Response) => { } }; -export const requestDeleteAccount = async (req: Request, res: Response) => { - try { - const userId = req.userId; - if (!userId) { - return res.status(401).json({ error: "Unauthorized." }); - } - - const user = await prisma.user.findUnique({ where: { id: userId } }); - if (!user) { - return res.status(404).json({ error: "User not found." }); - } - if (!user.email) { - return res - .status(400) - .json({ error: "Account has no email to send confirmation to." }); - } - if (user.password) { - return res.status(409).json({ - error: - "This account has a password. Delete it directly with password confirmation.", - }); - } - - const { rawToken } = await createEmailToken( - user.id, - "delete_account", - DELETE_TOKEN_TTL_MS, - ); - - try { - await sendAccountDeletionEmail(user.email, rawToken); - } catch (mailError) { - console.error("Failed to send delete confirmation email:", mailError); - return res - .status(502) - .json({ error: "Failed to deliver the confirmation email." }); - } - - return res.status(200).json({ - message: - "Account deletion confirmation has been sent to your email. The link expires in 1 hour.", - }); - } catch (error) { - console.error("Request delete error:", error); - return res - .status(500) - .json({ error: "Server error during delete request." }); - } -}; - export const updateProfile = async (req: Request, res: Response) => { try { const userId = req.userId; @@ -1243,26 +1038,3 @@ export const updateProfile = async (req: Request, res: Response) => { .json({ error: "Server error during profile update." }); } }; - -export const confirmDeleteAccount = async (req: Request, res: Response) => { - try { - const token = typeof req.query.token === "string" ? req.query.token : ""; - if (!token) { - return res.redirect(`${FRONTEND_URL}?auth=delete_error`); - } - - const result = await consumeEmailToken(token, "delete_account"); - if (!result.ok) { - const param = - result.reason === "already_used" ? "already_deleted" : "delete_error"; - return res.redirect(`${FRONTEND_URL}?auth=${param}`); - } - - await prisma.user.delete({ where: { id: result.userId } }); - clearAuthCookies(res); - return res.redirect(`${FRONTEND_URL}?auth=account_deleted`); - } catch (error) { - console.error("Confirm delete error:", error); - return res.redirect(`${FRONTEND_URL}?auth=delete_error`); - } -}; diff --git a/apps/backend/src/models/AuthSchema.ts b/apps/backend/src/models/AuthSchema.ts index 0d06e8d..06a2c5b 100644 --- a/apps/backend/src/models/AuthSchema.ts +++ b/apps/backend/src/models/AuthSchema.ts @@ -45,6 +45,11 @@ export const DeleteAccountSchema = z.object({ password: z.string().min(1).optional(), }); +// photoUrl accepts http(s) URLs and base64 image data URLs only; rejects +// arbitrary schemes (e.g. javascript:) so the value is always safe in . +const PHOTO_URL_REGEX = + /^(https?:\/\/[^\s]+|data:image\/(png|jpe?g|gif|webp|svg\+xml);base64,[A-Za-z0-9+/=]+)$/; + export const UpdateProfileSchema = z.object({ login: z .string() @@ -55,6 +60,10 @@ export const UpdateProfileSchema = z.object({ photoUrl: z .string() .max(800_000, "Image is too large (max ~600 KB).") + .refine( + (value) => value === "" || PHOTO_URL_REGEX.test(value), + "Photo must be an http(s) URL or a base64 image data URL.", + ) .nullable() .optional(), }); diff --git a/apps/backend/src/router/authRouter.ts b/apps/backend/src/router/authRouter.ts index 6736363..db91dc0 100644 --- a/apps/backend/src/router/authRouter.ts +++ b/apps/backend/src/router/authRouter.ts @@ -8,16 +8,12 @@ import { resendVerification, startGoogleAuth, googleAuthCallback, - verifyGoogleMerge, refreshSession, logoutUser, logoutAllUserSessions, getCurrentUser, setPassword, - sendOneTimePassword, deleteAccount, - requestDeleteAccount, - confirmDeleteAccount, updateProfile, } from "../controllers/authController.js"; import { protect } from "../middleware/authMiddleware.js"; @@ -28,16 +24,12 @@ authRouter.get("/verify-email", verifyEmail); authRouter.post("/resend-verification", resendVerification); authRouter.get("/google/start", startGoogleAuth); authRouter.get("/google/callback", googleAuthCallback); -authRouter.get("/verify-merge", verifyGoogleMerge); authRouter.post("/refresh", refreshSession); authRouter.post("/logout", logoutUser); authRouter.get("/me", protect, getCurrentUser); authRouter.post("/logout-all", protect, logoutAllUserSessions); authRouter.post("/set-password", protect, setPassword); -authRouter.post("/send-one-time-password", protect, sendOneTimePassword); authRouter.patch("/me", protect, updateProfile); authRouter.delete("/account", protect, deleteAccount); -authRouter.post("/account/request-delete", protect, requestDeleteAccount); -authRouter.get("/account/confirm-delete", confirmDeleteAccount); export default authRouter; diff --git a/apps/backend/src/services/emailService.ts b/apps/backend/src/services/emailService.ts index b04b90b..3a0075c 100644 --- a/apps/backend/src/services/emailService.ts +++ b/apps/backend/src/services/emailService.ts @@ -7,20 +7,14 @@ const SMTP_PASS = process.env.SMTP_PASS; const SMTP_FROM = process.env.SMTP_FROM || "CoinRadar "; const API_PUBLIC_URL = process.env.API_PUBLIC_URL || "http://localhost:4000"; -const FRONTEND_URL = process.env.FRONTEND_URL || "http://localhost:5173"; -export type EmailPurpose = - | "verify_email" - | "merge_google" - | "delete_account" - | "one_time_password"; +export type EmailPurpose = "verify_email"; export interface SentEmailRecord { to: string; subject: string; purpose: EmailPurpose; token?: string; - otp?: string; text: string; html: string; sentAt: Date; @@ -44,11 +38,9 @@ const getTransporter = (): Transporter => { }); } else { transporter = nodemailer.createTransport({ jsonTransport: true }); - if (process.env.NODE_ENV !== "test") { - console.warn( - "[emailService] SMTP_* env vars not set - using jsonTransport. Emails will be logged, not delivered.", - ); - } + console.warn( + "[emailService] SMTP_* env vars not set - using jsonTransport. Emails will be logged, not delivered.", + ); } return transporter; @@ -61,11 +53,10 @@ interface DispatchArgs { text: string; html: string; token?: string; - otp?: string; } const dispatch = async (args: DispatchArgs) => { - const { to, subject, purpose, text, html, token, otp } = args; + const { to, subject, purpose, text, html, token } = args; const info = await getTransporter().sendMail({ from: SMTP_FROM, to, @@ -83,7 +74,6 @@ const dispatch = async (args: DispatchArgs) => { html, sentAt: new Date(), ...(token !== undefined && { token }), - ...(otp !== undefined && { otp }), }); } else if (!SMTP_HOST) { console.info(`[emailService][${purpose}] -> ${to}\n${text}`); @@ -122,89 +112,8 @@ The link expires in 24 hours. If you did not register, ignore this email.`; return dispatch({ to, subject, purpose: "verify_email", text, html, token }); }; -export const sendGoogleMergeConfirmationEmail = async ( - to: string, - token: string, -) => { - const link = `${API_PUBLIC_URL}/api/auth/verify-merge?token=${encodeURIComponent(token)}`; - const subject = "Confirm linking Google to your CoinRadar account"; - const text = `Someone tried to sign in with Google using this email address. - -If that was you, confirm the link to attach Google to your existing CoinRadar account: - -${link} - -The link expires in 1 hour. If it was not you, ignore this email - nothing will change.`; - const html = wrapHtml( - "Link Google to your CoinRadar account", - `

Someone tried to sign in with Google using this email address.

-

If that was you, confirm to attach Google to your existing CoinRadar account. You will still be able to sign in with your password.

-

Link Google

-

Or open this link:
${link}

-

The link expires in 1 hour. If it was not you, ignore this email - nothing will change.

`, - ); - - return dispatch({ to, subject, purpose: "merge_google", text, html, token }); -}; - -export const sendAccountDeletionEmail = async (to: string, token: string) => { - const link = `${FRONTEND_URL}?auth=delete_confirm&token=${encodeURIComponent(token)}`; - const subject = "Confirm deletion of your CoinRadar account"; - const text = `You requested permanent deletion of your CoinRadar account. - -Confirm by opening the link below. This is irreversible - all wallets and transactions will be removed: - -${link} - -The link expires in 1 hour. If you did not request deletion, ignore this email and your account stays intact.`; - const html = wrapHtml( - "Confirm account deletion", - `

You requested permanent deletion of your CoinRadar account.

-

This is irreversible. All wallets and transactions will be removed.

-

Confirm deletion

-

Or open this link:
${link}

-

The link expires in 1 hour. If you did not request deletion, ignore this email and your account stays intact.

`, - ); - - return dispatch({ - to, - subject, - purpose: "delete_account", - text, - html, - token, - }); -}; - -export const sendOneTimePasswordEmail = async (to: string, otp: string) => { - const subject = "Your CoinRadar one-time password"; - const text = `Your temporary CoinRadar password: ${otp} - -Use it once to sign in, then change it from your account settings. If you did not request this, ignore this email and consider rotating your account password.`; - const html = wrapHtml( - "Your one-time password", - `

Your temporary CoinRadar password:

-

${otp}

-

Use it once to sign in, then change it from your account settings.

-

If you did not request this, ignore this email and consider rotating your account password.

`, - ); - - return dispatch({ - to, - subject, - purpose: "one_time_password", - text, - html, - otp, - }); -}; - // Test helpers - only meaningful when NODE_ENV === "test". -// Kept in production builds because tree-shaking is not configured for the backend. export const __getCapturedEmails = (): readonly SentEmailRecord[] => captured; export const __resetCapturedEmails = (): void => { captured.length = 0; }; -export const __resetTransporterForTests = (): void => { - transporter = null; -}; diff --git a/apps/backend/tests/integration/auth.security.int.test.ts b/apps/backend/tests/integration/auth.security.int.test.ts index 2238c32..6c67a84 100644 --- a/apps/backend/tests/integration/auth.security.int.test.ts +++ b/apps/backend/tests/integration/auth.security.int.test.ts @@ -1,5 +1,6 @@ import request from "supertest"; import { beforeEach, describe, expect, it, jest } from "@jest/globals"; +import jwt from "jsonwebtoken"; import prisma from "../../src/prisma.js"; import { getApp, resetDatabase } from "../helpers/testUtils.js"; import { __getCapturedEmails } from "../../src/services/emailService.js"; @@ -12,7 +13,8 @@ type MockGoogleProfile = { picture?: string; }; -const rand = (prefix: string) => `${prefix}_${Math.random().toString(36).slice(2, 10)}`; +const rand = (prefix: string) => + `${prefix}_${Math.random().toString(36).slice(2, 10)}`; const loginOf = (prefix: string) => rand(prefix).slice(0, 30); const mockGoogleFetch = (profile: MockGoogleProfile) => { @@ -41,16 +43,15 @@ const mockGoogleFetch = (profile: MockGoogleProfile) => { json: async () => ({ id_token: "id-token-test" }), } as Response; } - return { - ok: false, - json: async () => ({}), - } as Response; + return { ok: false, json: async () => ({}) } as Response; }); return () => fetchMock.mockRestore(); }; -const startGoogleAndGetState = async (agent: ReturnType) => { +const startGoogleAndGetState = async ( + agent: ReturnType, +) => { const startResponse = await agent.get("/api/auth/google/start"); expect(startResponse.status).toBe(302); const location = String(startResponse.headers.location || ""); @@ -59,13 +60,8 @@ const startGoogleAndGetState = async (agent: ReturnType) = return state as string; }; -const registerUser = async (login: string, password: string, email: string) => { - return request(getApp()).post("/api/auth/register").send({ - login, - password, - email, - }); -}; +const registerUser = async (login: string, password: string, email: string) => + request(getApp()).post("/api/auth/register").send({ login, password, email }); const verifyUserDirectly = async (login: string) => { await prisma.user.update({ @@ -74,32 +70,34 @@ const verifyUserDirectly = async (login: string) => { }); }; -const loginUser = async (login: string, password: string) => { - return request(getApp()).post("/api/auth/login").send({ login, password }); +const loginUser = async (login: string, password: string) => + request(getApp()).post("/api/auth/login").send({ login, password }); + +const cookiesFrom = (response: request.Response): string[] => { + const raw = response.headers["set-cookie"]; + return Array.isArray(raw) ? raw : raw ? [raw] : []; +}; + +const accessCookieFromArray = (cookies: string[]): string | null => { + for (const c of cookies) { + if (c.startsWith("access_token=")) { + return c.split(";")[0].slice("access_token=".length); + } + } + return null; }; describe("Auth security flows", () => { beforeEach(async () => { - process.env.GOOGLE_CLIENT_ID = - process.env.GOOGLE_CLIENT_ID || "test-google-client-id"; - process.env.GOOGLE_CLIENT_SECRET = - process.env.GOOGLE_CLIENT_SECRET || "test-google-client-secret"; - process.env.GOOGLE_REDIRECT_URI = - process.env.GOOGLE_REDIRECT_URI || - "http://localhost:4000/api/auth/google/callback"; - process.env.API_PUBLIC_URL = - process.env.API_PUBLIC_URL || "http://localhost:4000"; - await resetDatabase(); }); - it("prevents takeover: second register can replace unverified squat on same login/email", async () => { + it("prevents takeover: second register replaces unverified squat on same login/email", async () => { const login = loginOf("squat"); const email = `${login}@mail.com`; const first = await registerUser(login, "password123", email); expect(first.status).toBe(201); - const firstUser = await prisma.user.findUnique({ where: { login } }); expect(firstUser).toBeTruthy(); @@ -111,50 +109,22 @@ describe("Auth security flows", () => { expect(users[0]?.id).not.toBe(firstUser?.id); }); - it("requires merge-confirmation email when google sign-in hits an existing email account", async () => { - const login = loginOf("merge"); + it("blocks duplicate register against a verified account", async () => { + const login = loginOf("verifiedconflict"); const email = `${login}@mail.com`; - const password = "password123"; - const registerResponse = await registerUser(login, password, email); - expect(registerResponse.status).toBe(201); + expect((await registerUser(login, "password123", email)).status).toBe(201); await verifyUserDirectly(login); - const restoreFetch = mockGoogleFetch({ - sub: rand("gsub"), - email, - email_verified: "true", - name: login, - }); - - try { - const agent = request.agent(getApp()); - const state = await startGoogleAndGetState(agent); - const callback = await agent - .get("/api/auth/google/callback") - .query({ code: "test-code", state }); - - expect(callback.status).toBe(302); - expect(String(callback.headers.location)).toContain( - "auth=google_pending_merge", - ); - - const sent = __getCapturedEmails().filter( - (m) => m.purpose === "merge_google" && m.to === email, - ); - expect(sent.length).toBeGreaterThan(0); - expect(sent.at(-1)?.token).toBeTruthy(); - } finally { - restoreFetch(); - } + const second = await registerUser(login, "password456", email); + expect(second.status).toBe(409); }); it("email verification link supports success, consumed, and expired cases", async () => { const login = loginOf("verify"); const email = `${login}@mail.com`; - const registerResponse = await registerUser(login, "password123", email); - expect(registerResponse.status).toBe(201); + expect((await registerUser(login, "password123", email)).status).toBe(201); const verifyMail = __getCapturedEmails() .filter((m) => m.purpose === "verify_email" && m.to === email) @@ -177,13 +147,9 @@ describe("Auth security flows", () => { const secondLogin = loginOf("verifyexp"); const secondEmail = `${secondLogin}@mail.com`; - const secondRegister = await registerUser( - secondLogin, - "password123", - secondEmail, - ); - expect(secondRegister.status).toBe(201); - + expect( + (await registerUser(secondLogin, "password123", secondEmail)).status, + ).toBe(201); const secondMail = __getCapturedEmails() .filter((m) => m.purpose === "verify_email" && m.to === secondEmail) .at(-1); @@ -201,70 +167,79 @@ describe("Auth security flows", () => { expect(String(expired.headers.location)).toContain("auth=verify_error"); }); - it("supports account linking both ways: merge-confirm and authenticated auto-link", async () => { - const login = loginOf("link"); + it("login is blocked until the email is verified", async () => { + const login = loginOf("loginblock"); const email = `${login}@mail.com`; const password = "password123"; - const registerResponse = await registerUser(login, password, email); - expect(registerResponse.status).toBe(201); + expect((await registerUser(login, password, email)).status).toBe(201); + + const before = await loginUser(login, password); + expect(before.status).toBe(403); + expect(before.body?.requiresVerification).toBe(true); + await verifyUserDirectly(login); - const restoreFetchA = mockGoogleFetch({ - sub: rand("gsubpend"), + const after = await loginUser(login, password); + expect(after.status).toBe(200); + }); + + it("auto-links google to a verified existing account on first sign-in", async () => { + const login = loginOf("autolink"); + const email = `${login}@mail.com`; + const password = "password123"; + + expect((await registerUser(login, password, email)).status).toBe(201); + await verifyUserDirectly(login); + + const restoreFetch = mockGoogleFetch({ + sub: rand("gsubauto"), email, email_verified: "true", }); try { - const pendingAgent = request.agent(getApp()); - const pendingState = await startGoogleAndGetState(pendingAgent); - const pending = await pendingAgent + const agent = request.agent(getApp()); + const state = await startGoogleAndGetState(agent); + const callback = await agent .get("/api/auth/google/callback") - .query({ code: "test-code", state: pendingState }); - expect(String(pending.headers.location)).toContain( - "google_pending_merge", - ); + .query({ code: "test-code", state }); - const mergeToken = __getCapturedEmails() - .filter((m) => m.purpose === "merge_google" && m.to === email) - .at(-1)?.token; - expect(mergeToken).toBeTruthy(); + expect(callback.status).toBe(302); + expect(String(callback.headers.location)).toContain( + "auth=google_success", + ); - const verifyMerge = await pendingAgent - .get("/api/auth/verify-merge") - .query({ token: mergeToken }); - expect(verifyMerge.status).toBe(302); - expect(String(verifyMerge.headers.location)).toContain("merge_confirmed"); + const linked = await prisma.authIdentity.findFirst({ + where: { user: { login }, provider: "google" }, + }); + expect(linked).toBeTruthy(); } finally { - restoreFetchA(); + restoreFetch(); } + }); - const localOnlyLogin = loginOf("linklocal"); - const localOnlyEmail = `${localOnlyLogin}@mail.com`; - const reg2 = await registerUser(localOnlyLogin, password, localOnlyEmail); - expect(reg2.status).toBe(201); - await verifyUserDirectly(localOnlyLogin); + it("google sign-in replaces an unverified squat sharing the email", async () => { + const squatLogin = loginOf("squatg"); + const email = `${squatLogin}@mail.com`; - const loginResponse = await loginUser(localOnlyLogin, password); - expect(loginResponse.status).toBe(200); - const cookieRaw = loginResponse.headers["set-cookie"]; - const loginCookies = Array.isArray(cookieRaw) - ? cookieRaw - : cookieRaw - ? [cookieRaw] - : []; - - const restoreFetchB = mockGoogleFetch({ - sub: rand("gsubauto"), - email: localOnlyEmail, + expect((await registerUser(squatLogin, "password123", email)).status).toBe( + 201, + ); + const squatUser = await prisma.user.findUnique({ + where: { login: squatLogin }, + }); + expect(squatUser?.emailVerified).toBe(false); + + const restoreFetch = mockGoogleFetch({ + sub: rand("gsubreplace"), + email, email_verified: "true", }); try { - const autoAgent = request.agent(getApp()); - const state = await startGoogleAndGetState(autoAgent); - const callback = await autoAgent + const agent = request.agent(getApp()); + const state = await startGoogleAndGetState(agent); + const callback = await agent .get("/api/auth/google/callback") - .set("Cookie", loginCookies.join("; ")) .query({ code: "test-code", state }); expect(callback.status).toBe(302); @@ -272,16 +247,44 @@ describe("Auth security flows", () => { "auth=google_success", ); - const linked = await prisma.authIdentity.findFirst({ - where: { user: { login: localOnlyLogin }, provider: "google" }, + // The squat row must be gone — new google user may pick the same login, + // so identity must be checked by id, not by login. + const stillSquatById = await prisma.user.findUnique({ + where: { id: squatUser?.id ?? "" }, }); - expect(linked).toBeTruthy(); + expect(stillSquatById).toBeNull(); + + const googleUser = await prisma.user.findFirst({ where: { email } }); + expect(googleUser).toBeTruthy(); + expect(googleUser?.id).not.toBe(squatUser?.id); + expect(googleUser?.emailVerified).toBe(true); + expect(googleUser?.password).toBeNull(); + } finally { + restoreFetch(); + } + }); + + it("google sign-in refuses if google itself did not verify the email", async () => { + const restoreFetch = mockGoogleFetch({ + sub: rand("gsubnv"), + email: `${loginOf("gnv")}@mail.com`, + email_verified: "false", + }); + try { + const agent = request.agent(getApp()); + const state = await startGoogleAndGetState(agent); + const callback = await agent + .get("/api/auth/google/callback") + .query({ code: "test-code", state }); + + expect(callback.status).toBe(302); + expect(String(callback.headers.location)).toContain("auth=google_error"); } finally { - restoreFetchB(); + restoreFetch(); } }); - it("supports set-password and one-time-password flows", async () => { + it("set-password: first set creates local identity; change requires correct old password", async () => { const restoreFetch = mockGoogleFetch({ sub: rand("gsubpwd"), email: `${loginOf("guser")}@mail.com`, @@ -291,115 +294,146 @@ describe("Auth security flows", () => { try { const agent = request.agent(getApp()); const state = await startGoogleAndGetState(agent); - const callback = await agent + await agent .get("/api/auth/google/callback") .query({ code: "test-code", state }); - expect(callback.status).toBe(302); - expect(String(callback.headers.location)).toContain( - "auth=google_success", - ); - const setPasswordResponse = await agent + const first = await agent .post("/api/auth/set-password") .send({ password: "new-password-123" }); - expect(setPasswordResponse.status).toBe(200); + expect(first.status).toBe(200); const me = await agent.get("/api/auth/me"); - expect(me.status).toBe(200); expect(me.body?.user?.hasPassword).toBe(true); - const otpResponse = await agent.post("/api/auth/send-one-time-password"); - expect(otpResponse.status).toBe(200); - - const meAfterOtp = await agent.get("/api/auth/me"); - expect(meAfterOtp.status).toBe(200); - const userLogin = meAfterOtp.body?.user?.login; - const userEmail = meAfterOtp.body?.user?.email; - expect(userLogin).toBeTruthy(); - expect(userEmail).toBeTruthy(); - - const otpMail = __getCapturedEmails() - .filter((m) => m.purpose === "one_time_password" && m.to === userEmail) - .at(-1); - expect(otpMail?.otp).toBeTruthy(); - - const loginWithOtp = await request(getApp()) - .post("/api/auth/login") - .send({ login: userLogin, password: otpMail?.otp }); - expect(loginWithOtp.status).toBe(200); + const wrongOld = await agent + .post("/api/auth/set-password") + .send({ oldPassword: "wrong-one", password: "rotated-789" }); + expect(wrongOld.status).toBe(401); + + const correctOld = await agent + .post("/api/auth/set-password") + .send({ oldPassword: "new-password-123", password: "rotated-789" }); + expect(correctOld.status).toBe(200); + + const userLogin = (await agent.get("/api/auth/me")).body?.user?.login; + const reLogin = await loginUser(userLogin, "rotated-789"); + expect(reLogin.status).toBe(200); } finally { restoreFetch(); } }); - it("supports account deletion for local-password and google-only email-link cases", async () => { - const localLogin = loginOf("deletelocal"); - const localEmail = `${localLogin}@mail.com`; - const localPassword = "password123"; + it("updateProfile: rename re-signs access JWT and 409s on a taken login", async () => { + const loginA = loginOf("renameA"); + const loginB = loginOf("renameB"); + const password = "password123"; - const localRegister = await registerUser( - localLogin, - localPassword, - localEmail, - ); - expect(localRegister.status).toBe(201); - await verifyUserDirectly(localLogin); + expect( + (await registerUser(loginA, password, `${loginA}@mail.com`)).status, + ).toBe(201); + await verifyUserDirectly(loginA); + expect( + (await registerUser(loginB, password, `${loginB}@mail.com`)).status, + ).toBe(201); + await verifyUserDirectly(loginB); + + const agent = request.agent(getApp()); + expect( + (await agent.post("/api/auth/login").send({ login: loginA, password })) + .status, + ).toBe(200); + + const newName = loginOf("renamed"); + const ok = await agent.patch("/api/auth/me").send({ login: newName }); + expect(ok.status).toBe(200); + expect(ok.body?.user?.login).toBe(newName); + + const tokenCookie = accessCookieFromArray(cookiesFrom(ok)); + expect(tokenCookie).toBeTruthy(); + const decoded = jwt.verify( + tokenCookie as string, + process.env.JWT_SECRET as string, + ) as { userLogin?: string }; + expect(decoded.userLogin).toBe(newName); + + const conflict = await agent.patch("/api/auth/me").send({ login: loginB }); + expect(conflict.status).toBe(409); + }); + + it("updateProfile: rejects unsafe photoUrl and stores a base64 data URL", async () => { + const login = loginOf("photo"); + const password = "password123"; + expect( + (await registerUser(login, password, `${login}@mail.com`)).status, + ).toBe(201); + await verifyUserDirectly(login); + + const agent = request.agent(getApp()); + expect( + (await agent.post("/api/auth/login").send({ login, password })).status, + ).toBe(200); + + const unsafe = await agent + .patch("/api/auth/me") + .send({ photoUrl: "javascript:alert(1)" }); + expect(unsafe.status).toBe(400); + + const dataUrl = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAAEUlEQVR42mP8/5+hngEKGAEALAIB/X1KGQAAAABJRU5ErkJggg=="; + const ok = await agent.patch("/api/auth/me").send({ photoUrl: dataUrl }); + expect(ok.status).toBe(200); + expect(ok.body?.user?.photoUrl).toBe(dataUrl); + }); + + it("deletes a local account when the correct password is provided", async () => { + const login = loginOf("delete"); + const password = "password123"; + expect( + (await registerUser(login, password, `${login}@mail.com`)).status, + ).toBe(201); + await verifyUserDirectly(login); - const localAgent = request.agent(getApp()); - const localSignIn = await localAgent - .post("/api/auth/login") - .send({ login: localLogin, password: localPassword }); - expect(localSignIn.status).toBe(200); + const agent = request.agent(getApp()); + expect( + (await agent.post("/api/auth/login").send({ login, password })).status, + ).toBe(200); - const localDelete = await localAgent + const wrong = await agent .delete("/api/auth/account") - .send({ password: localPassword }); - expect(localDelete.status).toBe(200); + .send({ password: "nope" }); + expect(wrong.status).toBe(401); - const localStillThere = await prisma.user.findUnique({ - where: { login: localLogin }, - }); - expect(localStillThere).toBeNull(); + const ok = await agent.delete("/api/auth/account").send({ password }); + expect(ok.status).toBe(200); + + expect(await prisma.user.findUnique({ where: { login } })).toBeNull(); + }); + it("deletes a google-only account immediately, no password required", async () => { const restoreFetch = mockGoogleFetch({ sub: rand("gsubdel"), email: `${loginOf("gdel")}@mail.com`, email_verified: "true", }); try { - const googleAgent = request.agent(getApp()); - const state = await startGoogleAndGetState(googleAgent); - const callback = await googleAgent + const agent = request.agent(getApp()); + const state = await startGoogleAndGetState(agent); + await agent .get("/api/auth/google/callback") .query({ code: "test-code", state }); - expect(callback.status).toBe(302); - const requestDelete = await googleAgent.post( - "/api/auth/account/request-delete", - ); - expect(requestDelete.status).toBe(200); - - const me = await googleAgent.get("/api/auth/me"); + const me = await agent.get("/api/auth/me"); const googleLogin = me.body?.user?.login as string; expect(googleLogin).toBeTruthy(); + expect(me.body?.user?.hasPassword).toBe(false); - const deleteToken = __getCapturedEmails() - .filter((m) => m.purpose === "delete_account") - .at(-1)?.token; - expect(deleteToken).toBeTruthy(); - - const confirm = await request(getApp()) - .get("/api/auth/account/confirm-delete") - .query({ token: deleteToken }); - expect(confirm.status).toBe(302); - expect(String(confirm.headers.location)).toContain( - "auth=account_deleted", - ); + const ok = await agent.delete("/api/auth/account").send({}); + expect(ok.status).toBe(200); - const googleStillThere = await prisma.user.findUnique({ - where: { login: googleLogin }, - }); - expect(googleStillThere).toBeNull(); + expect( + await prisma.user.findUnique({ where: { login: googleLogin } }), + ).toBeNull(); } finally { restoreFetch(); } @@ -407,24 +441,15 @@ describe("Auth security flows", () => { it("revokes all refresh sessions on logout-all", async () => { const login = loginOf("logoutall"); - const email = `${login}@mail.com`; const password = "password123"; - - const registerResponse = await registerUser(login, password, email); - expect(registerResponse.status).toBe(201); + expect( + (await registerUser(login, password, `${login}@mail.com`)).status, + ).toBe(201); await verifyUserDirectly(login); - const loginResponse = await request(getApp()) - .post("/api/auth/login") - .send({ login, password }); + const loginResponse = await loginUser(login, password); expect(loginResponse.status).toBe(200); - - const cookieRaw = loginResponse.headers["set-cookie"]; - const cookies = Array.isArray(cookieRaw) - ? cookieRaw - : cookieRaw - ? [cookieRaw] - : []; + const cookies = cookiesFrom(loginResponse); expect(cookies.join(";")).toContain("refresh_token="); const logoutAll = await request(getApp()) diff --git a/apps/backend/tests/setup/globalSetup.cjs b/apps/backend/tests/setup/globalSetup.cjs index 7d4deb1..b07c5bf 100644 --- a/apps/backend/tests/setup/globalSetup.cjs +++ b/apps/backend/tests/setup/globalSetup.cjs @@ -1,51 +1,56 @@ -const path = require('path'); -const { execSync } = require('child_process'); -const { PostgreSqlContainer } = require('@testcontainers/postgresql'); +const path = require("path"); +const { execSync } = require("child_process"); +const { PostgreSqlContainer } = require("@testcontainers/postgresql"); module.exports = async () => { let container = null; - let databaseUrl = ''; + let databaseUrl = ""; try { - container = await new PostgreSqlContainer('postgres:16-alpine') - .withDatabase('coinradar_test') - .withUsername('postgres') - .withPassword('postgres') + container = await new PostgreSqlContainer("postgres:16-alpine") + .withDatabase("coinradar_test") + .withUsername("postgres") + .withPassword("postgres") .start(); databaseUrl = container.getConnectionUri(); } catch (error) { if (!process.env.TEST_DATABASE_URL) { throw new Error( [ - 'Could not start testcontainers Postgres (Docker runtime unavailable).', - 'Set TEST_DATABASE_URL to a dedicated test database or start Docker.', - ].join(' ') + "Could not start testcontainers Postgres (Docker runtime unavailable).", + "Set TEST_DATABASE_URL to a dedicated test database or start Docker.", + ].join(" "), ); } databaseUrl = process.env.TEST_DATABASE_URL; // eslint-disable-next-line no-console - console.warn('testcontainers unavailable, using TEST_DATABASE_URL fallback.'); + console.warn( + "testcontainers unavailable, using TEST_DATABASE_URL fallback.", + ); } - const backendRoot = path.resolve(__dirname, '../..'); + const backendRoot = path.resolve(__dirname, "../.."); process.env.DATABASE_URL = databaseUrl; process.env.DIRECT_URL = databaseUrl; - process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-access-secret'; - process.env.JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'test-refresh-secret'; - process.env.FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:5173'; - process.env.CORS_ALLOWED_ORIGINS = process.env.CORS_ALLOWED_ORIGINS || 'http://localhost:5173'; - process.env.COOKIE_SAME_SITE = process.env.COOKIE_SAME_SITE || 'lax'; - process.env.NODE_ENV = 'test'; + process.env.JWT_SECRET = process.env.JWT_SECRET || "test-access-secret"; + process.env.JWT_REFRESH_SECRET = + process.env.JWT_REFRESH_SECRET || "test-refresh-secret"; + process.env.FRONTEND_URL = + process.env.FRONTEND_URL || "http://localhost:5173"; + process.env.CORS_ALLOWED_ORIGINS = + process.env.CORS_ALLOWED_ORIGINS || "http://localhost:5173"; + process.env.COOKIE_SAME_SITE = process.env.COOKIE_SAME_SITE || "lax"; + process.env.NODE_ENV = "test"; - execSync('npx prisma db push --schema=./prisma/schema.prisma', { + execSync("npx prisma db push --schema=./prisma/schema.prisma", { cwd: backendRoot, env: { ...process.env, DATABASE_URL: databaseUrl, DIRECT_URL: databaseUrl, }, - stdio: 'inherit', + stdio: "inherit", }); globalThis.__TESTCONTAINER_POSTGRES__ = container; diff --git a/apps/frontend/src/modules/Auth/AccountSettingsPopup.tsx b/apps/frontend/src/modules/Auth/AccountSettingsPopup.tsx index 7846fb2..6fd6ced 100644 --- a/apps/frontend/src/modules/Auth/AccountSettingsPopup.tsx +++ b/apps/frontend/src/modules/Auth/AccountSettingsPopup.tsx @@ -1,16 +1,15 @@ import { useState, useRef, type FormEvent, type ChangeEvent } from "react"; import { useSetPasswordMutation, - useSendOneTimePasswordMutation, useDeleteAccountMutation, - useRequestDeleteAccountMutation, useUpdateProfileMutation, } from "./auth.api"; import type { UserSafe } from "./auth.schema"; +import { PasswordField } from "./PasswordField"; import { useAppDispatch, useAppSelector } from "../../store"; import { closePopup } from "../../portals/popup.slice"; -type Section = "profile" | "password" | "otp" | "delete"; +type Section = "profile" | "password" | "delete"; const MAX_PHOTO_BYTES = 600 * 1024; @@ -87,13 +86,6 @@ export function AccountSettingsPopup() { > Password - - - ); -} - function DeleteSection({ hasPassword, onDeleted, @@ -469,15 +393,11 @@ function DeleteSection({ }) { const [password, setPassword] = useState(""); const [formError, setFormError] = useState(null); - const [emailNotice, setEmailNotice] = useState(null); + const [successNotice, setSuccessNotice] = useState(null); const [deleteAccount, { isLoading: isDeleting, error: deleteError }] = useDeleteAccountMutation(); - const [requestDelete, { isLoading: isRequesting, error: requestError }] = - useRequestDeleteAccountMutation(); - - const serverError = - extractServerError(deleteError) || extractServerError(requestError); + const serverError = extractServerError(deleteError); const handleDelete = async () => { setFormError(null); @@ -487,22 +407,13 @@ function DeleteSection({ } try { await deleteAccount(hasPassword ? { password } : {}).unwrap(); - onDeleted(); + setSuccessNotice("Account deleted. Closing..."); + setTimeout(onDeleted, 1200); } catch (error) { console.error("Delete account failed:", error); } }; - const handleRequestEmail = async () => { - setEmailNotice(null); - try { - const response = await requestDelete().unwrap(); - setEmailNotice(response.message); - } catch (error) { - console.error("Request delete email failed:", error); - } - }; - return (
@@ -515,57 +426,36 @@ function DeleteSection({ {formError || serverError}
)} - - {emailNotice && ( + {successNotice && (
- {emailNotice} + {successNotice}
)} {hasPassword ? ( - <> -
- - setPassword(e.target.value)} - disabled={isDeleting} - className={inputClass} - placeholder="Current password" - autoComplete="current-password" - /> -
- - + setPassword(e.target.value)} + disabled={isDeleting || successNotice !== null} + placeholder="Current password" + autoComplete="current-password" + /> ) : ( - <> -

- Your account has no password (Google sign-in only). We will email - you a confirmation link - click it to delete. The link expires in 1 - hour. -

- - +

+ Your account is signed in via Google. Click delete to remove it + immediately. +

)} + +
); } diff --git a/apps/frontend/src/modules/Auth/AuthPopup.tsx b/apps/frontend/src/modules/Auth/AuthPopup.tsx index e428b31..9b0c6c3 100644 --- a/apps/frontend/src/modules/Auth/AuthPopup.tsx +++ b/apps/frontend/src/modules/Auth/AuthPopup.tsx @@ -13,6 +13,7 @@ import { type Login, type Register, } from "./auth.schema"; +import { PasswordField } from "./PasswordField"; import { useAppDispatch, useAppSelector } from "../../store"; import { closePopup } from "../../portals/popup.slice"; @@ -52,7 +53,6 @@ export function AuthPopup() { const currentUser = useAppSelector((state) => state.auth.user); const [stage, setStage] = useState("signin"); - const [showPassword, setShowPassword] = useState(false); const [verifyLogin, setVerifyLogin] = useState(""); const [verifyEmail, setVerifyEmail] = useState(""); const [resendNotice, setResendNotice] = useState(null); @@ -322,61 +322,16 @@ export function AuthPopup() { )} -
- - - - {formErrors.password && ( -

- {formErrors.password} -

- )} -
+
+ {error && ( +

{error}

+ )} +
+ ); +} diff --git a/apps/frontend/src/modules/Auth/auth.api.ts b/apps/frontend/src/modules/Auth/auth.api.ts index b806eb0..9f7d3f3 100644 --- a/apps/frontend/src/modules/Auth/auth.api.ts +++ b/apps/frontend/src/modules/Auth/auth.api.ts @@ -125,13 +125,6 @@ export const authApi = createApi({ invalidatesTags: ["User"], }), - sendOneTimePassword: builder.mutation<{ message: string }, void>({ - query: () => ({ - url: "auth/send-one-time-password", - method: "POST", - }), - }), - deleteAccount: builder.mutation<{ message: string }, { password?: string }>( { query: (body) => ({ @@ -152,13 +145,6 @@ export const authApi = createApi({ }, ), - requestDeleteAccount: builder.mutation<{ message: string }, void>({ - query: () => ({ - url: "auth/account/request-delete", - method: "POST", - }), - }), - updateProfile: builder.mutation< { message: string; user: UserSafe }, { login?: string; photoUrl?: string | null } @@ -190,8 +176,6 @@ export const { useGetCurrentUserQuery, useLogoutAllSessionsMutation, useSetPasswordMutation, - useSendOneTimePasswordMutation, useDeleteAccountMutation, - useRequestDeleteAccountMutation, useUpdateProfileMutation, } = authApi; diff --git a/apps/frontend/src/modules/FixedHeader/FixedHeader.tsx b/apps/frontend/src/modules/FixedHeader/FixedHeader.tsx index bf8ac62..7cfa89e 100644 --- a/apps/frontend/src/modules/FixedHeader/FixedHeader.tsx +++ b/apps/frontend/src/modules/FixedHeader/FixedHeader.tsx @@ -83,11 +83,11 @@ export function FixedHeader() { )}
-
+
{renderMenuButtons(true)}
From b87f7b7123bbad5295f70e7cc2008c7c39ecfff9 Mon Sep 17 00:00:00 2001 From: Bohdan Date: Thu, 21 May 2026 15:17:19 +0300 Subject: [PATCH 16/18] refactor(auth): shared utils, drop metadata, fix hasPassword fallback, trim comments --- apps/backend/.env.example | 2 +- .../migration.sql | 2 ++ apps/backend/prisma/schema.prisma | 1 - .../backend/src/controllers/authController.ts | 28 ++++--------------- apps/backend/tests/helpers/testUtils.ts | 4 +-- .../integration/auth.security.int.test.ts | 3 +- .../src/modules/Auth/AccountSettingsPopup.tsx | 27 ++++-------------- apps/frontend/src/modules/Auth/AuthPopup.tsx | 25 ++++------------- apps/frontend/src/modules/Auth/auth.utils.ts | 19 +++++++++++++ 9 files changed, 41 insertions(+), 70 deletions(-) create mode 100644 apps/backend/prisma/migrations/20260521090000_drop_email_token_metadata/migration.sql create mode 100644 apps/frontend/src/modules/Auth/auth.utils.ts diff --git a/apps/backend/.env.example b/apps/backend/.env.example index a54b72e..961ace9 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -37,4 +37,4 @@ SMTP_HOST="smtp.gmail.com" SMTP_PORT="587" SMTP_USER="your@gmail.com" SMTP_PASS="your_app_password" #https://myaccount.google.com/apppasswords -SMTP_FROM="CoinRadar " \ No newline at end of file +SMTP_FROM="CoinRadar " diff --git a/apps/backend/prisma/migrations/20260521090000_drop_email_token_metadata/migration.sql b/apps/backend/prisma/migrations/20260521090000_drop_email_token_metadata/migration.sql new file mode 100644 index 0000000..50b5fcf --- /dev/null +++ b/apps/backend/prisma/migrations/20260521090000_drop_email_token_metadata/migration.sql @@ -0,0 +1,2 @@ +-- Drop unused metadata column (merge-confirm flow was removed). +ALTER TABLE "EmailToken" DROP COLUMN IF EXISTS "metadata"; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index cec85ff..318f8d2 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -81,7 +81,6 @@ model EmailToken { expiresAt DateTime consumedAt DateTime? createdAt DateTime @default(now()) - metadata Json? user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId, purpose]) diff --git a/apps/backend/src/controllers/authController.ts b/apps/backend/src/controllers/authController.ts index 3c031cf..7ef3e3a 100644 --- a/apps/backend/src/controllers/authController.ts +++ b/apps/backend/src/controllers/authController.ts @@ -196,7 +196,6 @@ const createEmailToken = async ( userId: string, purpose: EmailTokenPurpose, ttlMs: number, - metadata?: Prisma.InputJsonValue, ): Promise<{ rawToken: string; expiresAt: Date }> => { await prisma.emailToken.deleteMany({ where: { userId, purpose, consumedAt: null }, @@ -207,13 +206,7 @@ const createEmailToken = async ( const expiresAt = new Date(Date.now() + ttlMs); await prisma.emailToken.create({ - data: { - userId, - tokenHash, - purpose, - expiresAt, - ...(metadata !== undefined && { metadata }), - }, + data: { userId, tokenHash, purpose, expiresAt }, }); return { rawToken, expiresAt }; @@ -241,11 +234,7 @@ const consumeEmailToken = async ( data: { consumedAt: new Date() }, }); - return { - ok: true as const, - userId: token.userId, - metadata: token.metadata, - }; + return { ok: true as const, userId: token.userId }; }; const saveRefreshToken = async ( @@ -613,7 +602,7 @@ export const googleAuthCallback = async (req: Request, res: Response) => { let user: UserWithWallets | null = existingIdentity?.user ?? null; if (!user) { - // Google must vouch for the email; otherwise we refuse to link or create. + // Require Google-verified email to link or create. if (!profile.emailVerified) { return res.redirect(`${FRONTEND_URL}?auth=google_error`); } @@ -624,9 +613,7 @@ export const googleAuthCallback = async (req: Request, res: Response) => { }); if (existingByEmail && existingByEmail.emailVerified) { - // Existing account already proved control of this email; attach Google - // (upsert handles the rare case of an old google identity on the same - // user that pointed to a different sub). + // Verified email owner exists; attach Google identity. await prisma.authIdentity.upsert({ where: { userId_provider: { @@ -643,8 +630,7 @@ export const googleAuthCallback = async (req: Request, res: Response) => { }); user = existingByEmail; } else { - // No verified owner: drop any unverified squat sharing the email and - // create a fresh google user. + // No verified owner: clear unverified squats, create a fresh Google account. await prisma.user.deleteMany({ where: { emailVerified: false, email: profile.email }, }); @@ -938,9 +924,7 @@ export const deleteAccount = async (req: Request, res: Response) => { return res.status(404).json({ error: "User not found." }); } - // Users with a password confirm via that password (sanity check against a - // leftover session). Google-only accounts skip it — the active session is - // proof enough, and there is no second factor we could ask for here. + // Password users confirm via password; Google-only accounts rely on session. if (user.password) { if (!password) { return res diff --git a/apps/backend/tests/helpers/testUtils.ts b/apps/backend/tests/helpers/testUtils.ts index 81b29f3..0bf3788 100644 --- a/apps/backend/tests/helpers/testUtils.ts +++ b/apps/backend/tests/helpers/testUtils.ts @@ -33,9 +33,7 @@ export const registerAndCreateWallet = async () => { ); } - // Register no longer opens a session and login is blocked until the email - // is confirmed. Tests can't click the email link, so flip the verified flag - // directly and then log in to get cookies. + // Flip emailVerified directly; tests can't click the confirmation link. await prisma.user.update({ where: { login }, data: { emailVerified: true }, diff --git a/apps/backend/tests/integration/auth.security.int.test.ts b/apps/backend/tests/integration/auth.security.int.test.ts index 6c67a84..78c80e9 100644 --- a/apps/backend/tests/integration/auth.security.int.test.ts +++ b/apps/backend/tests/integration/auth.security.int.test.ts @@ -247,8 +247,7 @@ describe("Auth security flows", () => { "auth=google_success", ); - // The squat row must be gone — new google user may pick the same login, - // so identity must be checked by id, not by login. + // Squat row deleted; check by id, not login. const stillSquatById = await prisma.user.findUnique({ where: { id: squatUser?.id ?? "" }, }); diff --git a/apps/frontend/src/modules/Auth/AccountSettingsPopup.tsx b/apps/frontend/src/modules/Auth/AccountSettingsPopup.tsx index 6fd6ced..1ac5daa 100644 --- a/apps/frontend/src/modules/Auth/AccountSettingsPopup.tsx +++ b/apps/frontend/src/modules/Auth/AccountSettingsPopup.tsx @@ -8,23 +8,22 @@ import type { UserSafe } from "./auth.schema"; import { PasswordField } from "./PasswordField"; import { useAppDispatch, useAppSelector } from "../../store"; import { closePopup } from "../../portals/popup.slice"; +import { + extractServerError, + inputClass, + secondaryButtonClass, +} from "./auth.utils"; type Section = "profile" | "password" | "delete"; const MAX_PHOTO_BYTES = 600 * 1024; -const inputClass = - "w-full px-4 py-3 bg-white/10 dark:bg-black/20 border border-white/10 rounded-xl focus:outline-none focus:ring-2 focus:ring-purple-400/50 text-(--color-text) transitioned hover:bg-white/20 dark:hover:bg-black/30 placeholder-gray-400"; - const primaryButtonClass = "w-full py-3 rounded-xl font-bold text-white shadow-lg transform active:scale-95 transition-all cursor-pointer hover:shadow-purple-500/30 hover:-translate-y-0.5 disabled:opacity-60 disabled:cursor-not-allowed disabled:hover:translate-y-0"; const dangerButtonClass = "w-full py-3 rounded-xl font-bold text-white shadow-lg transform active:scale-95 transition-all cursor-pointer bg-red-700 hover:bg-red-800 disabled:opacity-60 disabled:cursor-not-allowed"; -const secondaryButtonClass = - "w-full cursor-pointer py-3 rounded-xl font-semibold text-sm border border-white/20 text-(--color-text) hover:bg-white/10 transition-colors disabled:opacity-60"; - const tabButtonClass = (active: boolean) => `flex-1 px-3 py-2 rounded-lg text-sm font-semibold transition-colors cursor-pointer ${ active @@ -32,20 +31,6 @@ const tabButtonClass = (active: boolean) => : "text-(--color-text) opacity-70 hover:opacity-100 hover:bg-white/5 border border-transparent" }`; -const extractServerError = (error: unknown): string | null => { - if ( - error && - typeof error === "object" && - "data" in error && - error.data && - typeof error.data === "object" && - "error" in error.data - ) { - return String((error.data as { error: unknown }).error); - } - return null; -}; - export function AccountSettingsPopup() { const dispatch = useAppDispatch(); const currentUser = useAppSelector((state) => state.auth.user); @@ -60,7 +45,7 @@ export function AccountSettingsPopup() { ); } - const hasPassword = currentUser.hasPassword ?? true; + const hasPassword = currentUser.hasPassword ?? false; return (
diff --git a/apps/frontend/src/modules/Auth/AuthPopup.tsx b/apps/frontend/src/modules/Auth/AuthPopup.tsx index 9b0c6c3..f06640c 100644 --- a/apps/frontend/src/modules/Auth/AuthPopup.tsx +++ b/apps/frontend/src/modules/Auth/AuthPopup.tsx @@ -16,34 +16,19 @@ import { import { PasswordField } from "./PasswordField"; import { useAppDispatch, useAppSelector } from "../../store"; import { closePopup } from "../../portals/popup.slice"; +import { + extractServerError, + inputClass, + secondaryButtonClass, +} from "./auth.utils"; type Stage = "signin" | "signup" | "verifying"; type FormKeys = "login" | "password" | "email"; type FormErrors = Partial>; -const inputClass = - "w-full px-4 py-3 bg-white/10 dark:bg-black/20 border border-white/10 rounded-xl focus:outline-none focus:ring-2 focus:ring-purple-400/50 text-(--color-text) transitioned hover:bg-white/20 dark:hover:bg-black/30 placeholder-gray-400"; - const primaryButtonClass = "w-full py-4 rounded-xl font-bold text-white text-lg shadow-lg transform active:scale-95 transition-all duration-200 cursor-pointer hover:shadow-purple-500/30 hover:-translate-y-1"; -const secondaryButtonClass = - "w-full cursor-pointer py-3 rounded-xl font-semibold text-sm border border-white/20 text-(--color-text) hover:bg-white/10 transition-colors disabled:opacity-60"; - -const extractServerError = (error: unknown): string | null => { - if ( - error && - typeof error === "object" && - "data" in error && - error.data && - typeof error.data === "object" && - "error" in error.data - ) { - return String((error.data as { error: unknown }).error); - } - return null; -}; - export function AuthPopup() { const dispatch = useAppDispatch(); const BASE_URL = diff --git a/apps/frontend/src/modules/Auth/auth.utils.ts b/apps/frontend/src/modules/Auth/auth.utils.ts new file mode 100644 index 0000000..3691485 --- /dev/null +++ b/apps/frontend/src/modules/Auth/auth.utils.ts @@ -0,0 +1,19 @@ +export const extractServerError = (error: unknown): string | null => { + if ( + error && + typeof error === "object" && + "data" in error && + error.data && + typeof error.data === "object" && + "error" in error.data + ) { + return String((error.data as { error: unknown }).error); + } + return null; +}; + +export const inputClass = + "w-full px-4 py-3 bg-white/10 dark:bg-black/20 border border-white/10 rounded-xl focus:outline-none focus:ring-2 focus:ring-purple-400/50 text-(--color-text) transitioned hover:bg-white/20 dark:hover:bg-black/30 placeholder-gray-400"; + +export const secondaryButtonClass = + "w-full cursor-pointer py-3 rounded-xl font-semibold text-sm border border-white/20 text-(--color-text) hover:bg-white/10 transition-colors disabled:opacity-60"; From 845d8afb71b7ba98d34333e231f0b2bdfa5dfcba Mon Sep 17 00:00:00 2001 From: Bohdan Date: Thu, 21 May 2026 15:28:09 +0300 Subject: [PATCH 17/18] refactor(auth): split authController and Auth popups into subfolders --- .../src/controllers/auth/authConfig.ts | 54 + .../src/controllers/auth/authHelpers.ts | 159 +++ .../src/controllers/auth/emailTokens.ts | 51 + .../src/controllers/auth/googleHandlers.ts | 214 ++++ .../src/controllers/auth/loginHandler.ts | 48 + .../src/controllers/auth/profileHandlers.ts | 173 +++ .../src/controllers/auth/registerHandlers.ts | 123 ++ .../src/controllers/auth/sessionHandlers.ts | 166 +++ .../backend/src/controllers/authController.ts | 1024 ----------------- apps/backend/src/router/authRouter.ts | 10 +- .../src/modules/Auth/AccountSettingsPopup.tsx | 446 ------- .../AccountSettingsPopup.tsx | 81 ++ .../AccountSettingsPopup/DeleteSection.tsx | 83 ++ .../AccountSettingsPopup/PasswordSection.tsx | 120 ++ .../AccountSettingsPopup/ProfileSection.tsx | 172 +++ apps/frontend/src/modules/Auth/Auth.tsx | 2 +- .../Auth/{ => AuthPopup}/AuthPopup.tsx | 136 +-- .../modules/Auth/AuthPopup/SignedInView.tsx | 55 + .../modules/Auth/AuthPopup/VerifyingStage.tsx | 75 ++ apps/frontend/src/modules/Auth/Settings.tsx | 2 +- 20 files changed, 1600 insertions(+), 1594 deletions(-) create mode 100644 apps/backend/src/controllers/auth/authConfig.ts create mode 100644 apps/backend/src/controllers/auth/authHelpers.ts create mode 100644 apps/backend/src/controllers/auth/emailTokens.ts create mode 100644 apps/backend/src/controllers/auth/googleHandlers.ts create mode 100644 apps/backend/src/controllers/auth/loginHandler.ts create mode 100644 apps/backend/src/controllers/auth/profileHandlers.ts create mode 100644 apps/backend/src/controllers/auth/registerHandlers.ts create mode 100644 apps/backend/src/controllers/auth/sessionHandlers.ts delete mode 100644 apps/backend/src/controllers/authController.ts delete mode 100644 apps/frontend/src/modules/Auth/AccountSettingsPopup.tsx create mode 100644 apps/frontend/src/modules/Auth/AccountSettingsPopup/AccountSettingsPopup.tsx create mode 100644 apps/frontend/src/modules/Auth/AccountSettingsPopup/DeleteSection.tsx create mode 100644 apps/frontend/src/modules/Auth/AccountSettingsPopup/PasswordSection.tsx create mode 100644 apps/frontend/src/modules/Auth/AccountSettingsPopup/ProfileSection.tsx rename apps/frontend/src/modules/Auth/{ => AuthPopup}/AuthPopup.tsx (67%) create mode 100644 apps/frontend/src/modules/Auth/AuthPopup/SignedInView.tsx create mode 100644 apps/frontend/src/modules/Auth/AuthPopup/VerifyingStage.tsx diff --git a/apps/backend/src/controllers/auth/authConfig.ts b/apps/backend/src/controllers/auth/authConfig.ts new file mode 100644 index 0000000..afd3ed1 --- /dev/null +++ b/apps/backend/src/controllers/auth/authConfig.ts @@ -0,0 +1,54 @@ +import type { Prisma } from "@prisma/client"; +import type { SignOptions } from "jsonwebtoken"; + +export const EMAIL_VERIFY_TTL_MS = 24 * 60 * 60 * 1000; + +export const JWT_SECRET = process.env.JWT_SECRET; +export const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || JWT_SECRET; +export const ACCESS_TOKEN_EXPIRES = (process.env.JWT_ACCESS_EXPIRES || + "15m") as SignOptions["expiresIn"]; +export const REFRESH_EXPIRES_DAYS = Number( + process.env.JWT_REFRESH_EXPIRES_DAYS || 30, +); +export const REFRESH_TOKEN_EXPIRES = + `${REFRESH_EXPIRES_DAYS}d` as SignOptions["expiresIn"]; + +export const ACCESS_COOKIE_NAME = "access_token"; +export const REFRESH_COOKIE_NAME = "refresh_token"; +export const OAUTH_STATE_COOKIE_NAME = "oauth_state"; + +export const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID; +export const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET; +export const GOOGLE_REDIRECT_URI = process.env.GOOGLE_REDIRECT_URI; +export const FRONTEND_URL = process.env.FRONTEND_URL || "http://localhost:5173"; + +export const saltRounds = 10; + +const isProduction = process.env.NODE_ENV === "production"; +const cookieSameSite = ( + process.env.COOKIE_SAME_SITE || (isProduction ? "none" : "lax") +).toLowerCase(); +export const normalizedSameSite: "lax" | "strict" | "none" = + cookieSameSite === "strict" + ? "strict" + : cookieSameSite === "none" + ? "none" + : "lax"; +export const cookieSecure = normalizedSameSite === "none" ? true : isProduction; + +export type WalletListItem = Prisma.WalletGetPayload<{ + select: { id: true; name: true }; +}>; + +export type UserWithWallets = Prisma.UserGetPayload<{ + include: { + wallets: { select: { id: true; name: true } }; + }; +}>; + +export const userInclude = { + wallets: { + select: { id: true, name: true } as const, + orderBy: { createdAt: "asc" as const }, + }, +}; diff --git a/apps/backend/src/controllers/auth/authHelpers.ts b/apps/backend/src/controllers/auth/authHelpers.ts new file mode 100644 index 0000000..5f8b158 --- /dev/null +++ b/apps/backend/src/controllers/auth/authHelpers.ts @@ -0,0 +1,159 @@ +import type { Request, Response } from "express"; +import crypto from "node:crypto"; +import jwt from "jsonwebtoken"; +import prisma from "../../prisma.js"; +import { UserSchema } from "../../models/AuthSchema.js"; +import { + JWT_SECRET, + JWT_REFRESH_SECRET, + ACCESS_TOKEN_EXPIRES, + REFRESH_TOKEN_EXPIRES, + REFRESH_EXPIRES_DAYS, + ACCESS_COOKIE_NAME, + REFRESH_COOKIE_NAME, + OAUTH_STATE_COOKIE_NAME, + normalizedSameSite, + cookieSecure, + type WalletListItem, + type UserWithWallets, +} from "./authConfig.js"; + +export const hashToken = (token: string): string => + crypto.createHash("sha256").update(token).digest("hex"); + +export const signAccessToken = (userId: string, userLogin: string): string => { + if (!JWT_SECRET) throw new Error("JWT_SECRET is not defined."); + return jwt.sign({ userId, userLogin }, JWT_SECRET, { + expiresIn: ACCESS_TOKEN_EXPIRES!, + }); +}; + +export const signRefreshToken = (userId: string): string => { + if (!JWT_REFRESH_SECRET) + throw new Error("JWT_REFRESH_SECRET is not defined."); + return jwt.sign( + { userId, type: "refresh", jti: crypto.randomUUID() }, + JWT_REFRESH_SECRET, + { expiresIn: REFRESH_TOKEN_EXPIRES! }, + ); +}; + +export const setAuthCookies = ( + res: Response, + accessToken: string, + refreshToken: string, +): void => { + res.cookie(ACCESS_COOKIE_NAME, accessToken, { + httpOnly: true, + secure: cookieSecure, + sameSite: normalizedSameSite, + maxAge: 15 * 60 * 1000, + path: "/", + }); + res.cookie(REFRESH_COOKIE_NAME, refreshToken, { + httpOnly: true, + secure: cookieSecure, + sameSite: normalizedSameSite, + maxAge: REFRESH_EXPIRES_DAYS * 24 * 60 * 60 * 1000, + path: "/api/auth", + }); +}; + +export const clearAuthCookies = (res: Response): void => { + res.clearCookie(ACCESS_COOKIE_NAME, { + httpOnly: true, + secure: cookieSecure, + sameSite: normalizedSameSite, + path: "/", + }); + res.clearCookie(REFRESH_COOKIE_NAME, { + httpOnly: true, + secure: cookieSecure, + sameSite: normalizedSameSite, + path: "/api/auth", + }); +}; + +export const setOAuthStateCookie = (res: Response, state: string): void => { + res.cookie(OAUTH_STATE_COOKIE_NAME, state, { + httpOnly: true, + secure: cookieSecure, + sameSite: normalizedSameSite, + maxAge: 10 * 60 * 1000, + path: "/api/auth", + }); +}; + +export const clearOAuthStateCookie = (res: Response): void => { + res.clearCookie(OAUTH_STATE_COOKIE_NAME, { + httpOnly: true, + secure: cookieSecure, + sameSite: normalizedSameSite, + path: "/api/auth", + }); +}; + +export const parseCookieHeader = ( + cookieHeader?: string, +): Record => { + if (!cookieHeader) return {}; + return cookieHeader + .split(";") + .map((item: string) => item.trim()) + .filter(Boolean) + .reduce((acc: Record, pair: string) => { + const eqIndex = pair.indexOf("="); + if (eqIndex === -1) return acc; + const key = pair.slice(0, eqIndex).trim(); + const value = pair.slice(eqIndex + 1).trim(); + if (key) acc[key] = decodeURIComponent(value); + return acc; + }, {}); +}; + +export const toSafeUserResponse = (user: UserWithWallets) => + UserSchema.parse({ + uid: user.id, + login: user.login, + email: user.email, + emailVerified: user.emailVerified, + hasPassword: user.password !== null, + photoUrl: user.photoUrl, + wallets: user.wallets.map((w: WalletListItem) => ({ + id: w.id, + name: w.name, + })), + }); + +export const saveRefreshToken = async ( + userId: string, + rawRefreshToken: string, + req: Request, + replacedByTokenHash?: string, +): Promise => { + const tokenHash = hashToken(rawRefreshToken); + const expiresAt = new Date( + Date.now() + REFRESH_EXPIRES_DAYS * 24 * 60 * 60 * 1000, + ); + await prisma.refreshToken.create({ + data: { + userId, + tokenHash, + expiresAt, + replacedByTokenHash: replacedByTokenHash || null, + userAgent: req.headers["user-agent"] || null, + ip: req.ip || null, + }, + }); +}; + +export const createSession = async ( + user: UserWithWallets, + req: Request, + res: Response, +): Promise => { + const accessToken = signAccessToken(user.id, user.login); + const refreshToken = signRefreshToken(user.id); + await saveRefreshToken(user.id, refreshToken, req); + setAuthCookies(res, accessToken, refreshToken); +}; diff --git a/apps/backend/src/controllers/auth/emailTokens.ts b/apps/backend/src/controllers/auth/emailTokens.ts new file mode 100644 index 0000000..83cfc4b --- /dev/null +++ b/apps/backend/src/controllers/auth/emailTokens.ts @@ -0,0 +1,51 @@ +import crypto from "node:crypto"; +import prisma from "../../prisma.js"; +import { hashToken } from "./authHelpers.js"; +import { EMAIL_VERIFY_TTL_MS } from "./authConfig.js"; + +export type EmailTokenPurpose = "verify_email"; + +export { EMAIL_VERIFY_TTL_MS }; + +export const createEmailToken = async ( + userId: string, + purpose: EmailTokenPurpose, + ttlMs: number, +): Promise<{ rawToken: string; expiresAt: Date }> => { + await prisma.emailToken.deleteMany({ + where: { userId, purpose, consumedAt: null }, + }); + + const rawToken = crypto.randomBytes(32).toString("base64url"); + const tokenHash = hashToken(rawToken); + const expiresAt = new Date(Date.now() + ttlMs); + + await prisma.emailToken.create({ + data: { userId, tokenHash, purpose, expiresAt }, + }); + + return { rawToken, expiresAt }; +}; + +export const consumeEmailToken = async ( + rawToken: string, + expectedPurpose: EmailTokenPurpose, +) => { + const tokenHash = hashToken(rawToken); + const token = await prisma.emailToken.findUnique({ where: { tokenHash } }); + + if (!token) return { ok: false as const, reason: "not_found" as const }; + if (token.purpose !== expectedPurpose) + return { ok: false as const, reason: "wrong_purpose" as const }; + if (token.consumedAt) + return { ok: false as const, reason: "already_used" as const }; + if (token.expiresAt.getTime() <= Date.now()) + return { ok: false as const, reason: "expired" as const }; + + await prisma.emailToken.update({ + where: { id: token.id }, + data: { consumedAt: new Date() }, + }); + + return { ok: true as const, userId: token.userId }; +}; diff --git a/apps/backend/src/controllers/auth/googleHandlers.ts b/apps/backend/src/controllers/auth/googleHandlers.ts new file mode 100644 index 0000000..484f5b5 --- /dev/null +++ b/apps/backend/src/controllers/auth/googleHandlers.ts @@ -0,0 +1,214 @@ +import type { Request, Response } from "express"; +import crypto from "node:crypto"; +import prisma from "../../prisma.js"; +import { + GOOGLE_CLIENT_ID, + GOOGLE_CLIENT_SECRET, + GOOGLE_REDIRECT_URI, + FRONTEND_URL, + userInclude, + type UserWithWallets, +} from "./authConfig.js"; +import { + parseCookieHeader, + setOAuthStateCookie, + clearOAuthStateCookie, + createSession, +} from "./authHelpers.js"; + +const normalizeLogin = (raw: string): string => + raw + .toLowerCase() + .trim() + .replace(/[^a-z0-9_]/g, "_") + .replace(/_+/g, "_") + .replace(/^_+|_+$/g, "") || "user"; + +const generateUniqueLogin = async (seed: string): Promise => { + const base = normalizeLogin(seed).slice(0, 24); + let candidate = base; + let suffix = 1; + while (true) { + const existing = await prisma.user.findFirst({ + where: { login: candidate }, + select: { id: true }, + }); + if (!existing) return candidate; + suffix += 1; + candidate = `${base}_${suffix}`.slice(0, 30); + } +}; + +const getGoogleProfileFromCode = async (code: string) => { + if (!GOOGLE_CLIENT_ID || !GOOGLE_CLIENT_SECRET || !GOOGLE_REDIRECT_URI) { + throw new Error("Google OAuth env vars are missing."); + } + + const tokenResponse = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + code, + client_id: GOOGLE_CLIENT_ID, + client_secret: GOOGLE_CLIENT_SECRET, + redirect_uri: GOOGLE_REDIRECT_URI, + grant_type: "authorization_code", + }), + }); + + if (!tokenResponse.ok) + throw new Error("Failed to exchange Google auth code."); + + const tokenData = (await tokenResponse.json()) as { id_token?: string }; + if (!tokenData.id_token) throw new Error("Google did not return id_token."); + + const infoResponse = await fetch( + `https://oauth2.googleapis.com/tokeninfo?id_token=${encodeURIComponent(tokenData.id_token)}`, + ); + if (!infoResponse.ok) throw new Error("Failed to verify Google id_token."); + + const info = (await infoResponse.json()) as { + aud?: string; + sub?: string; + email?: string; + email_verified?: string; + name?: string; + picture?: string; + }; + + if (info.aud !== GOOGLE_CLIENT_ID) + throw new Error("Google token audience mismatch."); + if (!info.sub || !info.email) + throw new Error("Google profile is incomplete."); + + return { + sub: info.sub, + email: info.email, + emailVerified: info.email_verified === "true", + name: info.name || info.email, + picture: info.picture || null, + }; +}; + +export const startGoogleAuth = async (_req: Request, res: Response) => { + try { + if (!GOOGLE_CLIENT_ID || !GOOGLE_REDIRECT_URI) { + return res.status(500).json({ error: "Google OAuth is not configured." }); + } + + const state = crypto.randomBytes(32).toString("hex"); + setOAuthStateCookie(res, state); + + const params = new URLSearchParams({ + client_id: GOOGLE_CLIENT_ID, + redirect_uri: GOOGLE_REDIRECT_URI, + response_type: "code", + scope: "openid email profile", + access_type: "offline", + prompt: "consent", + state, + }); + + return res.redirect( + `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`, + ); + } catch (error) { + console.error("Google OAuth start error:", error); + return res.status(500).json({ error: "Unable to start Google OAuth." }); + } +}; + +export const googleAuthCallback = async (req: Request, res: Response) => { + try { + const code = req.query.code as string | undefined; + const incomingState = req.query.state as string | undefined; + const cookies = parseCookieHeader(req.headers.cookie); + const storedState = cookies["oauth_state"]; + clearOAuthStateCookie(res); + + if ( + !code || + !incomingState || + !storedState || + incomingState !== storedState + ) { + return res.redirect(`${FRONTEND_URL}?auth=google_error`); + } + + const profile = await getGoogleProfileFromCode(code); + + const existingIdentity = await prisma.authIdentity.findUnique({ + where: { + provider_providerId: { provider: "google", providerId: profile.sub }, + }, + include: { user: { include: userInclude } }, + }); + + let user: UserWithWallets | null = existingIdentity?.user ?? null; + + if (!user) { + // Require Google-verified email to link or create. + if (!profile.emailVerified) { + return res.redirect(`${FRONTEND_URL}?auth=google_error`); + } + + const existingByEmail = await prisma.user.findFirst({ + where: { email: profile.email }, + include: userInclude, + }); + + if (existingByEmail && existingByEmail.emailVerified) { + // Verified email owner exists; attach Google identity. + await prisma.authIdentity.upsert({ + where: { + userId_provider: { userId: existingByEmail.id, provider: "google" }, + }, + create: { + userId: existingByEmail.id, + provider: "google", + providerId: profile.sub, + }, + update: { providerId: profile.sub }, + }); + user = existingByEmail; + } else { + // No verified owner: clear unverified squats, create a fresh Google account. + await prisma.user.deleteMany({ + where: { emailVerified: false, email: profile.email }, + }); + + const seed = profile.email.split("@")[0] || profile.name; + const login = await generateUniqueLogin(seed); + + user = await prisma.$transaction(async (tx) => { + const created = await tx.user.create({ + data: { + login, + email: profile.email, + password: null, + emailVerified: true, + photoUrl: profile.picture, + }, + include: userInclude, + }); + + await tx.authIdentity.create({ + data: { + userId: created.id, + provider: "google", + providerId: profile.sub, + }, + }); + + return created; + }); + } + } + + await createSession(user, req, res); + return res.redirect(`${FRONTEND_URL}?auth=google_success`); + } catch (error) { + console.error("Google OAuth callback error:", error); + return res.redirect(`${FRONTEND_URL}?auth=google_error`); + } +}; diff --git a/apps/backend/src/controllers/auth/loginHandler.ts b/apps/backend/src/controllers/auth/loginHandler.ts new file mode 100644 index 0000000..e0fa444 --- /dev/null +++ b/apps/backend/src/controllers/auth/loginHandler.ts @@ -0,0 +1,48 @@ +import type { Request, Response } from "express"; +import bcrypt from "bcrypt"; +import { z } from "zod"; +import prisma from "../../prisma.js"; +import { LoginSchema } from "../../models/AuthSchema.js"; +import { handleZodError } from "../../utils/helpers.js"; +import { userInclude } from "./authConfig.js"; +import { createSession, toSafeUserResponse } from "./authHelpers.js"; + +export const loginUser = async (req: Request, res: Response) => { + try { + const { login, password } = LoginSchema.parse(req.body); + + const user = await prisma.user.findFirst({ + where: { login }, + include: userInclude, + }); + + if (!user || !user.password) { + return res.status(401).json({ error: "Invalid credentials." }); + } + + const passwordMatch = await bcrypt.compare(password, user.password); + if (!passwordMatch) { + return res.status(401).json({ error: "Invalid credentials." }); + } + + if (!user.emailVerified) { + return res.status(403).json({ + error: + "Email not confirmed. Check your inbox or request a new verification link.", + requiresVerification: true, + email: user.email, + }); + } + + await createSession(user, req, res); + + return res.status(200).json({ + message: "Login successful", + user: toSafeUserResponse(user), + }); + } catch (error: any) { + if (error instanceof z.ZodError) return handleZodError(res, error); + console.error("Login error:", error); + return res.status(500).json({ error: "Server error during login." }); + } +}; diff --git a/apps/backend/src/controllers/auth/profileHandlers.ts b/apps/backend/src/controllers/auth/profileHandlers.ts new file mode 100644 index 0000000..ff39b5e --- /dev/null +++ b/apps/backend/src/controllers/auth/profileHandlers.ts @@ -0,0 +1,173 @@ +import type { Request, Response } from "express"; +import type { Prisma } from "@prisma/client"; +import bcrypt from "bcrypt"; +import { z } from "zod"; +import prisma from "../../prisma.js"; +import { + SetPasswordSchema, + DeleteAccountSchema, + UpdateProfileSchema, +} from "../../models/AuthSchema.js"; +import { handleZodError } from "../../utils/helpers.js"; +import { + saltRounds, + ACCESS_COOKIE_NAME, + normalizedSameSite, + cookieSecure, +} from "./authConfig.js"; +import { + signAccessToken, + clearAuthCookies, + toSafeUserResponse, +} from "./authHelpers.js"; + +export const setPassword = async (req: Request, res: Response) => { + try { + const userId = req.userId; + if (!userId) return res.status(401).json({ error: "Unauthorized." }); + + const { password, oldPassword } = SetPasswordSchema.parse(req.body); + + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (!user) return res.status(404).json({ error: "User not found." }); + + if (user.password) { + if (!oldPassword) { + return res + .status(400) + .json({ error: "Current password is required to change it." }); + } + const match = await bcrypt.compare(oldPassword, user.password); + if (!match) { + return res.status(401).json({ error: "Current password is wrong." }); + } + } + + const hashed = await bcrypt.hash(password, saltRounds); + + await prisma.$transaction(async (tx) => { + await tx.user.update({ + where: { id: userId }, + data: { password: hashed }, + }); + await tx.authIdentity.upsert({ + where: { userId_provider: { userId, provider: "local" } }, + create: { userId, provider: "local" }, + update: {}, + }); + }); + + return res.status(200).json({ + message: user.password + ? "Password updated." + : "Password set. You can now sign in with login and password.", + }); + } catch (error) { + if (error instanceof z.ZodError) return handleZodError(res, error); + console.error("Set password error:", error); + return res + .status(500) + .json({ error: "Server error during password change." }); + } +}; + +export const deleteAccount = async (req: Request, res: Response) => { + try { + const userId = req.userId; + if (!userId) return res.status(401).json({ error: "Unauthorized." }); + + const { password } = DeleteAccountSchema.parse(req.body ?? {}); + + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (!user) return res.status(404).json({ error: "User not found." }); + + // Password users confirm via password; Google-only accounts rely on session. + if (user.password) { + if (!password) { + return res + .status(400) + .json({ error: "Password is required to delete the account." }); + } + const match = await bcrypt.compare(password, user.password); + if (!match) { + return res.status(401).json({ error: "Password is wrong." }); + } + } + + await prisma.user.delete({ where: { id: userId } }); + clearAuthCookies(res); + return res.status(200).json({ message: "Account deleted." }); + } catch (error) { + if (error instanceof z.ZodError) return handleZodError(res, error); + console.error("Delete account error:", error); + return res + .status(500) + .json({ error: "Server error during account deletion." }); + } +}; + +export const updateProfile = async (req: Request, res: Response) => { + try { + const userId = req.userId; + if (!userId) return res.status(401).json({ error: "Unauthorized." }); + + const parsed = UpdateProfileSchema.parse(req.body ?? {}); + if (parsed.login === undefined && parsed.photoUrl === undefined) { + return res.status(400).json({ error: "Nothing to update." }); + } + + const data: Prisma.UserUpdateInput = {}; + if (parsed.login !== undefined) data.login = parsed.login; + if (parsed.photoUrl !== undefined) { + const normalized = + parsed.photoUrl === null ? null : parsed.photoUrl.trim(); + data.photoUrl = normalized === "" ? null : normalized; + } + + let updated; + try { + updated = await prisma.user.update({ + where: { id: userId }, + data, + include: { + wallets: { + select: { id: true, name: true }, + orderBy: { createdAt: "asc" }, + }, + }, + }); + } catch (e: unknown) { + if ( + e && + typeof e === "object" && + "code" in e && + (e as { code: string }).code === "P2002" + ) { + return res.status(409).json({ error: "Login is already taken." }); + } + throw e; + } + + if (parsed.login !== undefined) { + const newAccess = signAccessToken(updated.id, updated.login); + res.cookie(ACCESS_COOKIE_NAME, newAccess, { + httpOnly: true, + secure: cookieSecure, + sameSite: normalizedSameSite, + maxAge: 15 * 60 * 1000, + path: "/", + }); + } + + return res.status(200).json({ + message: "Profile updated.", + user: toSafeUserResponse(updated), + }); + } catch (error) { + if (error instanceof z.ZodError) return handleZodError(res, error); + console.error("Update profile error:", error); + return res + .status(500) + .json({ error: "Server error during profile update." }); + } +}; diff --git a/apps/backend/src/controllers/auth/registerHandlers.ts b/apps/backend/src/controllers/auth/registerHandlers.ts new file mode 100644 index 0000000..3a8d551 --- /dev/null +++ b/apps/backend/src/controllers/auth/registerHandlers.ts @@ -0,0 +1,123 @@ +import type { Request, Response } from "express"; +import bcrypt from "bcrypt"; +import { z } from "zod"; +import prisma from "../../prisma.js"; +import { + RegisterSchema, + ResendVerificationSchema, +} from "../../models/AuthSchema.js"; +import { handleZodError } from "../../utils/helpers.js"; +import { sendVerificationEmail } from "../../services/emailService.js"; +import { saltRounds, FRONTEND_URL } from "./authConfig.js"; +import { + createEmailToken, + consumeEmailToken, + EMAIL_VERIFY_TTL_MS, +} from "./emailTokens.js"; + +export const registerUser = async (req: Request, res: Response) => { + try { + const { login, password, email } = RegisterSchema.parse(req.body); + const hashedPassword = await bcrypt.hash(password, saltRounds); + + const newUser = await prisma.$transaction(async (tx) => { + await tx.user.deleteMany({ + where: { emailVerified: false, OR: [{ login }, { email }] }, + }); + + const user = await tx.user.create({ + data: { login, password: hashedPassword, email, emailVerified: false }, + }); + + await tx.authIdentity.create({ + data: { userId: user.id, provider: "local" }, + }); + + return user; + }); + + const { rawToken } = await createEmailToken( + newUser.id, + "verify_email", + EMAIL_VERIFY_TTL_MS, + ); + + try { + await sendVerificationEmail(email, rawToken); + } catch (mailError) { + console.error("Failed to send verification email:", mailError); + } + + return res.status(201).json({ + message: + "Account created. Check your inbox to confirm your email before signing in.", + requiresVerification: true, + email, + }); + } catch (error: any) { + if (error.code === "P2002") { + return res.status(409).json({ + error: + "Account with this login or email already exists. Try signing in or pick different credentials.", + }); + } + if (error instanceof z.ZodError) return handleZodError(res, error); + console.error("Registration error:", error); + return res.status(500).json({ error: "Server error during registration." }); + } +}; + +export const verifyEmail = async (req: Request, res: Response) => { + const token = typeof req.query.token === "string" ? req.query.token : ""; + if (!token) return res.redirect(`${FRONTEND_URL}?auth=verify_error`); + + const result = await consumeEmailToken(token, "verify_email"); + if (!result.ok) { + const param = + result.reason === "already_used" ? "already_verified" : "verify_error"; + return res.redirect(`${FRONTEND_URL}?auth=${param}`); + } + + await prisma.user.update({ + where: { id: result.userId }, + data: { emailVerified: true }, + }); + + return res.redirect(`${FRONTEND_URL}?auth=verified`); +}; + +export const resendVerification = async (req: Request, res: Response) => { + try { + const { login } = ResendVerificationSchema.parse(req.body); + const user = await prisma.user.findFirst({ where: { login } }); + + const genericResponse = { + message: + "If an account exists for that login and is unverified, a new verification email is on the way.", + }; + + if (!user || !user.email || user.emailVerified) { + return res.status(200).json(genericResponse); + } + + const { rawToken } = await createEmailToken( + user.id, + "verify_email", + EMAIL_VERIFY_TTL_MS, + ); + + try { + await sendVerificationEmail(user.email, rawToken); + } catch (mailError) { + console.error("Failed to resend verification email:", mailError); + } + + return res.status(200).json(genericResponse); + } catch (error) { + if (error instanceof z.ZodError) return handleZodError(res, error); + console.error("Resend verification error:", error); + return res + .status(500) + .json({ error: "Server error during verification resend." }); + } +}; diff --git a/apps/backend/src/controllers/auth/sessionHandlers.ts b/apps/backend/src/controllers/auth/sessionHandlers.ts new file mode 100644 index 0000000..e1aaac4 --- /dev/null +++ b/apps/backend/src/controllers/auth/sessionHandlers.ts @@ -0,0 +1,166 @@ +import type { Request, Response } from "express"; +import jwt from "jsonwebtoken"; +import prisma from "../../prisma.js"; +import { + JWT_REFRESH_SECRET, + REFRESH_EXPIRES_DAYS, + userInclude, +} from "./authConfig.js"; +import { + parseCookieHeader, + hashToken, + signRefreshToken, + signAccessToken, + setAuthCookies, + clearAuthCookies, + toSafeUserResponse, +} from "./authHelpers.js"; + +export const refreshSession = async (req: Request, res: Response) => { + try { + const cookies = parseCookieHeader(req.headers.cookie); + const refreshToken = cookies["refresh_token"]; + + if (!refreshToken) { + clearAuthCookies(res); + return res.status(401).json({ error: "Refresh token is missing." }); + } + + if (!JWT_REFRESH_SECRET) { + return res.status(500).json({ error: "Server configuration error." }); + } + + try { + jwt.verify(refreshToken, JWT_REFRESH_SECRET); + } catch { + clearAuthCookies(res); + return res.status(401).json({ error: "Invalid refresh token." }); + } + + const refreshHash = hashToken(refreshToken); + const existingToken = await prisma.refreshToken.findFirst({ + where: { tokenHash: refreshHash, revokedAt: null }, + include: { user: { include: userInclude } }, + }); + + if (!existingToken) { + clearAuthCookies(res); + return res + .status(401) + .json({ error: "Refresh token is revoked or invalid." }); + } + + if (existingToken.expiresAt.getTime() <= Date.now()) { + await prisma.refreshToken.update({ + where: { id: existingToken.id }, + data: { revokedAt: new Date() }, + }); + clearAuthCookies(res); + return res.status(401).json({ error: "Refresh token has expired." }); + } + + const nextRefreshToken = signRefreshToken(existingToken.userId); + const nextRefreshHash = hashToken(nextRefreshToken); + const nextAccessToken = signAccessToken( + existingToken.user.id, + existingToken.user.login, + ); + + await prisma.$transaction([ + prisma.refreshToken.update({ + where: { id: existingToken.id }, + data: { revokedAt: new Date(), replacedByTokenHash: nextRefreshHash }, + }), + prisma.refreshToken.create({ + data: { + userId: existingToken.userId, + tokenHash: nextRefreshHash, + expiresAt: new Date( + Date.now() + REFRESH_EXPIRES_DAYS * 24 * 60 * 60 * 1000, + ), + userAgent: req.headers["user-agent"] || null, + ip: req.ip || null, + }, + }), + ]); + + setAuthCookies(res, nextAccessToken, nextRefreshToken); + + return res.status(200).json({ + message: "Session refreshed", + user: toSafeUserResponse(existingToken.user), + }); + } catch (error) { + console.error("Refresh session error:", error); + clearAuthCookies(res); + return res.status(500).json({ error: "Server error during refresh." }); + } +}; + +export const logoutUser = async (req: Request, res: Response) => { + try { + const cookies = parseCookieHeader(req.headers.cookie); + const refreshToken = cookies["refresh_token"]; + + if (refreshToken) { + const refreshHash = hashToken(refreshToken); + await prisma.refreshToken.updateMany({ + where: { tokenHash: refreshHash, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + } + + clearAuthCookies(res); + return res.status(200).json({ message: "Logout successful." }); + } catch (error) { + console.error("Logout error:", error); + clearAuthCookies(res); + return res.status(500).json({ error: "Server error during logout." }); + } +}; + +export const logoutAllUserSessions = async (req: Request, res: Response) => { + try { + const userId = req.userId; + if (!userId) { + clearAuthCookies(res); + return res.status(401).json({ error: "Unauthorized." }); + } + + await prisma.refreshToken.updateMany({ + where: { userId, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + + clearAuthCookies(res); + return res.status(200).json({ message: "All sessions logged out." }); + } catch (error) { + console.error("Logout all sessions error:", error); + clearAuthCookies(res); + return res.status(500).json({ error: "Server error during logout-all." }); + } +}; + +export const getCurrentUser = async (req: Request, res: Response) => { + try { + const userId = req.userId; + if (!userId) return res.status(401).json({ error: "Unauthorized." }); + + const user = await prisma.user.findFirst({ + where: { id: userId }, + include: userInclude, + }); + + if (!user) return res.status(404).json({ error: "User not found." }); + + return res.status(200).json({ + message: "Current user loaded.", + user: toSafeUserResponse(user), + }); + } catch (error) { + console.error("Get current user error:", error); + return res + .status(500) + .json({ error: "Server error during current user fetch." }); + } +}; diff --git a/apps/backend/src/controllers/authController.ts b/apps/backend/src/controllers/authController.ts deleted file mode 100644 index 7ef3e3a..0000000 --- a/apps/backend/src/controllers/authController.ts +++ /dev/null @@ -1,1024 +0,0 @@ -import type { Request, Response } from "express"; -import type { Prisma } from "@prisma/client"; -import bcrypt from "bcrypt"; -import crypto from "node:crypto"; -import jwt, { type SignOptions } from "jsonwebtoken"; -import { z } from "zod"; -import prisma from "../prisma.js"; -import { - RegisterSchema, - LoginSchema, - ResendVerificationSchema, - SetPasswordSchema, - DeleteAccountSchema, - UpdateProfileSchema, - UserSchema, -} from "../models/AuthSchema.js"; -import { handleZodError } from "../utils/helpers.js"; -import { sendVerificationEmail } from "../services/emailService.js"; - -const EMAIL_VERIFY_TTL_MS = 24 * 60 * 60 * 1000; - -const JWT_SECRET = process.env.JWT_SECRET; -const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || JWT_SECRET; -const ACCESS_TOKEN_EXPIRES = (process.env.JWT_ACCESS_EXPIRES || - "15m") as SignOptions["expiresIn"]; -const REFRESH_EXPIRES_DAYS = Number(process.env.JWT_REFRESH_EXPIRES_DAYS || 30); -const REFRESH_TOKEN_EXPIRES = - `${REFRESH_EXPIRES_DAYS}d` as SignOptions["expiresIn"]; -const ACCESS_COOKIE_NAME = "access_token"; -const REFRESH_COOKIE_NAME = "refresh_token"; -const OAUTH_STATE_COOKIE_NAME = "oauth_state"; -const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID; -const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET; -const GOOGLE_REDIRECT_URI = process.env.GOOGLE_REDIRECT_URI; -const FRONTEND_URL = process.env.FRONTEND_URL || "http://localhost:5173"; -const saltRounds = 10; - -type WalletListItem = Prisma.WalletGetPayload<{ - select: { - id: true; - name: true; - }; -}>; - -type UserWithWallets = Prisma.UserGetPayload<{ - include: { - wallets: { - select: { - id: true; - name: true; - }; - }; - }; -}>; - -const isProduction = process.env.NODE_ENV === "production"; -const cookieSameSite = ( - process.env.COOKIE_SAME_SITE || (isProduction ? "none" : "lax") -).toLowerCase(); -const normalizedSameSite: "lax" | "strict" | "none" = - cookieSameSite === "strict" - ? "strict" - : cookieSameSite === "none" - ? "none" - : "lax"; -const cookieSecure = normalizedSameSite === "none" ? true : isProduction; - -const parseCookieHeader = (cookieHeader?: string): Record => { - if (!cookieHeader) return {}; - - return cookieHeader - .split(";") - .map((item: string) => item.trim()) - .filter(Boolean) - .reduce((acc: Record, pair: string) => { - const eqIndex = pair.indexOf("="); - if (eqIndex === -1) return acc; - - const key = pair.slice(0, eqIndex).trim(); - const value = pair.slice(eqIndex + 1).trim(); - - if (key) { - acc[key] = decodeURIComponent(value); - } - return acc; - }, {}); -}; - -const hashToken = (token: string): string => { - return crypto.createHash("sha256").update(token).digest("hex"); -}; - -const signAccessToken = (userId: string, userLogin: string): string => { - if (!JWT_SECRET) { - throw new Error("JWT_SECRET is not defined in environment variables."); - } - - return jwt.sign({ userId, userLogin }, JWT_SECRET, { - expiresIn: ACCESS_TOKEN_EXPIRES!, - }); -}; - -const signRefreshToken = (userId: string): string => { - if (!JWT_REFRESH_SECRET) { - throw new Error( - "JWT_REFRESH_SECRET/JWT_SECRET is not defined in environment variables.", - ); - } - - return jwt.sign( - { - userId, - type: "refresh", - jti: crypto.randomUUID(), - }, - JWT_REFRESH_SECRET, - { expiresIn: REFRESH_TOKEN_EXPIRES! }, - ); -}; - -const setAuthCookies = ( - res: Response, - accessToken: string, - refreshToken: string, -): void => { - res.cookie(ACCESS_COOKIE_NAME, accessToken, { - httpOnly: true, - secure: cookieSecure, - sameSite: normalizedSameSite, - maxAge: 15 * 60 * 1000, - path: "/", - }); - - res.cookie(REFRESH_COOKIE_NAME, refreshToken, { - httpOnly: true, - secure: cookieSecure, - sameSite: normalizedSameSite, - maxAge: REFRESH_EXPIRES_DAYS * 24 * 60 * 60 * 1000, - path: "/api/auth", - }); -}; - -const clearAuthCookies = (res: Response): void => { - res.clearCookie(ACCESS_COOKIE_NAME, { - httpOnly: true, - secure: cookieSecure, - sameSite: normalizedSameSite, - path: "/", - }); - - res.clearCookie(REFRESH_COOKIE_NAME, { - httpOnly: true, - secure: cookieSecure, - sameSite: normalizedSameSite, - path: "/api/auth", - }); -}; - -const setOAuthStateCookie = (res: Response, state: string): void => { - res.cookie(OAUTH_STATE_COOKIE_NAME, state, { - httpOnly: true, - secure: cookieSecure, - sameSite: normalizedSameSite, - maxAge: 10 * 60 * 1000, - path: "/api/auth", - }); -}; - -const clearOAuthStateCookie = (res: Response): void => { - res.clearCookie(OAUTH_STATE_COOKIE_NAME, { - httpOnly: true, - secure: cookieSecure, - sameSite: normalizedSameSite, - path: "/api/auth", - }); -}; - -const toSafeUserResponse = (user: UserWithWallets) => { - return UserSchema.parse({ - uid: user.id, - login: user.login, - email: user.email, - emailVerified: user.emailVerified, - hasPassword: user.password !== null, - photoUrl: user.photoUrl, - wallets: user.wallets.map((wallet: WalletListItem) => ({ - id: wallet.id, - name: wallet.name, - })), - }); -}; - -type EmailTokenPurpose = "verify_email"; - -const createEmailToken = async ( - userId: string, - purpose: EmailTokenPurpose, - ttlMs: number, -): Promise<{ rawToken: string; expiresAt: Date }> => { - await prisma.emailToken.deleteMany({ - where: { userId, purpose, consumedAt: null }, - }); - - const rawToken = crypto.randomBytes(32).toString("base64url"); - const tokenHash = hashToken(rawToken); - const expiresAt = new Date(Date.now() + ttlMs); - - await prisma.emailToken.create({ - data: { userId, tokenHash, purpose, expiresAt }, - }); - - return { rawToken, expiresAt }; -}; - -const consumeEmailToken = async ( - rawToken: string, - expectedPurpose: EmailTokenPurpose, -) => { - const tokenHash = hashToken(rawToken); - const token = await prisma.emailToken.findUnique({ - where: { tokenHash }, - }); - - if (!token) return { ok: false as const, reason: "not_found" as const }; - if (token.purpose !== expectedPurpose) - return { ok: false as const, reason: "wrong_purpose" as const }; - if (token.consumedAt) - return { ok: false as const, reason: "already_used" as const }; - if (token.expiresAt.getTime() <= Date.now()) - return { ok: false as const, reason: "expired" as const }; - - await prisma.emailToken.update({ - where: { id: token.id }, - data: { consumedAt: new Date() }, - }); - - return { ok: true as const, userId: token.userId }; -}; - -const saveRefreshToken = async ( - userId: string, - rawRefreshToken: string, - req: Request, - replacedByTokenHash?: string, -): Promise => { - const tokenHash = hashToken(rawRefreshToken); - const expiresAt = new Date( - Date.now() + REFRESH_EXPIRES_DAYS * 24 * 60 * 60 * 1000, - ); - - await prisma.refreshToken.create({ - data: { - userId, - tokenHash, - expiresAt, - replacedByTokenHash: replacedByTokenHash || null, - userAgent: req.headers["user-agent"] || null, - ip: req.ip || null, - }, - }); -}; - -const createSession = async ( - user: UserWithWallets, - req: Request, - res: Response, -): Promise => { - const accessToken = signAccessToken(user.id, user.login); - const refreshToken = signRefreshToken(user.id); - - await saveRefreshToken(user.id, refreshToken, req); - setAuthCookies(res, accessToken, refreshToken); -}; - -const normalizeLogin = (raw: string): string => { - return ( - raw - .toLowerCase() - .trim() - .replace(/[^a-z0-9_]/g, "_") - .replace(/_+/g, "_") - .replace(/^_+|_+$/g, "") || "user" - ); -}; - -const generateUniqueLogin = async (seed: string): Promise => { - const base = normalizeLogin(seed).slice(0, 24); - let candidate = base; - let suffix = 1; - - while (true) { - const existing = await prisma.user.findFirst({ - where: { login: candidate }, - select: { id: true }, - }); - - if (!existing) return candidate; - - suffix += 1; - candidate = `${base}_${suffix}`.slice(0, 30); - } -}; - -const getGoogleProfileFromCode = async (code: string) => { - if (!GOOGLE_CLIENT_ID || !GOOGLE_CLIENT_SECRET || !GOOGLE_REDIRECT_URI) { - throw new Error("Google OAuth env vars are missing."); - } - - const tokenResponse = await fetch("https://oauth2.googleapis.com/token", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - code, - client_id: GOOGLE_CLIENT_ID, - client_secret: GOOGLE_CLIENT_SECRET, - redirect_uri: GOOGLE_REDIRECT_URI, - grant_type: "authorization_code", - }), - }); - - if (!tokenResponse.ok) { - throw new Error("Failed to exchange Google auth code."); - } - - const tokenData = (await tokenResponse.json()) as { id_token?: string }; - - if (!tokenData.id_token) { - throw new Error("Google did not return id_token."); - } - - const infoResponse = await fetch( - `https://oauth2.googleapis.com/tokeninfo?id_token=${encodeURIComponent(tokenData.id_token)}`, - ); - if (!infoResponse.ok) { - throw new Error("Failed to verify Google id_token."); - } - - const info = (await infoResponse.json()) as { - aud?: string; - sub?: string; - email?: string; - email_verified?: string; - name?: string; - picture?: string; - }; - - if (info.aud !== GOOGLE_CLIENT_ID) { - throw new Error("Google token audience mismatch."); - } - - if (!info.sub || !info.email) { - throw new Error("Google profile is incomplete."); - } - - return { - sub: info.sub, - email: info.email, - emailVerified: info.email_verified === "true", - name: info.name || info.email, - picture: info.picture || null, - }; -}; - -export const registerUser = async (req: Request, res: Response) => { - try { - const validatedData = RegisterSchema.parse(req.body); - const { login, password, email } = validatedData; - - const hashedPassword = await bcrypt.hash(password, saltRounds); - - const newUser = await prisma.$transaction(async (tx) => { - await tx.user.deleteMany({ - where: { - emailVerified: false, - OR: [{ login }, { email }], - }, - }); - - const user = await tx.user.create({ - data: { - login, - password: hashedPassword, - email, - emailVerified: false, - }, - }); - - await tx.authIdentity.create({ - data: { userId: user.id, provider: "local" }, - }); - - return user; - }); - - const { rawToken } = await createEmailToken( - newUser.id, - "verify_email", - EMAIL_VERIFY_TTL_MS, - ); - - try { - await sendVerificationEmail(email, rawToken); - } catch (mailError) { - console.error("Failed to send verification email:", mailError); - } - - return res.status(201).json({ - message: - "Account created. Check your inbox to confirm your email before signing in.", - requiresVerification: true, - email, - }); - } catch (error: any) { - if (error.code === "P2002") { - return res.status(409).json({ - error: - "Account with this login or email already exists. Try signing in or pick different credentials.", - }); - } - - if (error instanceof z.ZodError) { - return handleZodError(res, error); - } - - console.error("Registration error:", error); - return res.status(500).json({ error: "Server error during registration." }); - } -}; - -export const loginUser = async (req: Request, res: Response) => { - try { - const validatedData = LoginSchema.parse(req.body); - const { login, password } = validatedData; - - const user = await prisma.user.findFirst({ - where: { login }, - include: { - wallets: { - select: { id: true, name: true }, - orderBy: { createdAt: "asc" }, - }, - }, - }); - - if (!user || !user.password) { - return res.status(401).json({ error: "Invalid credentials." }); - } - - const passwordMatch = await bcrypt.compare(password, user.password); - if (!passwordMatch) { - return res.status(401).json({ error: "Invalid credentials." }); - } - - if (!user.emailVerified) { - return res.status(403).json({ - error: - "Email not confirmed. Check your inbox or request a new verification link.", - requiresVerification: true, - email: user.email, - }); - } - - await createSession(user, req, res); - - return res.status(200).json({ - message: "Login successful", - user: toSafeUserResponse(user), - }); - } catch (error: any) { - if (error instanceof z.ZodError) { - return handleZodError(res, error); - } - - console.error("Login error:", error); - return res.status(500).json({ error: "Server error during login." }); - } -}; - -export const verifyEmail = async (req: Request, res: Response) => { - const token = typeof req.query.token === "string" ? req.query.token : ""; - if (!token) { - return res.redirect(`${FRONTEND_URL}?auth=verify_error`); - } - - const result = await consumeEmailToken(token, "verify_email"); - if (!result.ok) { - const param = - result.reason === "already_used" ? "already_verified" : "verify_error"; - return res.redirect(`${FRONTEND_URL}?auth=${param}`); - } - - await prisma.user.update({ - where: { id: result.userId }, - data: { emailVerified: true }, - }); - - return res.redirect(`${FRONTEND_URL}?auth=verified`); -}; - -export const resendVerification = async (req: Request, res: Response) => { - try { - const { login } = ResendVerificationSchema.parse(req.body); - - const user = await prisma.user.findFirst({ where: { login } }); - - const genericResponse = { - message: - "If an account exists for that login and is unverified, a new verification email is on the way.", - }; - - if (!user || !user.email || user.emailVerified) { - return res.status(200).json(genericResponse); - } - - const { rawToken } = await createEmailToken( - user.id, - "verify_email", - EMAIL_VERIFY_TTL_MS, - ); - - try { - await sendVerificationEmail(user.email, rawToken); - } catch (mailError) { - console.error("Failed to resend verification email:", mailError); - } - - return res.status(200).json(genericResponse); - } catch (error) { - if (error instanceof z.ZodError) { - return handleZodError(res, error); - } - console.error("Resend verification error:", error); - return res - .status(500) - .json({ error: "Server error during verification resend." }); - } -}; - -export const startGoogleAuth = async (_req: Request, res: Response) => { - try { - if (!GOOGLE_CLIENT_ID || !GOOGLE_REDIRECT_URI) { - return res.status(500).json({ error: "Google OAuth is not configured." }); - } - - const state = crypto.randomBytes(32).toString("hex"); - setOAuthStateCookie(res, state); - - const params = new URLSearchParams({ - client_id: GOOGLE_CLIENT_ID, - redirect_uri: GOOGLE_REDIRECT_URI, - response_type: "code", - scope: "openid email profile", - access_type: "offline", - prompt: "consent", - state, - }); - - return res.redirect( - `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`, - ); - } catch (error) { - console.error("Google OAuth start error:", error); - return res.status(500).json({ error: "Unable to start Google OAuth." }); - } -}; - -export const googleAuthCallback = async (req: Request, res: Response) => { - try { - const code = req.query.code as string | undefined; - const incomingState = req.query.state as string | undefined; - const cookies = parseCookieHeader(req.headers.cookie); - const storedState = cookies[OAUTH_STATE_COOKIE_NAME]; - clearOAuthStateCookie(res); - - if ( - !code || - !incomingState || - !storedState || - incomingState !== storedState - ) { - return res.redirect(`${FRONTEND_URL}?auth=google_error`); - } - - const profile = await getGoogleProfileFromCode(code); - - const userInclude = { - wallets: { - select: { id: true, name: true }, - orderBy: { createdAt: "asc" as const }, - }, - }; - - const existingIdentity = await prisma.authIdentity.findUnique({ - where: { - provider_providerId: { provider: "google", providerId: profile.sub }, - }, - include: { user: { include: userInclude } }, - }); - - let user: UserWithWallets | null = existingIdentity?.user ?? null; - - if (!user) { - // Require Google-verified email to link or create. - if (!profile.emailVerified) { - return res.redirect(`${FRONTEND_URL}?auth=google_error`); - } - - const existingByEmail = await prisma.user.findFirst({ - where: { email: profile.email }, - include: userInclude, - }); - - if (existingByEmail && existingByEmail.emailVerified) { - // Verified email owner exists; attach Google identity. - await prisma.authIdentity.upsert({ - where: { - userId_provider: { - userId: existingByEmail.id, - provider: "google", - }, - }, - create: { - userId: existingByEmail.id, - provider: "google", - providerId: profile.sub, - }, - update: { providerId: profile.sub }, - }); - user = existingByEmail; - } else { - // No verified owner: clear unverified squats, create a fresh Google account. - await prisma.user.deleteMany({ - where: { emailVerified: false, email: profile.email }, - }); - - const seed = profile.email.split("@")[0] || profile.name; - const login = await generateUniqueLogin(seed); - - user = await prisma.$transaction(async (tx) => { - const created = await tx.user.create({ - data: { - login, - email: profile.email, - password: null, - emailVerified: true, - photoUrl: profile.picture, - }, - include: userInclude, - }); - - await tx.authIdentity.create({ - data: { - userId: created.id, - provider: "google", - providerId: profile.sub, - }, - }); - - return created; - }); - } - } - - await createSession(user, req, res); - - return res.redirect(`${FRONTEND_URL}?auth=google_success`); - } catch (error) { - console.error("Google OAuth callback error:", error); - return res.redirect(`${FRONTEND_URL}?auth=google_error`); - } -}; - -export const refreshSession = async (req: Request, res: Response) => { - try { - const cookies = parseCookieHeader(req.headers.cookie); - const refreshToken = cookies[REFRESH_COOKIE_NAME]; - - if (!refreshToken) { - clearAuthCookies(res); - return res.status(401).json({ error: "Refresh token is missing." }); - } - - if (!JWT_REFRESH_SECRET) { - return res.status(500).json({ error: "Server configuration error." }); - } - - try { - jwt.verify(refreshToken, JWT_REFRESH_SECRET); - } catch (error) { - clearAuthCookies(res); - return res.status(401).json({ error: "Invalid refresh token." }); - } - - const refreshHash = hashToken(refreshToken); - const existingToken = await prisma.refreshToken.findFirst({ - where: { - tokenHash: refreshHash, - revokedAt: null, - }, - include: { - user: { - include: { - wallets: { - select: { id: true, name: true }, - orderBy: { createdAt: "asc" }, - }, - }, - }, - }, - }); - - if (!existingToken) { - clearAuthCookies(res); - return res - .status(401) - .json({ error: "Refresh token is revoked or invalid." }); - } - - if (existingToken.expiresAt.getTime() <= Date.now()) { - await prisma.refreshToken.update({ - where: { id: existingToken.id }, - data: { revokedAt: new Date() }, - }); - - clearAuthCookies(res); - return res.status(401).json({ error: "Refresh token has expired." }); - } - - const nextRefreshToken = signRefreshToken(existingToken.userId); - const nextRefreshHash = hashToken(nextRefreshToken); - const nextAccessToken = signAccessToken( - existingToken.user.id, - existingToken.user.login, - ); - - await prisma.$transaction([ - prisma.refreshToken.update({ - where: { id: existingToken.id }, - data: { - revokedAt: new Date(), - replacedByTokenHash: nextRefreshHash, - }, - }), - prisma.refreshToken.create({ - data: { - userId: existingToken.userId, - tokenHash: nextRefreshHash, - expiresAt: new Date( - Date.now() + REFRESH_EXPIRES_DAYS * 24 * 60 * 60 * 1000, - ), - userAgent: req.headers["user-agent"] || null, - ip: req.ip || null, - }, - }), - ]); - - setAuthCookies(res, nextAccessToken, nextRefreshToken); - - return res.status(200).json({ - message: "Session refreshed", - user: toSafeUserResponse(existingToken.user), - }); - } catch (error) { - console.error("Refresh session error:", error); - clearAuthCookies(res); - return res.status(500).json({ error: "Server error during refresh." }); - } -}; - -export const logoutUser = async (req: Request, res: Response) => { - try { - const cookies = parseCookieHeader(req.headers.cookie); - const refreshToken = cookies[REFRESH_COOKIE_NAME]; - - if (refreshToken) { - const refreshHash = hashToken(refreshToken); - await prisma.refreshToken.updateMany({ - where: { - tokenHash: refreshHash, - revokedAt: null, - }, - data: { revokedAt: new Date() }, - }); - } - - clearAuthCookies(res); - return res.status(200).json({ message: "Logout successful." }); - } catch (error) { - console.error("Logout error:", error); - clearAuthCookies(res); - return res.status(500).json({ error: "Server error during logout." }); - } -}; - -export const logoutAllUserSessions = async (req: Request, res: Response) => { - try { - const userId = req.userId; - if (!userId) { - clearAuthCookies(res); - return res.status(401).json({ error: "Unauthorized." }); - } - - await prisma.refreshToken.updateMany({ - where: { - userId, - revokedAt: null, - }, - data: { revokedAt: new Date() }, - }); - - clearAuthCookies(res); - return res.status(200).json({ message: "All sessions logged out." }); - } catch (error) { - console.error("Logout all sessions error:", error); - clearAuthCookies(res); - return res.status(500).json({ error: "Server error during logout-all." }); - } -}; - -export const getCurrentUser = async (req: Request, res: Response) => { - try { - const userId = req.userId; - if (!userId) { - return res.status(401).json({ error: "Unauthorized." }); - } - - const user = await prisma.user.findFirst({ - where: { id: userId }, - include: { - wallets: { - select: { id: true, name: true }, - orderBy: { createdAt: "asc" }, - }, - }, - }); - - if (!user) { - return res.status(404).json({ error: "User not found." }); - } - - return res.status(200).json({ - message: "Current user loaded.", - user: toSafeUserResponse(user), - }); - } catch (error) { - console.error("Get current user error:", error); - return res - .status(500) - .json({ error: "Server error during current user fetch." }); - } -}; - -export const setPassword = async (req: Request, res: Response) => { - try { - const userId = req.userId; - if (!userId) { - return res.status(401).json({ error: "Unauthorized." }); - } - - const { password, oldPassword } = SetPasswordSchema.parse(req.body); - - const user = await prisma.user.findUnique({ where: { id: userId } }); - if (!user) { - return res.status(404).json({ error: "User not found." }); - } - - if (user.password) { - if (!oldPassword) { - return res - .status(400) - .json({ error: "Current password is required to change it." }); - } - const match = await bcrypt.compare(oldPassword, user.password); - if (!match) { - return res.status(401).json({ error: "Current password is wrong." }); - } - } - - const hashed = await bcrypt.hash(password, saltRounds); - - await prisma.$transaction(async (tx) => { - await tx.user.update({ - where: { id: userId }, - data: { password: hashed }, - }); - await tx.authIdentity.upsert({ - where: { - userId_provider: { userId, provider: "local" }, - }, - create: { userId, provider: "local" }, - update: {}, - }); - }); - - return res.status(200).json({ - message: user.password - ? "Password updated." - : "Password set. You can now sign in with login and password.", - }); - } catch (error) { - if (error instanceof z.ZodError) { - return handleZodError(res, error); - } - console.error("Set password error:", error); - return res - .status(500) - .json({ error: "Server error during password change." }); - } -}; - -export const deleteAccount = async (req: Request, res: Response) => { - try { - const userId = req.userId; - if (!userId) { - return res.status(401).json({ error: "Unauthorized." }); - } - - const { password } = DeleteAccountSchema.parse(req.body ?? {}); - - const user = await prisma.user.findUnique({ where: { id: userId } }); - if (!user) { - return res.status(404).json({ error: "User not found." }); - } - - // Password users confirm via password; Google-only accounts rely on session. - if (user.password) { - if (!password) { - return res - .status(400) - .json({ error: "Password is required to delete the account." }); - } - const match = await bcrypt.compare(password, user.password); - if (!match) { - return res.status(401).json({ error: "Password is wrong." }); - } - } - - await prisma.user.delete({ where: { id: userId } }); - clearAuthCookies(res); - return res.status(200).json({ message: "Account deleted." }); - } catch (error) { - if (error instanceof z.ZodError) { - return handleZodError(res, error); - } - console.error("Delete account error:", error); - return res - .status(500) - .json({ error: "Server error during account deletion." }); - } -}; - -export const updateProfile = async (req: Request, res: Response) => { - try { - const userId = req.userId; - if (!userId) { - return res.status(401).json({ error: "Unauthorized." }); - } - - const parsed = UpdateProfileSchema.parse(req.body ?? {}); - if (parsed.login === undefined && parsed.photoUrl === undefined) { - return res.status(400).json({ error: "Nothing to update." }); - } - - const data: Prisma.UserUpdateInput = {}; - if (parsed.login !== undefined) { - data.login = parsed.login; - } - if (parsed.photoUrl !== undefined) { - const normalizedPhotoUrl = - parsed.photoUrl === null ? null : parsed.photoUrl.trim(); - data.photoUrl = normalizedPhotoUrl === "" ? null : normalizedPhotoUrl; - } - - let updated; - try { - updated = await prisma.user.update({ - where: { id: userId }, - data, - include: { - wallets: { - select: { id: true, name: true }, - orderBy: { createdAt: "asc" }, - }, - }, - }); - } catch (e: unknown) { - if ( - e && - typeof e === "object" && - "code" in e && - (e as { code: string }).code === "P2002" - ) { - return res.status(409).json({ error: "Login is already taken." }); - } - throw e; - } - - if (parsed.login !== undefined) { - const newAccess = signAccessToken(updated.id, updated.login); - res.cookie(ACCESS_COOKIE_NAME, newAccess, { - httpOnly: true, - secure: cookieSecure, - sameSite: normalizedSameSite, - maxAge: 15 * 60 * 1000, - path: "/", - }); - } - - return res.status(200).json({ - message: "Profile updated.", - user: toSafeUserResponse(updated), - }); - } catch (error) { - if (error instanceof z.ZodError) { - return handleZodError(res, error); - } - console.error("Update profile error:", error); - return res - .status(500) - .json({ error: "Server error during profile update." }); - } -}; diff --git a/apps/backend/src/router/authRouter.ts b/apps/backend/src/router/authRouter.ts index db91dc0..fc4e11b 100644 --- a/apps/backend/src/router/authRouter.ts +++ b/apps/backend/src/router/authRouter.ts @@ -3,19 +3,25 @@ const authRouter = express.Router(); import { registerUser, - loginUser, verifyEmail, resendVerification, +} from "../controllers/auth/registerHandlers.js"; +import { loginUser } from "../controllers/auth/loginHandler.js"; +import { startGoogleAuth, googleAuthCallback, +} from "../controllers/auth/googleHandlers.js"; +import { refreshSession, logoutUser, logoutAllUserSessions, getCurrentUser, +} from "../controllers/auth/sessionHandlers.js"; +import { setPassword, deleteAccount, updateProfile, -} from "../controllers/authController.js"; +} from "../controllers/auth/profileHandlers.js"; import { protect } from "../middleware/authMiddleware.js"; authRouter.post("/register", registerUser); diff --git a/apps/frontend/src/modules/Auth/AccountSettingsPopup.tsx b/apps/frontend/src/modules/Auth/AccountSettingsPopup.tsx deleted file mode 100644 index 1ac5daa..0000000 --- a/apps/frontend/src/modules/Auth/AccountSettingsPopup.tsx +++ /dev/null @@ -1,446 +0,0 @@ -import { useState, useRef, type FormEvent, type ChangeEvent } from "react"; -import { - useSetPasswordMutation, - useDeleteAccountMutation, - useUpdateProfileMutation, -} from "./auth.api"; -import type { UserSafe } from "./auth.schema"; -import { PasswordField } from "./PasswordField"; -import { useAppDispatch, useAppSelector } from "../../store"; -import { closePopup } from "../../portals/popup.slice"; -import { - extractServerError, - inputClass, - secondaryButtonClass, -} from "./auth.utils"; - -type Section = "profile" | "password" | "delete"; - -const MAX_PHOTO_BYTES = 600 * 1024; - -const primaryButtonClass = - "w-full py-3 rounded-xl font-bold text-white shadow-lg transform active:scale-95 transition-all cursor-pointer hover:shadow-purple-500/30 hover:-translate-y-0.5 disabled:opacity-60 disabled:cursor-not-allowed disabled:hover:translate-y-0"; - -const dangerButtonClass = - "w-full py-3 rounded-xl font-bold text-white shadow-lg transform active:scale-95 transition-all cursor-pointer bg-red-700 hover:bg-red-800 disabled:opacity-60 disabled:cursor-not-allowed"; - -const tabButtonClass = (active: boolean) => - `flex-1 px-3 py-2 rounded-lg text-sm font-semibold transition-colors cursor-pointer ${ - active - ? "bg-purple-600/30 text-white border border-purple-400/40" - : "text-(--color-text) opacity-70 hover:opacity-100 hover:bg-white/5 border border-transparent" - }`; - -export function AccountSettingsPopup() { - const dispatch = useAppDispatch(); - const currentUser = useAppSelector((state) => state.auth.user); - - const [section, setSection] = useState
("profile"); - - if (!currentUser) { - return ( -

- You need to be signed in to manage your account. -

- ); - } - - const hasPassword = currentUser.hasPassword ?? false; - - return ( -
-
- Signed in as {currentUser.login} - {currentUser.email && ( - {currentUser.email} - )} -
- -
- - - -
- - {section === "profile" && } - {section === "password" && ( - dispatch(closePopup())} - /> - )} - {section === "delete" && ( - dispatch(closePopup())} - /> - )} -
- ); -} - -function ProfileSection({ user }: { user: UserSafe }) { - const fileInputRef = useRef(null); - const [login, setLogin] = useState(user.login); - const [photoUrl, setPhotoUrl] = useState( - user.photoUrl ?? null, - ); - const [fileError, setFileError] = useState(null); - const [successMessage, setSuccessMessage] = useState(null); - - const [updateProfile, { isLoading, error, isError }] = - useUpdateProfileMutation(); - const serverError = isError ? extractServerError(error) : null; - - const dirty = login !== user.login || photoUrl !== (user.photoUrl ?? null); - - const handleFile = (e: ChangeEvent) => { - setFileError(null); - const file = e.target.files?.[0]; - if (!file) return; - if (!file.type.startsWith("image/")) { - setFileError("Please pick an image file."); - return; - } - if (file.size > MAX_PHOTO_BYTES) { - setFileError("Image is too large. Max ~600 KB."); - return; - } - const reader = new FileReader(); - reader.onload = () => { - if (typeof reader.result === "string") setPhotoUrl(reader.result); - }; - reader.onerror = () => setFileError("Could not read the file."); - reader.readAsDataURL(file); - }; - - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); - setSuccessMessage(null); - if (!dirty) return; - - const payload: { login?: string; photoUrl?: string | null } = {}; - if (login !== user.login) payload.login = login.trim(); - if (photoUrl !== (user.photoUrl ?? null)) { - payload.photoUrl = photoUrl === null ? null : photoUrl.trim(); - } - - try { - const response = await updateProfile(payload).unwrap(); - setSuccessMessage(response.message); - } catch (error) { - console.error("Update profile failed:", error); - } - }; - - return ( -
-
-
- {photoUrl ? ( - Avatar setFileError("Could not load that image.")} - /> - ) : ( - - {user.login.slice(0, 1).toUpperCase()} - - )} -
-
- - -
- -
- -
- - setPhotoUrl(e.target.value || null)} - disabled={isLoading} - className={inputClass} - placeholder="https://example.com/avatar.png" - /> - {photoUrl?.startsWith("data:") && ( -

- Currently using an uploaded image. Type a URL above to switch. -

- )} -
- -
- - setLogin(e.target.value)} - disabled={isLoading} - className={inputClass} - placeholder="Enter a new login (3-30 characters)" - maxLength={30} - /> -

- If this login is already taken, you will see a notification after - saving. -

-
- - {(fileError || serverError) && ( -
- {fileError || serverError} -
- )} - {successMessage && ( -
- {successMessage} -
- )} - - -
- ); -} - -function PasswordSection({ - hasPassword, - onDone, -}: { - hasPassword: boolean; - onDone: () => void; -}) { - const [oldPassword, setOldPassword] = useState(""); - const [password, setPassword] = useState(""); - const [confirm, setConfirm] = useState(""); - const [formError, setFormError] = useState(null); - const [successMessage, setSuccessMessage] = useState(null); - const [setPasswordMutation, { isLoading, error, isError }] = - useSetPasswordMutation(); - - const serverError = isError ? extractServerError(error) : null; - - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); - setFormError(null); - setSuccessMessage(null); - - if (password.length < 6) { - setFormError("Password must be at least 6 characters."); - return; - } - if (password !== confirm) { - setFormError("Passwords do not match."); - return; - } - if (hasPassword && !oldPassword) { - setFormError("Current password is required."); - return; - } - - try { - const response = await setPasswordMutation( - hasPassword ? { password, oldPassword } : { password }, - ).unwrap(); - setSuccessMessage(response.message); - setOldPassword(""); - setPassword(""); - setConfirm(""); - setTimeout(onDone, 1500); - } catch (error) { - console.error("Set password failed:", error); - } - }; - - return ( -
-

- {hasPassword - ? "Change your password. You will stay signed in on this device; revoke other sessions from the Sign-in popup if needed." - : "Set a password so you can also sign in without Google."} -

- - {successMessage && ( -
- {successMessage} -
- )} - {(formError || serverError) && ( -
- {formError || serverError} -
- )} - - {hasPassword && ( - setOldPassword(e.target.value)} - disabled={isLoading} - placeholder="Current password" - autoComplete="current-password" - /> - )} - - setPassword(e.target.value)} - disabled={isLoading} - placeholder="At least 6 characters" - autoComplete="new-password" - /> - - setConfirm(e.target.value)} - disabled={isLoading} - placeholder="Repeat new password" - autoComplete="new-password" - /> - - - - ); -} - -function DeleteSection({ - hasPassword, - onDeleted, -}: { - hasPassword: boolean; - onDeleted: () => void; -}) { - const [password, setPassword] = useState(""); - const [formError, setFormError] = useState(null); - const [successNotice, setSuccessNotice] = useState(null); - - const [deleteAccount, { isLoading: isDeleting, error: deleteError }] = - useDeleteAccountMutation(); - const serverError = extractServerError(deleteError); - - const handleDelete = async () => { - setFormError(null); - if (hasPassword && !password) { - setFormError("Password is required to confirm deletion."); - return; - } - try { - await deleteAccount(hasPassword ? { password } : {}).unwrap(); - setSuccessNotice("Account deleted. Closing..."); - setTimeout(onDeleted, 1200); - } catch (error) { - console.error("Delete account failed:", error); - } - }; - - return ( -
-
- Deleting your account removes all wallets, transactions and sessions. - This cannot be undone. -
- - {(formError || serverError) && ( -
- {formError || serverError} -
- )} - {successNotice && ( -
- {successNotice} -
- )} - - {hasPassword ? ( - setPassword(e.target.value)} - disabled={isDeleting || successNotice !== null} - placeholder="Current password" - autoComplete="current-password" - /> - ) : ( -

- Your account is signed in via Google. Click delete to remove it - immediately. -

- )} - - -
- ); -} diff --git a/apps/frontend/src/modules/Auth/AccountSettingsPopup/AccountSettingsPopup.tsx b/apps/frontend/src/modules/Auth/AccountSettingsPopup/AccountSettingsPopup.tsx new file mode 100644 index 0000000..d59949e --- /dev/null +++ b/apps/frontend/src/modules/Auth/AccountSettingsPopup/AccountSettingsPopup.tsx @@ -0,0 +1,81 @@ +import { useState } from "react"; +import { useAppDispatch, useAppSelector } from "../../../store"; +import { closePopup } from "../../../portals/popup.slice"; +import { ProfileSection } from "./ProfileSection"; +import { PasswordSection } from "./PasswordSection"; +import { DeleteSection } from "./DeleteSection"; + +type Section = "profile" | "password" | "delete"; + +const tabButtonClass = (active: boolean) => + `flex-1 px-3 py-2 rounded-lg text-sm font-semibold transition-colors cursor-pointer ${ + active + ? "bg-purple-600/30 text-white border border-purple-400/40" + : "text-(--color-text) opacity-70 hover:opacity-100 hover:bg-white/5 border border-transparent" + }`; + +export function AccountSettingsPopup() { + const dispatch = useAppDispatch(); + const currentUser = useAppSelector((state) => state.auth.user); + + const [section, setSection] = useState
("profile"); + + if (!currentUser) { + return ( +

+ You need to be signed in to manage your account. +

+ ); + } + + const hasPassword = currentUser.hasPassword ?? false; + + return ( +
+
+ Signed in as {currentUser.login} + {currentUser.email && ( + {currentUser.email} + )} +
+ +
+ + + +
+ + {section === "profile" && } + {section === "password" && ( + dispatch(closePopup())} + /> + )} + {section === "delete" && ( + dispatch(closePopup())} + /> + )} +
+ ); +} diff --git a/apps/frontend/src/modules/Auth/AccountSettingsPopup/DeleteSection.tsx b/apps/frontend/src/modules/Auth/AccountSettingsPopup/DeleteSection.tsx new file mode 100644 index 0000000..dc1e197 --- /dev/null +++ b/apps/frontend/src/modules/Auth/AccountSettingsPopup/DeleteSection.tsx @@ -0,0 +1,83 @@ +import { useState } from "react"; +import { useDeleteAccountMutation } from "../auth.api"; +import { PasswordField } from "../PasswordField"; +import { extractServerError } from "../auth.utils"; + +const dangerButtonClass = + "w-full py-3 rounded-xl font-bold text-white shadow-lg transform active:scale-95 transition-all cursor-pointer bg-red-700 hover:bg-red-800 disabled:opacity-60 disabled:cursor-not-allowed"; + +export function DeleteSection({ + hasPassword, + onDeleted, +}: { + hasPassword: boolean; + onDeleted: () => void; +}) { + const [password, setPassword] = useState(""); + const [formError, setFormError] = useState(null); + const [successNotice, setSuccessNotice] = useState(null); + + const [deleteAccount, { isLoading: isDeleting, error: deleteError }] = + useDeleteAccountMutation(); + const serverError = extractServerError(deleteError); + + const handleDelete = async () => { + setFormError(null); + if (hasPassword && !password) { + setFormError("Password is required to confirm deletion."); + return; + } + try { + await deleteAccount(hasPassword ? { password } : {}).unwrap(); + setSuccessNotice("Account deleted. Closing..."); + setTimeout(onDeleted, 1200); + } catch (error) { + console.error("Delete account failed:", error); + } + }; + + return ( +
+
+ Deleting your account removes all wallets, transactions and sessions. + This cannot be undone. +
+ + {(formError || serverError) && ( +
+ {formError || serverError} +
+ )} + {successNotice && ( +
+ {successNotice} +
+ )} + + {hasPassword ? ( + setPassword(e.target.value)} + disabled={isDeleting || successNotice !== null} + placeholder="Current password" + autoComplete="current-password" + /> + ) : ( +

+ Your account is signed in via Google. Click delete to remove it + immediately. +

+ )} + + +
+ ); +} diff --git a/apps/frontend/src/modules/Auth/AccountSettingsPopup/PasswordSection.tsx b/apps/frontend/src/modules/Auth/AccountSettingsPopup/PasswordSection.tsx new file mode 100644 index 0000000..7a33b89 --- /dev/null +++ b/apps/frontend/src/modules/Auth/AccountSettingsPopup/PasswordSection.tsx @@ -0,0 +1,120 @@ +import { useState, type FormEvent } from "react"; +import { useSetPasswordMutation } from "../auth.api"; +import { PasswordField } from "../PasswordField"; +import { extractServerError } from "../auth.utils"; + +const primaryButtonClass = + "w-full py-3 rounded-xl font-bold text-white shadow-lg transform active:scale-95 transition-all cursor-pointer hover:shadow-purple-500/30 hover:-translate-y-0.5 disabled:opacity-60 disabled:cursor-not-allowed disabled:hover:translate-y-0"; + +export function PasswordSection({ + hasPassword, + onDone, +}: { + hasPassword: boolean; + onDone: () => void; +}) { + const [oldPassword, setOldPassword] = useState(""); + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [formError, setFormError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + const [setPasswordMutation, { isLoading, error, isError }] = + useSetPasswordMutation(); + + const serverError = isError ? extractServerError(error) : null; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setFormError(null); + setSuccessMessage(null); + + if (password.length < 6) { + setFormError("Password must be at least 6 characters."); + return; + } + if (password !== confirm) { + setFormError("Passwords do not match."); + return; + } + if (hasPassword && !oldPassword) { + setFormError("Current password is required."); + return; + } + + try { + const response = await setPasswordMutation( + hasPassword ? { password, oldPassword } : { password }, + ).unwrap(); + setSuccessMessage(response.message); + setOldPassword(""); + setPassword(""); + setConfirm(""); + setTimeout(onDone, 1500); + } catch (error) { + console.error("Set password failed:", error); + } + }; + + return ( +
+

+ {hasPassword + ? "Change your password. You will stay signed in on this device; revoke other sessions from the Sign-in popup if needed." + : "Set a password so you can also sign in without Google."} +

+ + {successMessage && ( +
+ {successMessage} +
+ )} + {(formError || serverError) && ( +
+ {formError || serverError} +
+ )} + + {hasPassword && ( + setOldPassword(e.target.value)} + disabled={isLoading} + placeholder="Current password" + autoComplete="current-password" + /> + )} + + setPassword(e.target.value)} + disabled={isLoading} + placeholder="At least 6 characters" + autoComplete="new-password" + /> + + setConfirm(e.target.value)} + disabled={isLoading} + placeholder="Repeat new password" + autoComplete="new-password" + /> + + + + ); +} diff --git a/apps/frontend/src/modules/Auth/AccountSettingsPopup/ProfileSection.tsx b/apps/frontend/src/modules/Auth/AccountSettingsPopup/ProfileSection.tsx new file mode 100644 index 0000000..5dd8456 --- /dev/null +++ b/apps/frontend/src/modules/Auth/AccountSettingsPopup/ProfileSection.tsx @@ -0,0 +1,172 @@ +import { useState, useRef, type FormEvent, type ChangeEvent } from "react"; +import { useUpdateProfileMutation } from "../auth.api"; +import type { UserSafe } from "../auth.schema"; +import { + extractServerError, + inputClass, + secondaryButtonClass, +} from "../auth.utils"; + +const MAX_PHOTO_BYTES = 600 * 1024; + +const primaryButtonClass = + "w-full py-3 rounded-xl font-bold text-white shadow-lg transform active:scale-95 transition-all cursor-pointer hover:shadow-purple-500/30 hover:-translate-y-0.5 disabled:opacity-60 disabled:cursor-not-allowed disabled:hover:translate-y-0"; + +export function ProfileSection({ user }: { user: UserSafe }) { + const fileInputRef = useRef(null); + const [login, setLogin] = useState(user.login); + const [photoUrl, setPhotoUrl] = useState( + user.photoUrl ?? null, + ); + const [fileError, setFileError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + + const [updateProfile, { isLoading, error, isError }] = + useUpdateProfileMutation(); + const serverError = isError ? extractServerError(error) : null; + + const dirty = login !== user.login || photoUrl !== (user.photoUrl ?? null); + + const handleFile = (e: ChangeEvent) => { + setFileError(null); + const file = e.target.files?.[0]; + if (!file) return; + if (!file.type.startsWith("image/")) { + setFileError("Please pick an image file."); + return; + } + if (file.size > MAX_PHOTO_BYTES) { + setFileError("Image is too large. Max ~600 KB."); + return; + } + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result === "string") setPhotoUrl(reader.result); + }; + reader.onerror = () => setFileError("Could not read the file."); + reader.readAsDataURL(file); + }; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setSuccessMessage(null); + if (!dirty) return; + + const payload: { login?: string; photoUrl?: string | null } = {}; + if (login !== user.login) payload.login = login.trim(); + if (photoUrl !== (user.photoUrl ?? null)) { + payload.photoUrl = photoUrl === null ? null : photoUrl.trim(); + } + + try { + const response = await updateProfile(payload).unwrap(); + setSuccessMessage(response.message); + } catch (error) { + console.error("Update profile failed:", error); + } + }; + + return ( +
+
+
+ {photoUrl ? ( + Avatar setFileError("Could not load that image.")} + /> + ) : ( + + {user.login.slice(0, 1).toUpperCase()} + + )} +
+
+ + +
+ +
+ +
+ + setPhotoUrl(e.target.value || null)} + disabled={isLoading} + className={inputClass} + placeholder="https://example.com/avatar.png" + /> + {photoUrl?.startsWith("data:") && ( +

+ Currently using an uploaded image. Type a URL above to switch. +

+ )} +
+ +
+ + setLogin(e.target.value)} + disabled={isLoading} + className={inputClass} + placeholder="Enter a new login (3-30 characters)" + maxLength={30} + /> +

+ If this login is already taken, you will see a notification after + saving. +

+
+ + {(fileError || serverError) && ( +
+ {fileError || serverError} +
+ )} + {successMessage && ( +
+ {successMessage} +
+ )} + + +
+ ); +} diff --git a/apps/frontend/src/modules/Auth/Auth.tsx b/apps/frontend/src/modules/Auth/Auth.tsx index 664db3e..43704ed 100644 --- a/apps/frontend/src/modules/Auth/Auth.tsx +++ b/apps/frontend/src/modules/Auth/Auth.tsx @@ -1,6 +1,6 @@ import { openPopup } from "../../portals/popup.slice"; import { useAppDispatch, useAppSelector } from "../../store"; -import { AuthPopup } from "./AuthPopup"; +import { AuthPopup } from "./AuthPopup/AuthPopup"; export function Auth() { const currentUser = useAppSelector((state) => state.auth.user); diff --git a/apps/frontend/src/modules/Auth/AuthPopup.tsx b/apps/frontend/src/modules/Auth/AuthPopup/AuthPopup.tsx similarity index 67% rename from apps/frontend/src/modules/Auth/AuthPopup.tsx rename to apps/frontend/src/modules/Auth/AuthPopup/AuthPopup.tsx index f06640c..310e064 100644 --- a/apps/frontend/src/modules/Auth/AuthPopup.tsx +++ b/apps/frontend/src/modules/Auth/AuthPopup/AuthPopup.tsx @@ -1,26 +1,21 @@ import { useState, type ChangeEvent, type FormEvent } from "react"; - -import { - useLoginUserMutation, - useRegisterUserMutation, - useLogoutUserMutation, - useLogoutAllSessionsMutation, - useResendVerificationMutation, -} from "./auth.api"; +import { useLoginUserMutation, useRegisterUserMutation } from "../auth.api"; import { LoginSchema, RegisterSchema, type Login, type Register, -} from "./auth.schema"; -import { PasswordField } from "./PasswordField"; -import { useAppDispatch, useAppSelector } from "../../store"; -import { closePopup } from "../../portals/popup.slice"; +} from "../auth.schema"; +import { PasswordField } from "../PasswordField"; +import { useAppDispatch, useAppSelector } from "../../../store"; +import { closePopup } from "../../../portals/popup.slice"; import { extractServerError, inputClass, secondaryButtonClass, -} from "./auth.utils"; +} from "../auth.utils"; +import { SignedInView } from "./SignedInView"; +import { VerifyingStage } from "./VerifyingStage"; type Stage = "signin" | "signup" | "verifying"; type FormKeys = "login" | "password" | "email"; @@ -38,9 +33,8 @@ export function AuthPopup() { const currentUser = useAppSelector((state) => state.auth.user); const [stage, setStage] = useState("signin"); - const [verifyLogin, setVerifyLogin] = useState(""); - const [verifyEmail, setVerifyEmail] = useState(""); - const [resendNotice, setResendNotice] = useState(null); + const [verifyLogin, setVerifyLogin] = useState(""); + const [verifyEmail, setVerifyEmail] = useState(""); const [loginData, setLoginData] = useState({ login: "", @@ -51,7 +45,6 @@ export function AuthPopup() { password: "", email: "", }); - const [formErrors, setFormErrors] = useState({}); const [ @@ -66,54 +59,9 @@ export function AuthPopup() { isError: isRegisterError, }, ] = useRegisterUserMutation(); - const [logoutUser, { isLoading: isLogoutLoading }] = useLogoutUserMutation(); - const [logoutAllSessions, { isLoading: isLogoutAllLoading }] = - useLogoutAllSessionsMutation(); - const [resendVerification, { isLoading: isResendLoading }] = - useResendVerificationMutation(); if (currentUser) { - return ( -
-

- Signed in -

-
-

- Logged in as {currentUser.login} -

- {currentUser.email && ( -

{currentUser.email}

- )} -
- - - - -
- ); + return ; } const isLoginMode = stage === "signin"; @@ -190,65 +138,13 @@ export function AuthPopup() { dispatch(closePopup()); }; - const handleResend = async () => { - setResendNotice(null); - try { - const response = await resendVerification({ - login: verifyLogin, - }).unwrap(); - setResendNotice(response.message); - } catch { - setResendNotice( - "Could not resend right now. Please try again in a moment.", - ); - } - }; - if (stage === "verifying") { return ( -
-

- Check your inbox -

-

- We sent a confirmation link to - {verifyEmail ? ( - <> - {" "} - {verifyEmail}. - - ) : ( - " your email." - )}{" "} - Click it to activate your account, then come back to sign in. -

- - {resendNotice && ( -
- {resendNotice} -
- )} - - - - -
+ setStage("signin")} + /> ); } diff --git a/apps/frontend/src/modules/Auth/AuthPopup/SignedInView.tsx b/apps/frontend/src/modules/Auth/AuthPopup/SignedInView.tsx new file mode 100644 index 0000000..76972f4 --- /dev/null +++ b/apps/frontend/src/modules/Auth/AuthPopup/SignedInView.tsx @@ -0,0 +1,55 @@ +import { useAppDispatch } from "../../../store"; +import { closePopup } from "../../../portals/popup.slice"; +import { + useLogoutUserMutation, + useLogoutAllSessionsMutation, +} from "../auth.api"; +import { secondaryButtonClass } from "../auth.utils"; +import type { UserSafe } from "../auth.schema"; + +export function SignedInView({ user }: { user: UserSafe }) { + const dispatch = useAppDispatch(); + const [logoutUser, { isLoading: isLogoutLoading }] = useLogoutUserMutation(); + const [logoutAllSessions, { isLoading: isLogoutAllLoading }] = + useLogoutAllSessionsMutation(); + + return ( +
+

+ Signed in +

+
+

+ Logged in as {user.login} +

+ {user.email &&

{user.email}

} +
+ + + + +
+ ); +} diff --git a/apps/frontend/src/modules/Auth/AuthPopup/VerifyingStage.tsx b/apps/frontend/src/modules/Auth/AuthPopup/VerifyingStage.tsx new file mode 100644 index 0000000..35f4433 --- /dev/null +++ b/apps/frontend/src/modules/Auth/AuthPopup/VerifyingStage.tsx @@ -0,0 +1,75 @@ +import { useState } from "react"; +import { useResendVerificationMutation } from "../auth.api"; +import { secondaryButtonClass } from "../auth.utils"; + +interface Props { + verifyEmail: string; + verifyLogin: string; + onBack: () => void; +} + +export function VerifyingStage({ verifyEmail, verifyLogin, onBack }: Props) { + const [resendNotice, setResendNotice] = useState(null); + const [resendVerification, { isLoading: isResendLoading }] = + useResendVerificationMutation(); + + const handleResend = async () => { + setResendNotice(null); + try { + const response = await resendVerification({ + login: verifyLogin, + }).unwrap(); + setResendNotice(response.message); + } catch { + setResendNotice( + "Could not resend right now. Please try again in a moment.", + ); + } + }; + + return ( +
+

+ Check your inbox +

+

+ We sent a confirmation link to + {verifyEmail ? ( + <> + {" "} + {verifyEmail}. + + ) : ( + " your email." + )}{" "} + Click it to activate your account, then come back to sign in. +

+ + {resendNotice && ( +
+ {resendNotice} +
+ )} + + + + +
+ ); +} diff --git a/apps/frontend/src/modules/Auth/Settings.tsx b/apps/frontend/src/modules/Auth/Settings.tsx index 3493766..368da1b 100644 --- a/apps/frontend/src/modules/Auth/Settings.tsx +++ b/apps/frontend/src/modules/Auth/Settings.tsx @@ -1,6 +1,6 @@ import { openPopup } from "../../portals/popup.slice"; import { useAppDispatch, useAppSelector } from "../../store"; -import { AccountSettingsPopup } from "./AccountSettingsPopup"; +import { AccountSettingsPopup } from "./AccountSettingsPopup/AccountSettingsPopup"; export function Settings() { const currentUser = useAppSelector((state) => state.auth.user); From 7ee4ea6e0d0a8dda6ea88499728f1ce36cced1a2 Mon Sep 17 00:00:00 2001 From: Bohdan Date: Thu, 21 May 2026 15:32:53 +0300 Subject: [PATCH 18/18] fix(auth): close settings popup immediately on account delete --- .../Auth/AccountSettingsPopup/DeleteSection.tsx | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/apps/frontend/src/modules/Auth/AccountSettingsPopup/DeleteSection.tsx b/apps/frontend/src/modules/Auth/AccountSettingsPopup/DeleteSection.tsx index dc1e197..8bb4cfc 100644 --- a/apps/frontend/src/modules/Auth/AccountSettingsPopup/DeleteSection.tsx +++ b/apps/frontend/src/modules/Auth/AccountSettingsPopup/DeleteSection.tsx @@ -15,7 +15,6 @@ export function DeleteSection({ }) { const [password, setPassword] = useState(""); const [formError, setFormError] = useState(null); - const [successNotice, setSuccessNotice] = useState(null); const [deleteAccount, { isLoading: isDeleting, error: deleteError }] = useDeleteAccountMutation(); @@ -29,8 +28,9 @@ export function DeleteSection({ } try { await deleteAccount(hasPassword ? { password } : {}).unwrap(); - setSuccessNotice("Account deleted. Closing..."); - setTimeout(onDeleted, 1200); + // Close immediately: the mutation dispatches logout() which clears + // currentUser. Delaying would cause a "not signed in" flash. + onDeleted(); } catch (error) { console.error("Delete account failed:", error); } @@ -48,18 +48,12 @@ export function DeleteSection({ {formError || serverError}
)} - {successNotice && ( -
- {successNotice} -
- )} - {hasPassword ? ( setPassword(e.target.value)} - disabled={isDeleting || successNotice !== null} + disabled={isDeleting} placeholder="Current password" autoComplete="current-password" /> @@ -72,7 +66,7 @@ export function DeleteSection({