mission-control/lib/server/auth.ts
OpenClaw Bot c1c01bd21e feat: merge Gantt Board into Mission Control
- Add Projects page with Sprint Board and Backlog views
- Copy SprintBoard and BacklogView components to components/gantt/
- Copy useTaskStore for project/task/sprint management
- Add API routes for task persistence with SQLite
- Add UI components: dialog, select, table, textarea
- Add avatar and attachment utilities
- Update sidebar with Projects navigation link
- Remove static export config to support API routes
- Add dist to .gitignore
2026-02-20 18:49:52 -06:00

336 lines
10 KiB
TypeScript

import Database from "better-sqlite3";
import { randomBytes, scryptSync, timingSafeEqual, createHash } from "crypto";
import { mkdirSync } from "fs";
import { join } from "path";
import { cookies } from "next/headers";
const DATA_DIR = join(process.cwd(), "data");
const DB_FILE = join(DATA_DIR, "tasks.db");
const SESSION_COOKIE_NAME = "gantt_session";
const SESSION_HOURS_SHORT = 12;
const SESSION_DAYS_REMEMBER = 30;
type SqliteDb = InstanceType<typeof Database>;
let db: SqliteDb | null = null;
export interface AuthUser {
id: string;
name: string;
email: string;
avatarUrl?: string;
createdAt: string;
}
interface UserRow extends AuthUser {
passwordHash: string;
}
function normalizeEmail(email: string): string {
return email.trim().toLowerCase();
}
function normalizeAvatarDataUrl(value: string | null | undefined): string | undefined {
if (value == null) return undefined;
const trimmed = value.trim();
if (!trimmed) return undefined;
if (!trimmed.startsWith("data:image/")) {
throw new Error("Avatar must be an image");
}
if (trimmed.length > 2_000_000) {
throw new Error("Avatar image is too large");
}
return trimmed;
}
function ensureUserSchema(database: SqliteDb) {
const userColumns = database.prepare("PRAGMA table_info(users)").all() as Array<{ name: string }>;
if (!userColumns.some((column) => column.name === "avatarUrl")) {
database.exec("ALTER TABLE users ADD COLUMN avatarUrl TEXT;");
}
}
function getDb(): SqliteDb {
if (db) {
ensureUserSchema(db);
return db;
}
mkdirSync(DATA_DIR, { recursive: true });
const database = new Database(DB_FILE);
database.pragma("journal_mode = WAL");
database.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
avatarUrl TEXT,
passwordHash TEXT NOT NULL,
createdAt TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
userId TEXT NOT NULL,
tokenHash TEXT NOT NULL UNIQUE,
createdAt TEXT NOT NULL,
expiresAt TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions(tokenHash);
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(userId);
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expiresAt);
`);
ensureUserSchema(database);
db = database;
return database;
}
function hashPassword(password: string, salt?: string): string {
const safeSalt = salt || randomBytes(16).toString("hex");
const derived = scryptSync(password, safeSalt, 64).toString("hex");
return `scrypt$${safeSalt}$${derived}`;
}
function verifyPassword(password: string, stored: string): boolean {
const parts = stored.split("$");
if (parts.length !== 3 || parts[0] !== "scrypt") return false;
const [, salt, digest] = parts;
const candidate = hashPassword(password, salt);
const candidateDigest = candidate.split("$")[2];
const a = Buffer.from(digest, "hex");
const b = Buffer.from(candidateDigest, "hex");
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
function hashSessionToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
function deleteExpiredSessions(database: SqliteDb) {
const now = new Date().toISOString();
database.prepare("DELETE FROM sessions WHERE expiresAt <= ?").run(now);
}
export function registerUser(params: {
name: string;
email: string;
password: string;
}): AuthUser {
const database = getDb();
deleteExpiredSessions(database);
const name = params.name.trim();
const email = normalizeEmail(params.email);
const password = params.password;
if (name.length < 2) throw new Error("Name must be at least 2 characters");
if (!email.includes("@")) throw new Error("Invalid email");
if (password.length < 8) throw new Error("Password must be at least 8 characters");
const existing = database
.prepare("SELECT id FROM users WHERE email = ? LIMIT 1")
.get(email) as { id: string } | undefined;
if (existing) {
throw new Error("Email already exists");
}
const user: AuthUser = {
id: `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name,
email,
avatarUrl: undefined,
createdAt: new Date().toISOString(),
};
database
.prepare("INSERT INTO users (id, name, email, avatarUrl, passwordHash, createdAt) VALUES (?, ?, ?, ?, ?, ?)")
.run(user.id, user.name, user.email, user.avatarUrl ?? null, hashPassword(password), user.createdAt);
return user;
}
export function authenticateUser(params: {
email: string;
password: string;
}): AuthUser | null {
const database = getDb();
deleteExpiredSessions(database);
const email = normalizeEmail(params.email);
const row = database
.prepare("SELECT id, name, email, avatarUrl, passwordHash, createdAt FROM users WHERE email = ? LIMIT 1")
.get(email) as UserRow | undefined;
if (!row) return null;
if (!verifyPassword(params.password, row.passwordHash)) return null;
return {
id: row.id,
name: row.name,
email: row.email,
avatarUrl: row.avatarUrl ?? undefined,
createdAt: row.createdAt,
};
}
export function updateUserAccount(params: {
userId: string;
name?: string;
email?: string;
avatarUrl?: string | null;
currentPassword?: string;
newPassword?: string;
}): AuthUser {
const database = getDb();
deleteExpiredSessions(database);
const row = database
.prepare("SELECT id, name, email, avatarUrl, passwordHash, createdAt FROM users WHERE id = ? LIMIT 1")
.get(params.userId) as UserRow | undefined;
if (!row) throw new Error("User not found");
const requestedName = typeof params.name === "string" ? params.name.trim() : row.name;
const requestedEmail = typeof params.email === "string" ? normalizeEmail(params.email) : row.email;
const hasAvatarInput = Object.prototype.hasOwnProperty.call(params, "avatarUrl");
const requestedAvatar = hasAvatarInput ? normalizeAvatarDataUrl(params.avatarUrl) : row.avatarUrl;
const currentPassword = params.currentPassword || "";
const newPassword = params.newPassword || "";
if (requestedName.length < 2) throw new Error("Name must be at least 2 characters");
if (!requestedEmail.includes("@")) throw new Error("Invalid email");
if (newPassword && newPassword.length < 8) throw new Error("New password must be at least 8 characters");
const emailChanged = requestedEmail !== row.email;
const passwordChanged = newPassword.length > 0;
const needsPasswordCheck = emailChanged || passwordChanged;
if (needsPasswordCheck) {
if (!currentPassword) throw new Error("Current password is required");
if (!verifyPassword(currentPassword, row.passwordHash)) {
throw new Error("Current password is incorrect");
}
}
if (emailChanged) {
const existing = database
.prepare("SELECT id FROM users WHERE email = ? AND id != ? LIMIT 1")
.get(requestedEmail, row.id) as { id: string } | undefined;
if (existing) throw new Error("Email already exists");
}
const nextPasswordHash = passwordChanged ? hashPassword(newPassword) : row.passwordHash;
database
.prepare("UPDATE users SET name = ?, email = ?, avatarUrl = ?, passwordHash = ? WHERE id = ?")
.run(requestedName, requestedEmail, requestedAvatar ?? null, nextPasswordHash, row.id);
return {
id: row.id,
name: requestedName,
email: requestedEmail,
avatarUrl: requestedAvatar ?? undefined,
createdAt: row.createdAt,
};
}
export function listUsers(): AuthUser[] {
const database = getDb();
return database
.prepare("SELECT id, name, email, avatarUrl, createdAt FROM users ORDER BY LOWER(name) ASC")
.all() as AuthUser[];
}
export function createUserSession(userId: string, rememberMe: boolean): {
token: string;
expiresAt: string;
} {
const database = getDb();
deleteExpiredSessions(database);
const now = Date.now();
const ttlMs = rememberMe
? SESSION_DAYS_REMEMBER * 24 * 60 * 60 * 1000
: SESSION_HOURS_SHORT * 60 * 60 * 1000;
const createdAt = new Date(now).toISOString();
const expiresAt = new Date(now + ttlMs).toISOString();
const token = randomBytes(32).toString("hex");
const tokenHash = hashSessionToken(token);
database
.prepare("INSERT INTO sessions (id, userId, tokenHash, createdAt, expiresAt) VALUES (?, ?, ?, ?, ?)")
.run(`session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, userId, tokenHash, createdAt, expiresAt);
return { token, expiresAt };
}
export function revokeSession(token: string) {
const database = getDb();
const tokenHash = hashSessionToken(token);
database.prepare("DELETE FROM sessions WHERE tokenHash = ?").run(tokenHash);
}
export function getUserBySessionToken(token: string): AuthUser | null {
const database = getDb();
deleteExpiredSessions(database);
const tokenHash = hashSessionToken(token);
const now = new Date().toISOString();
const row = database
.prepare(`
SELECT u.id, u.name, u.email, u.avatarUrl, u.createdAt
FROM sessions s
JOIN users u ON u.id = s.userId
WHERE s.tokenHash = ? AND s.expiresAt > ?
LIMIT 1
`)
.get(tokenHash, now) as AuthUser | undefined;
return row ?? null;
}
export async function setSessionCookie(token: string, rememberMe: boolean) {
const cookieStore = await cookies();
const baseOptions = {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
} as const;
if (rememberMe) {
cookieStore.set(SESSION_COOKIE_NAME, token, {
...baseOptions,
maxAge: SESSION_DAYS_REMEMBER * 24 * 60 * 60,
});
return;
}
// Session cookie (clears on browser close)
cookieStore.set(SESSION_COOKIE_NAME, token, baseOptions);
}
export async function clearSessionCookie() {
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE_NAME, "", {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: 0,
});
}
export async function getSessionTokenFromCookies(): Promise<string | null> {
const cookieStore = await cookies();
return cookieStore.get(SESSION_COOKIE_NAME)?.value ?? null;
}
export async function getAuthenticatedUser(): Promise<AuthUser | null> {
const token = await getSessionTokenFromCookies();
if (!token) return null;
return getUserBySessionToken(token);
}