diff --git a/apps/backend/.env.example b/apps/backend/.env.example index 08bf2a8..961ace9 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -26,3 +26,15 @@ 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 + +# 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" +SMTP_USER="your@gmail.com" +SMTP_PASS="your_app_password" #https://myaccount.google.com/apppasswords +SMTP_FROM="CoinRadar " 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/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/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/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/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/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 c03786a..318f8d2 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? @@ -48,14 +48,42 @@ 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) + photoUrl String? + 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 +108,3 @@ model SwapSettings { stableCoins String[] @default(["usdt", "usdc"]) wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade) } - 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/src/app.ts b/apps/backend/src/app.ts index ef21d9c..8ca3f85 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -17,7 +17,7 @@ const allowedOrigins = ( .map((origin: string) => origin.trim()) .filter(Boolean); -app.use(express.json()); +app.use(express.json({ limit: "1mb" })); app.use( cors({ origin: allowedOrigins, 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 aa5cf91..0000000 --- a/apps/backend/src/controllers/authController.ts +++ /dev/null @@ -1,690 +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, - UserSchema, -} from "../models/AuthSchema.js"; -import { handleZodError } from "../utils/helpers.js"; - -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, - wallets: user.wallets.map((wallet: WalletListItem) => ({ - id: wallet.id, - name: wallet.name, - })), - }); -}; - -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; - }; - - 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, - }; -}; - -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.user.create({ - data: { - login, - password: hashedPassword, - email: email || null, - provider: "local", - }, - include: { - wallets: { - select: { id: true, name: true }, - orderBy: { createdAt: "asc" }, - }, - }, - }); - - await createSession(newUser, req, res); - - return res.status(201).json({ - message: "User successfully registered.", - user: toSafeUserResponse(newUser), - }); - } catch (error: any) { - if (error.code === "P2002") { - return res.status(409).json({ - error: "Login or email is already taken. Please choose a different.", - }); - } - - 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." }); - } - - 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 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); - - let user = await prisma.user.findFirst({ - where: { - provider: "google", - providerId: profile.sub, - }, - include: { - wallets: { - select: { id: true, name: true }, - orderBy: { createdAt: "asc" }, - }, - }, - }); - - if (!user) { - const existingByEmail = await prisma.user.findFirst({ - where: { email: profile.email }, - include: { - wallets: { - select: { id: true, name: true }, - orderBy: { createdAt: "asc" }, - }, - }, - }); - - if (existingByEmail) { - if (!profile.emailVerified) { - return res.redirect(`${FRONTEND_URL}?auth=google_error`); - } - - user = await prisma.user.update({ - where: { id: existingByEmail.id }, - data: { - provider: "google", - providerId: profile.sub, - email: profile.email, - }, - include: { - wallets: { - select: { id: true, name: true }, - orderBy: { createdAt: "asc" }, - }, - }, - }); - } 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" }, - }, - }, - }); - } - } - - 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." }); - } -}; diff --git a/apps/backend/src/models/AuthSchema.ts b/apps/backend/src/models/AuthSchema.ts index 6b3b84f..06a2c5b 100644 --- a/apps/backend/src/models/AuthSchema.ts +++ b/apps/backend/src/models/AuthSchema.ts @@ -9,10 +9,12 @@ 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(), + 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({ @@ -22,10 +24,46 @@ 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), +}); + +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(), +}); + +// 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() + .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).") + .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 e99e511..fc4e11b 100644 --- a/apps/backend/src/router/authRouter.ts +++ b/apps/backend/src/router/authRouter.ts @@ -3,23 +3,39 @@ 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/authController.js"; +} from "../controllers/auth/sessionHandlers.js"; +import { + setPassword, + deleteAccount, + updateProfile, +} from "../controllers/auth/profileHandlers.js"; 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); authRouter.post("/logout", logoutUser); authRouter.get("/me", protect, getCurrentUser); authRouter.post("/logout-all", protect, logoutAllUserSessions); +authRouter.post("/set-password", protect, setPassword); +authRouter.patch("/me", protect, updateProfile); +authRouter.delete("/account", protect, deleteAccount); export default authRouter; diff --git a/apps/backend/src/services/emailService.ts b/apps/backend/src/services/emailService.ts new file mode 100644 index 0000000..3a0075c --- /dev/null +++ b/apps/backend/src/services/emailService.ts @@ -0,0 +1,119 @@ +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"; + +export type EmailPurpose = "verify_email"; + +export interface SentEmailRecord { + to: string; + subject: string; + purpose: EmailPurpose; + token?: string; + text: string; + html: string; + sentAt: Date; +} + +const captured: SentEmailRecord[] = []; + +let transporter: Transporter | null = null; + +const getTransporter = (): Transporter => { + if (transporter) return transporter; + + 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, + secure: SMTP_PORT === 465, + auth: { user: SMTP_USER, pass: SMTP_PASS }, + }); + } else { + transporter = nodemailer.createTransport({ jsonTransport: true }); + 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; +} + +const dispatch = async (args: DispatchArgs) => { + const { to, subject, purpose, text, html, token } = 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 }), + }); + } 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 }); +}; + +// Test helpers - only meaningful when NODE_ENV === "test". +export const __getCapturedEmails = (): readonly SentEmailRecord[] => captured; +export const __resetCapturedEmails = (): void => { + captured.length = 0; +}; diff --git a/apps/backend/tests/helpers/testUtils.ts b/apps/backend/tests/helpers/testUtils.ts index 2a16a3d..0bf3788 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,22 @@ export const registerAndCreateWallet = async () => { ); } + // Flip emailVerified directly; tests can't click the confirmation link. + 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") 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..78c80e9 --- /dev/null +++ b/apps/backend/tests/integration/auth.security.int.test.ts @@ -0,0 +1,464 @@ +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"; + +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) => + 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) => + 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 () => { + await resetDatabase(); + }); + + 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(); + + 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("blocks duplicate register against a verified account", async () => { + const login = loginOf("verifiedconflict"); + const email = `${login}@mail.com`; + + expect((await registerUser(login, "password123", email)).status).toBe(201); + await verifyUserDirectly(login); + + 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`; + + expect((await registerUser(login, "password123", email)).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`; + expect( + (await registerUser(secondLogin, "password123", secondEmail)).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("login is blocked until the email is verified", async () => { + const login = loginOf("loginblock"); + const email = `${login}@mail.com`; + const password = "password123"; + + 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 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 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 linked = await prisma.authIdentity.findFirst({ + where: { user: { login }, provider: "google" }, + }); + expect(linked).toBeTruthy(); + } finally { + restoreFetch(); + } + }); + + it("google sign-in replaces an unverified squat sharing the email", async () => { + const squatLogin = loginOf("squatg"); + const email = `${squatLogin}@mail.com`; + + 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 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", + ); + + // Squat row deleted; check by id, not login. + const stillSquatById = await prisma.user.findUnique({ + where: { id: squatUser?.id ?? "" }, + }); + 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 { + restoreFetch(); + } + }); + + 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`, + email_verified: "true", + }); + + try { + const agent = request.agent(getApp()); + const state = await startGoogleAndGetState(agent); + await agent + .get("/api/auth/google/callback") + .query({ code: "test-code", state }); + + const first = await agent + .post("/api/auth/set-password") + .send({ password: "new-password-123" }); + expect(first.status).toBe(200); + + const me = await agent.get("/api/auth/me"); + expect(me.body?.user?.hasPassword).toBe(true); + + 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("updateProfile: rename re-signs access JWT and 409s on a taken login", async () => { + const loginA = loginOf("renameA"); + const loginB = loginOf("renameB"); + const password = "password123"; + + 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 agent = request.agent(getApp()); + expect( + (await agent.post("/api/auth/login").send({ login, password })).status, + ).toBe(200); + + const wrong = await agent + .delete("/api/auth/account") + .send({ password: "nope" }); + expect(wrong.status).toBe(401); + + 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 agent = request.agent(getApp()); + const state = await startGoogleAndGetState(agent); + await agent + .get("/api/auth/google/callback") + .query({ code: "test-code", state }); + + 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 ok = await agent.delete("/api/auth/account").send({}); + expect(ok.status).toBe(200); + + expect( + await prisma.user.findUnique({ where: { login: googleLogin } }), + ).toBeNull(); + } finally { + restoreFetch(); + } + }); + + it("revokes all refresh sessions on logout-all", async () => { + const login = loginOf("logoutall"); + const password = "password123"; + expect( + (await registerUser(login, password, `${login}@mail.com`)).status, + ).toBe(201); + await verifyUserDirectly(login); + + const loginResponse = await loginUser(login, password); + expect(loginResponse.status).toBe(200); + const cookies = cookiesFrom(loginResponse); + 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/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/backend/tests/setup/jest.setup.cjs b/apps/backend/tests/setup/jest.setup.cjs index 066094b..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."); @@ -13,6 +22,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/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/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..8bb4cfc --- /dev/null +++ b/apps/frontend/src/modules/Auth/AccountSettingsPopup/DeleteSection.tsx @@ -0,0 +1,77 @@ +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 [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(); + // 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); + } + }; + + return ( +
+
+ Deleting your account removes all wallets, transactions and sessions. + This cannot be undone. +
+ + {(formError || serverError) && ( +
+ {formError || serverError} +
+ )} + {hasPassword ? ( + setPassword(e.target.value)} + disabled={isDeleting} + 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 c0f48f9..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); @@ -20,7 +20,7 @@ export function Auth() { onClick={handleOpenPopup} className="flex justify-center items-center text-center px-9 py-2 bg-(--color-card) cursor-pointer rounded transitioned hover:scale-105 text-[white] border-[white] border-2 max-w-[300px] overflow-x-auto" > - {currentUser ? (currentUser.login ?? "Authenticated") : "Sign in"} + {currentUser ? "Signed in" : "Sign in"} ); } diff --git a/apps/frontend/src/modules/Auth/AuthPopup.tsx b/apps/frontend/src/modules/Auth/AuthPopup.tsx deleted file mode 100644 index bbb677c..0000000 --- a/apps/frontend/src/modules/Auth/AuthPopup.tsx +++ /dev/null @@ -1,292 +0,0 @@ -import { useState, type ChangeEvent, type FormEvent } from "react"; - -import { - useLoginUserMutation, - useRegisterUserMutation, - useLogoutUserMutation, -} from "./auth.api"; -import { - LoginSchema, - RegisterSchema, - type Login, - type Register, -} from "./auth.schema"; -import { useAppDispatch, useAppSelector } from "../../store"; -import { closePopup } from "../../portals/popup.slice"; - -type CombinedFormKeys = "login" | "password" | "email"; -type FormErrors = Partial>; - -export function AuthPopup() { - const dispatch = useAppDispatch(); - const BASE_URL = - import.meta.env.VITE_API_BASE_URL || - "https://coinradar-wmzg.onrender.com/api/"; - - const [isLoginMode, setIsLoginMode] = useState(true); - const [showPassword, setShowPassword] = useState(false); - const [logoutUser] = useLogoutUserMutation(); - const [loginData, setLoginData] = useState({ - login: "", - password: "", - }); - const [registerData, setRegister] = useState({ - login: "", - password: "", - email: "", - }); - - const [formErrors, setFormErrors] = useState({}); - - const [ - loginUser, - { isLoading: isLoginLoading, error: loginError, isError: isLoginError }, - ] = useLoginUserMutation(); - const [ - registerUser, - { - isLoading: isRegisterLoading, - error: registerError, - isError: isRegisterError, - }, - ] = useRegisterUserMutation(); - - const currentUser = useAppSelector((state) => state.auth.user); - - const formData = isLoginMode ? loginData : registerData; - const setFormData = isLoginMode ? setLoginData : setRegister; - const currentSchema = isLoginMode ? LoginSchema : RegisterSchema; - const currentMutation = isLoginMode ? loginUser : registerUser; - - const isLoading = isLoginLoading || isRegisterLoading; - const isError = isLoginError || isRegisterError; - const currentError = isLoginMode ? loginError : registerError; - - const handleChange = (e: ChangeEvent) => { - const { name, value } = e.target; - setFormData((prev: Login | Register) => ({ ...prev, [name]: value })); - - if (formErrors[name as CombinedFormKeys]) { - setFormErrors((prev) => ({ ...prev, [name]: undefined })); - } - }; - - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); - 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; - } - } - setFormErrors(newErrors); - return; - } - - try { - await currentMutation(result.data as Login | Register).unwrap(); - dispatch(closePopup()); - } catch (err) { - 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()); - }; - - return ( -
-

- {isLoginMode ? "Sign In" : "Sign Up"} -

- - {serverErrorMessage && ( -
- {serverErrorMessage} -
- )} - -
-
- - - {formErrors.login && ( -

- {formErrors.login} -

- )} -
- - {!isLoginMode && ( -
- - - {formErrors.email && ( -

- {formErrors.email} -

- )} -
- )} - -
- - - - {formErrors.password && ( -

- {formErrors.password} -

- )} -
- -
- -
- - - - {currentUser && isLoginMode && ( - - )} -
- -
-

- {isLoginMode ? "Don't have an account?" : "Already have an account?"} -

- -
-
- ); -} diff --git a/apps/frontend/src/modules/Auth/AuthPopup/AuthPopup.tsx b/apps/frontend/src/modules/Auth/AuthPopup/AuthPopup.tsx new file mode 100644 index 0000000..310e064 --- /dev/null +++ b/apps/frontend/src/modules/Auth/AuthPopup/AuthPopup.tsx @@ -0,0 +1,264 @@ +import { useState, type ChangeEvent, type FormEvent } from "react"; +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"; +import { + extractServerError, + inputClass, + secondaryButtonClass, +} from "../auth.utils"; +import { SignedInView } from "./SignedInView"; +import { VerifyingStage } from "./VerifyingStage"; + +type Stage = "signin" | "signup" | "verifying"; +type FormKeys = "login" | "password" | "email"; +type FormErrors = Partial>; + +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"; + +export function AuthPopup() { + const dispatch = useAppDispatch(); + const BASE_URL = + import.meta.env.VITE_API_BASE_URL || + "https://coinradar-wmzg.onrender.com/api/"; + + const currentUser = useAppSelector((state) => state.auth.user); + + const [stage, setStage] = useState("signin"); + const [verifyLogin, setVerifyLogin] = useState(""); + const [verifyEmail, setVerifyEmail] = useState(""); + + const [loginData, setLoginData] = useState({ + login: "", + password: "", + }); + const [registerData, setRegisterData] = useState({ + login: "", + password: "", + email: "", + }); + const [formErrors, setFormErrors] = useState({}); + + const [ + loginUser, + { isLoading: isLoginLoading, error: loginError, isError: isLoginError }, + ] = useLoginUserMutation(); + const [ + registerUser, + { + isLoading: isRegisterLoading, + error: registerError, + isError: isRegisterError, + }, + ] = useRegisterUserMutation(); + + if (currentUser) { + return ; + } + + const isLoginMode = stage === "signin"; + const formData = isLoginMode ? loginData : registerData; + const setFormData = isLoginMode ? setLoginData : setRegisterData; + const currentSchema = isLoginMode ? LoginSchema : RegisterSchema; + 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; + ( + setFormData as ( + updater: (prev: Login | Register) => Login | Register, + ) => void + )((prev) => ({ ...prev, [name]: value }) as Login | Register); + + if (formErrors[name as FormKeys]) { + setFormErrors((prev) => ({ ...prev, [name]: undefined })); + } + }; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + 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 FormKeys]) { + newErrors[issue.path[0] as FormKeys] = issue.message; + } + } + setFormErrors(newErrors); + return; + } + + try { + 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) { + 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 handleContinueWithGoogle = () => { + window.location.href = `${BASE_URL}auth/google/start`; + dispatch(closePopup()); + }; + + if (stage === "verifying") { + return ( + setStage("signin")} + /> + ); + } + + return ( +
+

+ {isLoginMode ? "Sign In" : "Sign Up"} +

+ + {serverErrorMessage && ( +
+ {serverErrorMessage} +
+ )} + +
+
+ + + {formErrors.login && ( +

+ {formErrors.login} +

+ )} +
+ + {!isLoginMode && ( +
+ + + {formErrors.email && ( +

+ {formErrors.email} +

+ )} +
+ )} + + + +
+ +
+ + + + +
+

+ {isLoginMode ? "Don't have an account?" : "Already have an account?"} +

+ +
+
+ ); +} 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/AuthQueryParamToast.tsx b/apps/frontend/src/modules/Auth/AuthQueryParamToast.tsx new file mode 100644 index 0000000..d45e9d0 --- /dev/null +++ b/apps/frontend/src/modules/Auth/AuthQueryParamToast.tsx @@ -0,0 +1,91 @@ +import { useEffect, useState } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { useAppDispatch } from "../../store"; +import { authApi } from "./auth.api"; + +type Tone = "success" | "info" | "error"; +interface Notice { + tone: Tone; + text: string; +} + +const MESSAGES: Record = { + verified: { + tone: "success", + text: "Email verified. You can sign in now.", + }, + already_verified: { + tone: "info", + text: "This email is already verified. Sign in to continue.", + }, + verify_error: { + tone: "error", + text: "Verification link is invalid or expired. Request a new one from the sign-in popup.", + }, + google_success: { + tone: "success", + text: "Signed in with Google.", + }, + google_error: { + tone: "error", + text: "Google sign-in failed. Try again or use email and password.", + }, +}; + +const toneClass: Record = { + success: "bg-green-900/80 border-green-500/50 text-green-50", + info: "bg-purple-900/80 border-purple-500/50 text-purple-50", + error: "bg-red-900/80 border-red-500/50 text-red-50", +}; + +export function AuthQueryParamToast() { + const [notice, setNotice] = useState(null); + const dispatch = useAppDispatch(); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const authParam = params.get("auth"); + if (!authParam) return; + + const matched = MESSAGES[authParam]; + if (matched) setNotice(matched); + + if (authParam === "google_success") { + dispatch(authApi.util.invalidateTags(["User"])); + } + + params.delete("auth"); + const next = + window.location.pathname + + (params.toString() ? `?${params.toString()}` : "") + + window.location.hash; + window.history.replaceState({}, "", next); + }, [dispatch]); + + useEffect(() => { + if (!notice) return; + const timer = setTimeout(() => setNotice(null), 5000); + return () => clearTimeout(timer); + }, [notice]); + + return ( + + {notice && ( + + {notice.text} + + )} + + ); +} diff --git a/apps/frontend/src/modules/Auth/PasswordField.tsx b/apps/frontend/src/modules/Auth/PasswordField.tsx new file mode 100644 index 0000000..eaa02c8 --- /dev/null +++ b/apps/frontend/src/modules/Auth/PasswordField.tsx @@ -0,0 +1,89 @@ +import { useState, type ChangeEventHandler } from "react"; + +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"; + +interface Props { + name?: string; + value: string; + onChange: ChangeEventHandler; + disabled?: boolean; + placeholder?: string; + autoComplete?: string; + label?: string; + error?: string; +} + +export function PasswordField({ + name, + value, + onChange, + disabled, + placeholder, + autoComplete, + label, + error, +}: Props) { + const [visible, setVisible] = useState(false); + + return ( +
+ {label && ( + + )} + + + {error && ( +

{error}

+ )} +
+ ); +} diff --git a/apps/frontend/src/modules/Auth/Settings.tsx b/apps/frontend/src/modules/Auth/Settings.tsx new file mode 100644 index 0000000..368da1b --- /dev/null +++ b/apps/frontend/src/modules/Auth/Settings.tsx @@ -0,0 +1,42 @@ +import { openPopup } from "../../portals/popup.slice"; +import { useAppDispatch, useAppSelector } from "../../store"; +import { AccountSettingsPopup } from "./AccountSettingsPopup/AccountSettingsPopup"; + +export function Settings() { + const currentUser = useAppSelector((state) => state.auth.user); + const dispatch = useAppDispatch(); + + if (!currentUser) return null; + + const handleOpen = () => { + dispatch( + openPopup({ + title: "Account settings", + children: , + }), + ); + }; + + const fallbackLetter = currentUser.login.slice(0, 1).toUpperCase(); + + return ( + + ); +} diff --git a/apps/frontend/src/modules/Auth/auth.api.ts b/apps/frontend/src/modules/Auth/auth.api.ts index 7069145..9f7d3f3 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", @@ -93,6 +95,76 @@ 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"], + }), + + 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); + } + }, + }, + ), + + 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"], + }), }), }); @@ -100,5 +172,10 @@ export const { useLogoutUserMutation, useLoginUserMutation, useRegisterUserMutation, + useResendVerificationMutation, useGetCurrentUserQuery, + useLogoutAllSessionsMutation, + useSetPasswordMutation, + useDeleteAccountMutation, + useUpdateProfileMutation, } = authApi; diff --git a/apps/frontend/src/modules/Auth/auth.schema.ts b/apps/frontend/src/modules/Auth/auth.schema.ts index 35768c5..efba6cb 100644 --- a/apps/frontend/src/modules/Auth/auth.schema.ts +++ b/apps/frontend/src/modules/Auth/auth.schema.ts @@ -9,6 +9,9 @@ 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(), + hasPassword: z.boolean().optional(), + photoUrl: z.string().nullable().optional(), token: z.string().optional(), wallets: z.array(WalletListItemResponseSchema).optional(), @@ -23,7 +26,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 +42,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; 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"; diff --git a/apps/frontend/src/modules/FixedHeader/FixedHeader.tsx b/apps/frontend/src/modules/FixedHeader/FixedHeader.tsx index 2f78585..7cfa89e 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() { ))} + ); }; @@ -81,11 +83,11 @@ export function FixedHeader() { )}
-
+
{renderMenuButtons(true)}
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",