Files
pr-preview/backend/src/routes/auth.ts
T
luna d1ec1287d2 fix(v2): UX polish, spec compliance, type safety, race-condition fixes
- Auto-create WebhookToken for new users (first-user and admin-created),
  so setup wizard step 3 immediately has a valid secret to display
- Setup wizard: load webhook secret on step 3 entry (not only on AWS save),
  so skipping AWS setup still shows correct webhook info
- Admin panel: add Edit modal with username and password change for any user
  (spec: 'Edit username or password of any user')
- Webhook handler: only post 'no config' comment on opened/reopened actions,
  not on synchronize or closed — prevents spam on sync events
- deploy.ts: clear stale abort signal at start of runDeploy so a signal meant
  to cancel the previous job cannot accidentally abort the new one
- routes/auth.ts: fix sameSite cookie case to lowercase 'lax' per TypeScript
- Add node-cron type declaration to silence TS7016 for that import
- SPEC.md: fix ec2:ImportKeyPair → ec2:CreateKeyPair (code uses CreateKeyPair)
- example.env: improve comments, add NODE_ENV, add key generation hints

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-25 08:39:36 +00:00

139 lines
4.7 KiB
TypeScript

import { randomBytes } from "crypto";
import bcrypt from "bcryptjs";
import { Cookie } from "rjweb-server";
import { prisma } from "../lib/db";
import { makeResponse } from "../lib/response";
import { ERROR_MESSAGES } from "../lib/errors";
import { createLogger } from "../lib/logger";
const log = createLogger("AUTH_ROUTE");
const COOKIE_NAME = "pp_session";
const COOKIE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
export async function loginHandler(ctr: any) {
let body: any;
try {
body = await ctr.body();
} catch {
return makeResponse({ ctr, content: { code: 400, message: "Invalid body" } });
}
const { username, password } = body || {};
if (!username || !password) {
return makeResponse({ ctr, content: { code: 400, message: "Username and password required" } });
}
const user = await prisma.user.findUnique({ where: { username } });
if (!user) {
return makeResponse({ ctr, content: { code: 401, message: "Invalid credentials" } });
}
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) {
return makeResponse({ ctr, content: { code: 401, message: "Invalid credentials" } });
}
const sessionHash = randomBytes(32).toString("hex");
await prisma.session.create({ data: { hash: sessionHash, userId: user.id } });
ctr.cookies.set(
COOKIE_NAME,
new Cookie(sessionHash, {
httpOnly: true,
expires: new Date(Date.now() + COOKIE_MAX_AGE_MS),
path: "/",
sameSite: "lax",
}),
);
return makeResponse({ ctr, content: { code: 200, data: { id: user.id, username: user.username, isAdmin: user.isAdmin } } });
}
export async function logoutHandler(ctr: any) {
const auth = ctr.getAuth?.();
if (auth?.success) {
await prisma.session.delete({ where: { id: auth.sessionId } }).catch(() => {});
}
ctr.cookies.set(COOKIE_NAME, new Cookie("", { expires: new Date(0), path: "/" }));
return makeResponse({ ctr, content: { code: 200, message: "Logged out" } });
}
export async function meHandler(ctr: any) {
const auth = ctr.getAuth?.();
if (!auth?.success) {
return makeResponse({ ctr, content: { code: ERROR_MESSAGES.UNAUTHORIZED.code, message: ERROR_MESSAGES.UNAUTHORIZED.message } });
}
const user = await prisma.user.findUnique({ where: { id: auth.user.id } });
if (!user) return makeResponse({ ctr, content: { code: 404, message: "User not found" } });
return makeResponse({
ctr,
content: {
code: 200,
data: {
id: user.id,
username: user.username,
isAdmin: user.isAdmin,
isFounder: user.isFounder,
giteaUsername: user.giteaUsername,
giteaInstanceUrl: user.giteaInstanceUrl,
giteaPatSet: !!user.giteaPAT,
awsAccessKeyId: user.awsAccessKeyId ? "****" : null,
awsRegion: user.awsRegion,
awsConfigured: !!(user.awsAccessKeyId && user.awsSecretAccessKey && user.awsRegion),
setupComplete: !!(user.giteaInstanceUrl && user.giteaPAT && user.awsAccessKeyId && user.awsSecretAccessKey && user.awsRegion),
},
},
});
}
export async function setupStatusHandler(ctr: any) {
const count = await prisma.user.count();
return makeResponse({ ctr, content: { code: 200, data: { needsSetup: count === 0 } } });
}
export async function firstUserHandler(ctr: any) {
const count = await prisma.user.count();
if (count > 0) {
return makeResponse({ ctr, content: { code: 403, message: "Setup already completed. Contact an administrator to create your account." } });
}
let body: any;
try {
body = await ctr.body();
} catch {
return makeResponse({ ctr, content: { code: 400, message: "Invalid body" } });
}
const { username, password } = body || {};
if (!username || !password || password.length < 8) {
return makeResponse({ ctr, content: { code: 400, message: "Username and password (min 8 chars) required" } });
}
const hash = await bcrypt.hash(password, 12);
const user = await prisma.user.create({
data: { username, passwordHash: hash, isAdmin: true, isFounder: true },
});
// Auto-create a webhook token so it's immediately available in the setup wizard
const webhookSecret = randomBytes(32).toString("hex");
await prisma.webhookToken.create({ data: { userId: user.id, token: webhookSecret } });
const sessionHash = randomBytes(32).toString("hex");
await prisma.session.create({ data: { hash: sessionHash, userId: user.id } });
ctr.cookies.set(
COOKIE_NAME,
new Cookie(sessionHash, {
httpOnly: true,
expires: new Date(Date.now() + COOKIE_MAX_AGE_MS),
path: "/",
sameSite: "lax",
}),
);
log.info({ username }, "First user (founder) created");
return makeResponse({ ctr, content: { code: 201, data: { id: user.id, username: user.username, isAdmin: true, isFounder: true } } });
}