787a695fbe
- 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>
139 lines
4.7 KiB
TypeScript
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 } } });
|
|
}
|