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 path from 'path';
import dotenv from "dotenv";
import path from "path";
dotenv.config({ path: path.resolve(__dirname, '../../.env') });
dotenv.config({ path: path.resolve(__dirname, "../../.env") });
export const config = {
port: parseInt(process.env.PORT || '3001', 10),
nodeEnv: process.env.NODE_ENV || 'development',
port: parseInt(process.env.PORT || "3001", 10),
nodeEnv: process.env.NODE_ENV || "development",
database: {
url: process.env.DATABASE_URL || '',
url: process.env.DATABASE_URL || "",
},
oidc: {
issuerUrl: process.env.OIDC_ISSUER_URL || '',
clientId: process.env.OIDC_CLIENT_ID || '',
redirectUri: process.env.OIDC_REDIRECT_URI || 'http://localhost:3001/auth/callback',
issuerUrl: process.env.OIDC_ISSUER_URL || "",
clientId: process.env.OIDC_CLIENT_ID || "",
redirectUri:
process.env.OIDC_REDIRECT_URI ||
"http://localhost:3001/api/auth/callback",
},
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
},
cors: {
origin: process.env.FRONTEND_URL || 'http://localhost:5173',
origin: process.env.FRONTEND_URL || "http://localhost:5173",
credentials: true,
},
};
export function validateConfig(): void {
const required = [
'DATABASE_URL',
'OIDC_ISSUER_URL',
'OIDC_CLIENT_ID',
];
const required = ["DATABASE_URL", "OIDC_ISSUER_URL", "OIDC_CLIENT_ID"];
for (const key of required) {
if (!process.env[key]) {
@@ -42,6 +40,8 @@ export function validateConfig(): void {
}
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 cors from 'cors';
import session from 'express-session';
import { config, validateConfig } from './config';
import { connectDatabase } from './prisma/client';
import { errorHandler, notFoundHandler } from './middleware/errorHandler';
import express from "express";
import cors from "cors";
import session from "express-session";
import { config, validateConfig } from "./config";
import { connectDatabase } from "./prisma/client";
import { errorHandler, notFoundHandler } from "./middleware/errorHandler";
// Import routes
import authRoutes from './routes/auth.routes';
import clientRoutes from './routes/client.routes';
import projectRoutes from './routes/project.routes';
import timeEntryRoutes from './routes/timeEntry.routes';
import timerRoutes from './routes/timer.routes';
import authRoutes from "./routes/auth.routes";
import clientRoutes from "./routes/client.routes";
import projectRoutes from "./routes/project.routes";
import timeEntryRoutes from "./routes/timeEntry.routes";
import timerRoutes from "./routes/timer.routes";
async function main() {
// Validate configuration
@@ -22,40 +22,44 @@ async function main() {
const app = express();
// CORS
app.use(cors({
app.use(
cors({
origin: config.cors.origin,
credentials: true,
}));
}),
);
// Body parsing
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Session
app.use(session({
app.use(
session({
secret: config.session.secret,
resave: false,
saveUninitialized: false,
name: 'sessionId',
name: "sessionId",
cookie: {
secure: config.nodeEnv === 'production',
httpOnly: true,
secure: false,
httpOnly: false,
maxAge: config.session.maxAge,
sameSite: config.nodeEnv === 'production' ? 'strict' : 'lax',
sameSite: "lax",
},
}));
}),
);
// Health check
app.get('/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
app.get("/health", (_req, res) => {
res.json({ status: "ok", timestamp: new Date().toISOString() });
});
// Routes
app.use('/auth', authRoutes);
app.use('/api/clients', clientRoutes);
app.use('/api/projects', projectRoutes);
app.use('/api/time-entries', timeEntryRoutes);
app.use('/api/timer', timerRoutes);
app.use("/api/auth", authRoutes);
app.use("/api/clients", clientRoutes);
app.use("/api/projects", projectRoutes);
app.use("/api/time-entries", timeEntryRoutes);
app.use("/api/timer", timerRoutes);
// Error handling
app.use(notFoundHandler);
@@ -69,6 +73,6 @@ async function main() {
}
main().catch((error) => {
console.error('Failed to start server:', error);
console.error("Failed to start server:", error);
process.exit(1);
});

View File

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

View File

@@ -12,7 +12,7 @@ services:
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U timetracker"]
test: [ "CMD-SHELL", "pg_isready -U timetracker" ]
interval: 5s
timeout: 5s
retries: 5
@@ -25,7 +25,7 @@ services:
DATABASE_URL: "postgresql://timetracker:timetracker_password@db:5432/timetracker"
OIDC_ISSUER_URL: ${OIDC_ISSUER_URL}
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}
PORT: 3001
NODE_ENV: production

View File

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

View File

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