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`).
71 lines
2.5 KiB
TypeScript
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).*)"],
|
|
};
|