- 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
336 lines
10 KiB
TypeScript
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);
|
|
}
|