diff --git a/backend/prisma/migrations/20260216150447_init/migration.sql b/backend/prisma/migrations/20260216150447_init/migration.sql new file mode 100644 index 0000000..09c2bf7 --- /dev/null +++ b/backend/prisma/migrations/20260216150447_init/migration.sql @@ -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; diff --git a/backend/prisma/migrations/migration_lock.toml b/backend/prisma/migrations/migration_lock.toml index 648c57f..044d57c 100644 --- a/backend/prisma/migrations/migration_lock.toml +++ b/backend/prisma/migrations/migration_lock.toml @@ -1,3 +1,3 @@ # Please do not edit this file manually # It should be added in your version-control system (e.g., Git) -provider = "postgresql" \ No newline at end of file +provider = "postgresql" diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index f369bfe..af1df43 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -1,47 +1,47 @@ -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]) { throw new Error(`Missing required environment variable: ${key}`); } } - + 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", + ); } -} \ No newline at end of file +} diff --git a/backend/src/index.ts b/backend/src/index.ts index b9e24ef..28751a3 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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({ - origin: config.cors.origin, - credentials: true, - })); + 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({ - secret: config.session.secret, - resave: false, - saveUninitialized: false, - name: 'sessionId', - cookie: { - secure: config.nodeEnv === 'production', - httpOnly: true, - maxAge: config.session.maxAge, - sameSite: config.nodeEnv === 'production' ? 'strict' : 'lax', - }, - })); + app.use( + session({ + secret: config.session.secret, + resave: false, + saveUninitialized: false, + name: "sessionId", + cookie: { + secure: false, + httpOnly: false, + maxAge: config.session.maxAge, + 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); -}); \ No newline at end of file +}); diff --git a/backend/src/routes/auth.routes.ts b/backend/src/routes/auth.routes.ts index bf44e42..64b6f02 100644 --- a/backend/src/routes/auth.routes.ts +++ b/backend/src/routes/auth.routes.ts @@ -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,71 +22,74 @@ 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(); - + const session = createAuthSession(); req.session.oidc = session; - + 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, oidcSession); + const tokenSet = await handleCallback( + req.query as Record, + oidcSession, + ); const user = await getUserInfo(tokenSet); - + // Sync user with database await syncUser(user); - + // Store user in session req.session.user = user; 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); }); -export default router; \ No newline at end of file +export default router; diff --git a/docker-compose.yml b/docker-compose.yml index 4ac0e85..f398a1f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 @@ -48,4 +48,4 @@ services: - backend volumes: - postgres_data: \ No newline at end of file + postgres_data: diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index b2d743e..26161d0 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -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 => { @@ -22,4 +22,4 @@ export const authApi = { return null; } }, -}; \ No newline at end of file +}; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 00cef79..fd8dd49 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,25 +1,23 @@ -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, }, }, }, -}); \ No newline at end of file +});