This commit is contained in:
simon.franken
2026-02-16 16:09:07 +01:00
parent d200254783
commit a9228d19c8
8 changed files with 226 additions and 105 deletions

View File

@@ -0,0 +1,110 @@
-- CreateTable
CREATE TABLE "users" (
"id" VARCHAR(255) NOT NULL,
"username" VARCHAR(255) NOT NULL,
"email" VARCHAR(255) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "clients" (
"id" TEXT NOT NULL,
"name" VARCHAR(255) NOT NULL,
"description" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"user_id" VARCHAR(255) NOT NULL,
CONSTRAINT "clients_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "projects" (
"id" TEXT NOT NULL,
"name" VARCHAR(255) NOT NULL,
"description" TEXT,
"color" VARCHAR(7),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"user_id" VARCHAR(255) NOT NULL,
"client_id" TEXT NOT NULL,
CONSTRAINT "projects_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "time_entries" (
"id" TEXT NOT NULL,
"start_time" TIMESTAMPTZ NOT NULL,
"end_time" TIMESTAMPTZ NOT NULL,
"description" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"user_id" VARCHAR(255) NOT NULL,
"project_id" TEXT NOT NULL,
CONSTRAINT "time_entries_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ongoing_timers" (
"id" TEXT NOT NULL,
"start_time" TIMESTAMPTZ NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"user_id" VARCHAR(255) NOT NULL,
"project_id" TEXT,
CONSTRAINT "ongoing_timers_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "clients_user_id_idx" ON "clients"("user_id");
-- CreateIndex
CREATE INDEX "projects_user_id_idx" ON "projects"("user_id");
-- CreateIndex
CREATE INDEX "projects_client_id_idx" ON "projects"("client_id");
-- CreateIndex
CREATE INDEX "time_entries_user_id_idx" ON "time_entries"("user_id");
-- CreateIndex
CREATE INDEX "time_entries_user_id_start_time_idx" ON "time_entries"("user_id", "start_time");
-- CreateIndex
CREATE INDEX "time_entries_project_id_idx" ON "time_entries"("project_id");
-- CreateIndex
CREATE UNIQUE INDEX "ongoing_timers_user_id_key" ON "ongoing_timers"("user_id");
-- CreateIndex
CREATE UNIQUE INDEX "ongoing_timers_project_id_key" ON "ongoing_timers"("project_id");
-- CreateIndex
CREATE INDEX "ongoing_timers_user_id_idx" ON "ongoing_timers"("user_id");
-- AddForeignKey
ALTER TABLE "clients" ADD CONSTRAINT "clients_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "projects" ADD CONSTRAINT "projects_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "projects" ADD CONSTRAINT "projects_client_id_fkey" FOREIGN KEY ("client_id") REFERENCES "clients"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "time_entries" ADD CONSTRAINT "time_entries_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "time_entries" ADD CONSTRAINT "time_entries_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ongoing_timers" ADD CONSTRAINT "ongoing_timers_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ongoing_timers" ADD CONSTRAINT "ongoing_timers_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -1,39 +1,37 @@
import dotenv from 'dotenv'; import dotenv from "dotenv";
import path from 'path'; import path from "path";
dotenv.config({ path: path.resolve(__dirname, '../../.env') }); dotenv.config({ path: path.resolve(__dirname, "../../.env") });
export const config = { export const config = {
port: parseInt(process.env.PORT || '3001', 10), port: parseInt(process.env.PORT || "3001", 10),
nodeEnv: process.env.NODE_ENV || 'development', nodeEnv: process.env.NODE_ENV || "development",
database: { database: {
url: process.env.DATABASE_URL || '', url: process.env.DATABASE_URL || "",
}, },
oidc: { oidc: {
issuerUrl: process.env.OIDC_ISSUER_URL || '', issuerUrl: process.env.OIDC_ISSUER_URL || "",
clientId: process.env.OIDC_CLIENT_ID || '', clientId: process.env.OIDC_CLIENT_ID || "",
redirectUri: process.env.OIDC_REDIRECT_URI || 'http://localhost:3001/auth/callback', redirectUri:
process.env.OIDC_REDIRECT_URI ||
"http://localhost:3001/api/auth/callback",
}, },
session: { session: {
secret: process.env.SESSION_SECRET || 'default-secret-change-in-production', secret: process.env.SESSION_SECRET || "default-secret-change-in-production",
maxAge: 24 * 60 * 60 * 1000, // 24 hours maxAge: 24 * 60 * 60 * 1000, // 24 hours
}, },
cors: { cors: {
origin: process.env.FRONTEND_URL || 'http://localhost:5173', origin: process.env.FRONTEND_URL || "http://localhost:5173",
credentials: true, credentials: true,
}, },
}; };
export function validateConfig(): void { export function validateConfig(): void {
const required = [ const required = ["DATABASE_URL", "OIDC_ISSUER_URL", "OIDC_CLIENT_ID"];
'DATABASE_URL',
'OIDC_ISSUER_URL',
'OIDC_CLIENT_ID',
];
for (const key of required) { for (const key of required) {
if (!process.env[key]) { if (!process.env[key]) {
@@ -42,6 +40,8 @@ export function validateConfig(): void {
} }
if (config.session.secret.length < 32) { if (config.session.secret.length < 32) {
console.warn('Warning: SESSION_SECRET should be at least 32 characters for security'); console.warn(
"Warning: SESSION_SECRET should be at least 32 characters for security",
);
} }
} }

View File

@@ -1,16 +1,16 @@
import express from 'express'; import express from "express";
import cors from 'cors'; import cors from "cors";
import session from 'express-session'; import session from "express-session";
import { config, validateConfig } from './config'; import { config, validateConfig } from "./config";
import { connectDatabase } from './prisma/client'; import { connectDatabase } from "./prisma/client";
import { errorHandler, notFoundHandler } from './middleware/errorHandler'; import { errorHandler, notFoundHandler } from "./middleware/errorHandler";
// Import routes // Import routes
import authRoutes from './routes/auth.routes'; import authRoutes from "./routes/auth.routes";
import clientRoutes from './routes/client.routes'; import clientRoutes from "./routes/client.routes";
import projectRoutes from './routes/project.routes'; import projectRoutes from "./routes/project.routes";
import timeEntryRoutes from './routes/timeEntry.routes'; import timeEntryRoutes from "./routes/timeEntry.routes";
import timerRoutes from './routes/timer.routes'; import timerRoutes from "./routes/timer.routes";
async function main() { async function main() {
// Validate configuration // Validate configuration
@@ -22,40 +22,44 @@ async function main() {
const app = express(); const app = express();
// CORS // CORS
app.use(cors({ app.use(
cors({
origin: config.cors.origin, origin: config.cors.origin,
credentials: true, credentials: true,
})); }),
);
// Body parsing // Body parsing
app.use(express.json()); app.use(express.json());
app.use(express.urlencoded({ extended: true })); app.use(express.urlencoded({ extended: true }));
// Session // Session
app.use(session({ app.use(
session({
secret: config.session.secret, secret: config.session.secret,
resave: false, resave: false,
saveUninitialized: false, saveUninitialized: false,
name: 'sessionId', name: "sessionId",
cookie: { cookie: {
secure: config.nodeEnv === 'production', secure: false,
httpOnly: true, httpOnly: false,
maxAge: config.session.maxAge, maxAge: config.session.maxAge,
sameSite: config.nodeEnv === 'production' ? 'strict' : 'lax', sameSite: "lax",
}, },
})); }),
);
// Health check // Health check
app.get('/health', (_req, res) => { app.get("/health", (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() }); res.json({ status: "ok", timestamp: new Date().toISOString() });
}); });
// Routes // Routes
app.use('/auth', authRoutes); app.use("/api/auth", authRoutes);
app.use('/api/clients', clientRoutes); app.use("/api/clients", clientRoutes);
app.use('/api/projects', projectRoutes); app.use("/api/projects", projectRoutes);
app.use('/api/time-entries', timeEntryRoutes); app.use("/api/time-entries", timeEntryRoutes);
app.use('/api/timer', timerRoutes); app.use("/api/timer", timerRoutes);
// Error handling // Error handling
app.use(notFoundHandler); app.use(notFoundHandler);
@@ -69,6 +73,6 @@ async function main() {
} }
main().catch((error) => { main().catch((error) => {
console.error('Failed to start server:', error); console.error("Failed to start server:", error);
process.exit(1); process.exit(1);
}); });

View File

@@ -1,7 +1,13 @@
import { Router } from 'express'; import { Router } from "express";
import { initializeOIDC, createAuthSession, getAuthorizationUrl, handleCallback, getUserInfo } from '../auth/oidc'; import {
import { syncUser } from '../middleware/auth'; initializeOIDC,
import type { AuthenticatedRequest } from '../types'; createAuthSession,
getAuthorizationUrl,
handleCallback,
getUserInfo,
} from "../auth/oidc";
import { syncUser } from "../middleware/auth";
import type { AuthenticatedRequest } from "../types";
const router = Router(); const router = Router();
@@ -16,7 +22,7 @@ async function ensureOIDC() {
} }
// GET /auth/login - Initiate OIDC login flow // GET /auth/login - Initiate OIDC login flow
router.get('/login', async (req, res) => { router.get("/login", async (req, res) => {
try { try {
await ensureOIDC(); await ensureOIDC();
@@ -26,23 +32,26 @@ router.get('/login', async (req, res) => {
const authorizationUrl = getAuthorizationUrl(session); const authorizationUrl = getAuthorizationUrl(session);
res.redirect(authorizationUrl); res.redirect(authorizationUrl);
} catch (error) { } catch (error) {
console.error('Login error:', error); console.error("Login error:", error);
res.status(500).json({ error: 'Failed to initiate login' }); res.status(500).json({ error: "Failed to initiate login" });
} }
}); });
// GET /auth/callback - OIDC callback handler // GET /auth/callback - OIDC callback handler
router.get('/callback', async (req, res) => { router.get("/callback", async (req, res) => {
try { try {
await ensureOIDC(); await ensureOIDC();
const oidcSession = req.session.oidc; const oidcSession = req.session.oidc;
if (!oidcSession) { if (!oidcSession) {
res.status(400).json({ error: 'Invalid session' }); res.status(400).json({ error: "Invalid session" });
return; return;
} }
const tokenSet = await handleCallback(req.query as Record<string, string>, oidcSession); const tokenSet = await handleCallback(
req.query as Record<string, string>,
oidcSession,
);
const user = await getUserInfo(tokenSet); const user = await getUserInfo(tokenSet);
// Sync user with database // Sync user with database
@@ -53,31 +62,31 @@ router.get('/callback', async (req, res) => {
delete req.session.oidc; delete req.session.oidc;
// Redirect to frontend // Redirect to frontend
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173'; const frontendUrl = process.env.FRONTEND_URL || "http://localhost:5173";
res.redirect(`${frontendUrl}/auth/callback?success=true`); res.redirect(`${frontendUrl}/auth/callback?success=true`);
} catch (error) { } catch (error) {
console.error('Callback error:', error); console.error("Callback error:", error);
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173'; const frontendUrl = process.env.FRONTEND_URL || "http://localhost:5173";
res.redirect(`${frontendUrl}/auth/callback?error=authentication_failed`); res.redirect(`${frontendUrl}/auth/callback?error=authentication_failed`);
} }
}); });
// POST /auth/logout - End session // POST /auth/logout - End session
router.post('/logout', (req: AuthenticatedRequest, res) => { router.post("/logout", (req: AuthenticatedRequest, res) => {
req.session.destroy((err) => { req.session.destroy((err) => {
if (err) { if (err) {
res.status(500).json({ error: 'Failed to logout' }); res.status(500).json({ error: "Failed to logout" });
return; return;
} }
res.clearCookie('connect.sid'); res.clearCookie("connect.sid");
res.json({ message: 'Logged out successfully' }); res.json({ message: "Logged out successfully" });
}); });
}); });
// GET /auth/me - Get current user // GET /auth/me - Get current user
router.get('/me', (req: AuthenticatedRequest, res) => { router.get("/me", (req: AuthenticatedRequest, res) => {
if (!req.session?.user) { if (!req.session?.user) {
res.status(401).json({ error: 'Not authenticated' }); res.status(401).json({ error: "Not authenticated" });
return; return;
} }
res.json(req.session.user); res.json(req.session.user);

View File

@@ -12,7 +12,7 @@ services:
ports: ports:
- "5432:5432" - "5432:5432"
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U timetracker"] test: [ "CMD-SHELL", "pg_isready -U timetracker" ]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 5 retries: 5
@@ -25,7 +25,7 @@ services:
DATABASE_URL: "postgresql://timetracker:timetracker_password@db:5432/timetracker" DATABASE_URL: "postgresql://timetracker:timetracker_password@db:5432/timetracker"
OIDC_ISSUER_URL: ${OIDC_ISSUER_URL} OIDC_ISSUER_URL: ${OIDC_ISSUER_URL}
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID} OIDC_CLIENT_ID: ${OIDC_CLIENT_ID}
OIDC_REDIRECT_URI: "http://localhost:3001/auth/callback" OIDC_REDIRECT_URI: "http://localhost:3001/api/auth/callback"
SESSION_SECRET: ${SESSION_SECRET} SESSION_SECRET: ${SESSION_SECRET}
PORT: 3001 PORT: 3001
NODE_ENV: production NODE_ENV: production

View File

@@ -1,7 +1,7 @@
import axios from 'axios'; import axios from "axios";
import type { User } from '@/types'; import type { User } from "@/types";
const AUTH_BASE = '/auth'; const AUTH_BASE = "/api/auth";
export const authApi = { export const authApi = {
login: (): void => { login: (): void => {

View File

@@ -1,23 +1,21 @@
import { defineConfig } from 'vite'; import { defineConfig } from "vite";
import react from '@vitejs/plugin-react'; import react from "@vitejs/plugin-react";
import path from 'path'; import path from "path";
const backend = "http://127.0.0.1:3001";
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
resolve: { resolve: {
alias: { alias: {
'@': path.resolve(__dirname, './src'), "@": path.resolve(__dirname, "./src"),
}, },
}, },
server: { server: {
port: 5173, port: 5173,
proxy: { proxy: {
'/api': { "/api": {
target: 'http://localhost:3001', target: backend,
changeOrigin: true,
},
'/auth': {
target: 'http://localhost:3001',
changeOrigin: true, changeOrigin: true,
}, },
}, },