Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
2cd5e13
chore(prisma): add AuthIdentity, EmailToken, emailVerified
BODMAT May 20, 2026
820cf3c
chore(backend): add nodemailer email service with templates
BODMAT May 20, 2026
1d25ece
feat(auth): require email verification for local register/login
BODMAT May 20, 2026
0dd8520
fix(auth): close google account-takeover with merge-confirmation flow
BODMAT May 20, 2026
2eef43a
feat(auth): set-password, one-time-password and delete-account endpoints
BODMAT May 20, 2026
498930c
chore(prisma/seed,tests): adapt to AuthIdentity + email verification
BODMAT May 20, 2026
eba2c82
fix(prisma): cascade Wallet on user delete
BODMAT May 20, 2026
6829b9a
fix(auth): let new register replace an unverified squat on login/email
BODMAT May 20, 2026
a0fb9e0
feat(frontend): verification UX, account settings popup, query toasts
BODMAT May 20, 2026
b5bcf5f
fix(auth-ui): drop DELETE typing confirmation and add header settings…
BODMAT May 20, 2026
46d6bf9
fix(auth): profile photo persistence, google-link flow, and auth UI copy
BODMAT May 20, 2026
b07404c
test(backend): add auth security integration coverage
BODMAT May 20, 2026
b94bf51
fix(auth): tighten deletion flow and stabilize security tests
BODMAT May 20, 2026
fea7f50
fix(backend): disable real SMTP delivery in test environment
BODMAT May 20, 2026
17f7cc5
refactor(auth): drop OTP + merge-confirm + delete-email; trust google…
BODMAT May 21, 2026
b87f7b7
refactor(auth): shared utils, drop metadata, fix hasPassword fallback…
BODMAT May 21, 2026
845d8af
refactor(auth): split authController and Auth popups into subfolders
BODMAT May 21, 2026
7ee4ea6
fix(auth): close settings popup immediately on account delete
BODMAT May 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions apps/backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 <your@gmail.com>"
2 changes: 2 additions & 0 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable: carry merge-confirmation payload (google sub/email) on the token
ALTER TABLE "EmailToken" ADD COLUMN "metadata" JSONB;
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- Drop unused metadata column (merge-confirm flow was removed).
ALTER TABLE "EmailToken" DROP COLUMN IF EXISTS "metadata";
47 changes: 37 additions & 10 deletions apps/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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 {
Expand All @@ -80,4 +108,3 @@ model SwapSettings {
stableCoins String[] @default(["usdt", "usdc"])
wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade)
}

17 changes: 14 additions & 3 deletions apps/backend/prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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) => [
{
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
54 changes: 54 additions & 0 deletions apps/backend/src/controllers/auth/authConfig.ts
Original file line number Diff line number Diff line change
@@ -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 },
},
};
Loading
Loading