Features: - Auth mit JWT + Argon2 (Login, Register, Refresh) - Rollen-System (Chef/Disponent/Mitarbeiter) - User Management mit Berechtigungen - Aufträge mit Zuweisungen - Verfügbarkeitsplanung - Stundenzettel mit Foto-Upload Support - Modulares System mit Config - Entwickler-Panel Endpoints Tech: - Deno + Oak - PostgreSQL - CORS enabled
240 lines
6.5 KiB
TypeScript
240 lines
6.5 KiB
TypeScript
import { Router } from "@oak/oak";
|
|
import { query, queryOne, execute } from "../db/postgres.ts";
|
|
import {
|
|
hashPassword,
|
|
verifyPassword,
|
|
generateAccessToken,
|
|
generateRefreshToken,
|
|
generateRandomToken,
|
|
verifyToken
|
|
} from "../utils/auth.ts";
|
|
import { AppError } from "../middleware/error.ts";
|
|
import { authMiddleware } from "../middleware/auth.ts";
|
|
import type { User } from "../types/index.ts";
|
|
|
|
export const authRouter = new Router({ prefix: "/api/auth" });
|
|
|
|
// Register (first user becomes chef, or must be created by higher role)
|
|
authRouter.post("/register", async (ctx) => {
|
|
const body = await ctx.request.body.json();
|
|
const { email, password, first_name, last_name, phone, org_slug } = body;
|
|
|
|
if (!email || !password || !first_name || !last_name || !org_slug) {
|
|
throw new AppError("Missing required fields", 400);
|
|
}
|
|
|
|
// Find organization
|
|
const org = await queryOne<{ id: string }>(
|
|
"SELECT id FROM organizations WHERE slug = $1",
|
|
[org_slug]
|
|
);
|
|
|
|
if (!org) {
|
|
throw new AppError("Organization not found", 404);
|
|
}
|
|
|
|
// Check if email already exists
|
|
const existing = await queryOne<{ id: string }>(
|
|
"SELECT id FROM users WHERE org_id = $1 AND email = $2",
|
|
[org.id, email]
|
|
);
|
|
|
|
if (existing) {
|
|
throw new AppError("Email already registered", 409);
|
|
}
|
|
|
|
// Check if this is the first user (becomes chef)
|
|
const userCount = await queryOne<{ count: string }>(
|
|
"SELECT COUNT(*) as count FROM users WHERE org_id = $1",
|
|
[org.id]
|
|
);
|
|
|
|
const isFirstUser = parseInt(userCount?.count || "0") === 0;
|
|
const role = isFirstUser ? "chef" : "mitarbeiter";
|
|
|
|
// Hash password and create user
|
|
const passwordHash = await hashPassword(password);
|
|
|
|
const result = await queryOne<{ id: string }>(
|
|
`INSERT INTO users (org_id, email, password_hash, role, first_name, last_name, phone, active)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, true)
|
|
RETURNING id`,
|
|
[org.id, email, passwordHash, role, first_name, last_name, phone || null]
|
|
);
|
|
|
|
ctx.response.status = 201;
|
|
ctx.response.body = {
|
|
message: "Registration successful",
|
|
userId: result?.id,
|
|
role,
|
|
isFirstUser
|
|
};
|
|
});
|
|
|
|
// Login
|
|
authRouter.post("/login", async (ctx) => {
|
|
const body = await ctx.request.body.json();
|
|
const { email, password, org_slug } = body;
|
|
|
|
if (!email || !password || !org_slug) {
|
|
throw new AppError("Email, password and organization required", 400);
|
|
}
|
|
|
|
// Find user with org
|
|
const user = await queryOne<User & { org_id: string }>(
|
|
`SELECT u.*, o.slug as org_slug
|
|
FROM users u
|
|
JOIN organizations o ON u.org_id = o.id
|
|
WHERE u.email = $1 AND o.slug = $2 AND u.active = true`,
|
|
[email, org_slug]
|
|
);
|
|
|
|
if (!user) {
|
|
throw new AppError("Invalid credentials", 401);
|
|
}
|
|
|
|
// Verify password
|
|
const valid = await verifyPassword(password, user.password_hash || "");
|
|
if (!valid) {
|
|
throw new AppError("Invalid credentials", 401);
|
|
}
|
|
|
|
// Generate tokens
|
|
const accessToken = await generateAccessToken(user.id, user.org_id, user.role, user.email);
|
|
const refreshToken = await generateRefreshToken(user.id);
|
|
|
|
// Store refresh token hash
|
|
const tokenHash = await hashPassword(refreshToken);
|
|
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
|
|
|
await execute(
|
|
`INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)`,
|
|
[user.id, tokenHash, expiresAt]
|
|
);
|
|
|
|
// Update last login
|
|
await execute(
|
|
`UPDATE users SET last_login = NOW() WHERE id = $1`,
|
|
[user.id]
|
|
);
|
|
|
|
ctx.response.body = {
|
|
accessToken,
|
|
refreshToken,
|
|
user: {
|
|
id: user.id,
|
|
email: user.email,
|
|
role: user.role,
|
|
first_name: user.first_name,
|
|
last_name: user.last_name,
|
|
},
|
|
};
|
|
});
|
|
|
|
// Refresh token
|
|
authRouter.post("/refresh", async (ctx) => {
|
|
const body = await ctx.request.body.json();
|
|
const { refreshToken } = body;
|
|
|
|
if (!refreshToken) {
|
|
throw new AppError("Refresh token required", 400);
|
|
}
|
|
|
|
// Verify JWT structure
|
|
const payload = await verifyToken(refreshToken);
|
|
if (!payload || (payload as unknown as { type?: string }).type !== "refresh") {
|
|
throw new AppError("Invalid refresh token", 401);
|
|
}
|
|
|
|
// Get user
|
|
const user = await queryOne<User>(
|
|
`SELECT * FROM users WHERE id = $1 AND active = true`,
|
|
[payload.sub]
|
|
);
|
|
|
|
if (!user) {
|
|
throw new AppError("User not found", 404);
|
|
}
|
|
|
|
// Generate new access token
|
|
const accessToken = await generateAccessToken(user.id, user.org_id, user.role, user.email);
|
|
|
|
ctx.response.body = { accessToken };
|
|
});
|
|
|
|
// Logout (invalidate refresh tokens)
|
|
authRouter.post("/logout", authMiddleware, async (ctx) => {
|
|
const userId = ctx.state.auth.user.id;
|
|
|
|
await execute(
|
|
`DELETE FROM refresh_tokens WHERE user_id = $1`,
|
|
[userId]
|
|
);
|
|
|
|
ctx.response.body = { message: "Logged out successfully" };
|
|
});
|
|
|
|
// Get current user
|
|
authRouter.get("/me", authMiddleware, async (ctx) => {
|
|
const userId = ctx.state.auth.user.id;
|
|
|
|
const user = await queryOne<User>(
|
|
`SELECT id, org_id, email, role, first_name, last_name, phone, avatar_url,
|
|
created_at, last_login
|
|
FROM users WHERE id = $1`,
|
|
[userId]
|
|
);
|
|
|
|
if (!user) {
|
|
throw new AppError("User not found", 404);
|
|
}
|
|
|
|
ctx.response.body = { user };
|
|
});
|
|
|
|
// Change password
|
|
authRouter.post("/change-password", authMiddleware, async (ctx) => {
|
|
const userId = ctx.state.auth.user.id;
|
|
const body = await ctx.request.body.json();
|
|
const { currentPassword, newPassword } = body;
|
|
|
|
if (!currentPassword || !newPassword) {
|
|
throw new AppError("Current and new password required", 400);
|
|
}
|
|
|
|
if (newPassword.length < 8) {
|
|
throw new AppError("Password must be at least 8 characters", 400);
|
|
}
|
|
|
|
// Get current password hash
|
|
const user = await queryOne<{ password_hash: string }>(
|
|
`SELECT password_hash FROM users WHERE id = $1`,
|
|
[userId]
|
|
);
|
|
|
|
if (!user) {
|
|
throw new AppError("User not found", 404);
|
|
}
|
|
|
|
// Verify current password
|
|
const valid = await verifyPassword(currentPassword, user.password_hash);
|
|
if (!valid) {
|
|
throw new AppError("Current password is incorrect", 401);
|
|
}
|
|
|
|
// Hash and update new password
|
|
const newHash = await hashPassword(newPassword);
|
|
await execute(
|
|
`UPDATE users SET password_hash = $1 WHERE id = $2`,
|
|
[newHash, userId]
|
|
);
|
|
|
|
// Invalidate all refresh tokens
|
|
await execute(
|
|
`DELETE FROM refresh_tokens WHERE user_id = $1`,
|
|
[userId]
|
|
);
|
|
|
|
ctx.response.body = { message: "Password changed successfully" };
|
|
});
|