Files
alpenwerk-hr/proxy.ts
Maximilian Stubhan 901c5c426e Consolidation pass: HR-only access, effective-dated mutations, data integrity guards, test suite
Reworks the app from a two-role (hr_admin/manager) model to a single
HR-only role gated by profiles.is_active, fixes transfer/promote/karenz/
reorg RPCs to actually defer future-dated changes via a new
pending_org_changes table instead of writing them immediately (applied
by a daily Vercel Cron route), makes reorg undo append-only instead of
deleting history, adds Karenz-return and history-date integrity guards,
deprecates the salary column, and adds explicit schema grants + perf
indexes needed to run against a fresh (non-hosted) Postgres instance.

Adds vitest unit + integration test suites (the latter against a real
local Supabase instance) covering all of the above, plus lint/typecheck/
build wiring (`npm run check`).
2026-07-14 20:32:20 +02:00

71 lines
2.5 KiB
TypeScript

import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
// Next.js 16 renamed Middleware to Proxy (same mechanism, new filename/export).
// This is the app's single entry-point gate (spec
// §2.2): unauthenticated users are sent to /login, and — this is the part
// that used to be missing — authenticated users who are NOT an active,
// explicitly-provisioned HR user are sent to /login too, with an error
// message, instead of being let through. Previously this only checked for
// a Supabase Auth session at all, which meant any signed-in user (even one
// with no profile row, or the old "manager" role) could open the app.
// This UI-layer gate is defense in depth, not the real boundary — every
// table is independently RLS-gated on is_hr_user() regardless of what this
// proxy does.
export async function proxy(request: NextRequest) {
let response = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value));
response = NextResponse.next({ request });
cookiesToSet.forEach(({ name, value, options }) => response.cookies.set(name, value, options));
},
},
}
);
const {
data: { user },
} = await supabase.auth.getUser();
const isLoginRoute = request.nextUrl.pathname.startsWith("/login");
if (!user) {
if (isLoginRoute) return response;
const url = request.nextUrl.clone();
url.pathname = "/login";
return NextResponse.redirect(url);
}
const { data: profile } = await supabase.from("profiles").select("role, is_active").eq("id", user.id).maybeSingle();
const isActiveHr = profile?.role === "hr" && profile?.is_active === true;
if (!isActiveHr) {
if (isLoginRoute) return response;
const url = request.nextUrl.clone();
url.pathname = "/login";
url.searchParams.set("error", "Kein HR-Zugriff. Bitte wenden Sie sich an eine:n bestehende:n HR-Benutzer:in.");
return NextResponse.redirect(url);
}
if (isLoginRoute) {
const url = request.nextUrl.clone();
url.pathname = "/";
return NextResponse.redirect(url);
}
return response;
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};