Passwort-Anmeldung: Provider, Formulare, drei Zustaende der Shell
This commit is contained in:
@@ -1,13 +1,22 @@
|
||||
import "server-only";
|
||||
import { NextResponse } from "next/server";
|
||||
import { withUser } from "@/lib/db";
|
||||
import { sql, withUser } from "@/lib/db";
|
||||
import { currentUserId } from "./session";
|
||||
|
||||
// Route Handlers under /api/export/* are outside the App Router layout tree,
|
||||
// so app/(app)/layout.tsx's HR gate never runs for them — each one has to
|
||||
// re-establish that the caller is an active HR user itself. RLS is still the
|
||||
// real boundary (an unauthorized session simply reads nothing); this exists
|
||||
// so those routes answer 401/403 instead of handing back an empty workbook.
|
||||
// Route Handlers under /api/export/* and /api/import are outside the App
|
||||
// Router layout tree, so app/(app)/layout.tsx's HR gate never runs for them —
|
||||
// each one has to re-establish that the caller is an active HR user itself.
|
||||
// RLS is still the real boundary (an unauthorized session simply reads
|
||||
// nothing); this exists so those routes answer 401/403 instead of handing back
|
||||
// an empty workbook.
|
||||
//
|
||||
// Gefragt wird is_hr_user() und nicht mehr profiles.role/is_active von Hand.
|
||||
// Der Unterschied ist nicht kosmetisch: is_hr_user() ist dieselbe Funktion, die
|
||||
// alle 25 RLS-Policies aufrufen. Was immer sie künftig zusätzlich prüft, gilt
|
||||
// hier automatisch mit — beim ausstehenden Passwortwechsel ist genau das schon
|
||||
// passiert (Migration 20260908120000). Die Handfassung hätte davon nichts
|
||||
// gewusst und einen Export ausgeliefert, den die Policies darunter leer
|
||||
// gelassen hätten.
|
||||
|
||||
export type HrGate = { denied: NextResponse } | { userId: string };
|
||||
|
||||
@@ -15,12 +24,11 @@ export async function requireHrUser(): Promise<HrGate> {
|
||||
const userId = await currentUserId();
|
||||
if (!userId) return { denied: NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }) };
|
||||
|
||||
const profile = await withUser(userId, (tx) =>
|
||||
tx.selectFrom("profiles").select(["role", "is_active"]).where("id", "=", userId).executeTakeFirst()
|
||||
);
|
||||
const erlaubt = await withUser(userId, async (tx) => {
|
||||
const ergebnis = await sql<{ ok: boolean }>`select is_hr_user() as ok`.execute(tx);
|
||||
return ergebnis.rows[0]?.ok === true;
|
||||
});
|
||||
|
||||
if (profile?.role !== "hr" || profile.is_active !== true) {
|
||||
return { denied: NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 }) };
|
||||
}
|
||||
if (!erlaubt) return { denied: NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 }) };
|
||||
return { userId };
|
||||
}
|
||||
|
||||
46
lib/passwort.ts
Normal file
46
lib/passwort.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
// Die Passwortregeln — noch einmal, für die Oberfläche.
|
||||
//
|
||||
// Verbindlich ist app_passwort_regeln() in der Datenbank
|
||||
// (20260908120000). Hier stehen dieselben Regeln ein zweites Mal, damit die
|
||||
// Maske sie beim Tippen anzeigen kann statt erst nach dem Absenden. Dieselbe
|
||||
// Doppelung wie in lib/history.ts, aus demselben Grund und mit derselben
|
||||
// Rangfolge: läuft eine Seite der anderen davon, gewinnt die Datenbank — sie
|
||||
// weist ab, und die Meldung von dort wird angezeigt.
|
||||
|
||||
/** Was in der Maske als Hinweis steht. Reihenfolge wie in der SQL-Funktion. */
|
||||
export const PASSWORT_REGELN = [
|
||||
"mindestens 12 Zeichen",
|
||||
"mindestens ein Grossbuchstabe",
|
||||
"mindestens ein Kleinbuchstabe",
|
||||
"mindestens eine Ziffer",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Liefert die Beanstandung im Klartext oder null.
|
||||
*
|
||||
* Wortgleich mit app_passwort_regeln(). Ein Unterschied bleibt und ist
|
||||
* unvermeidbar: `length()` in PostgreSQL zählt Zeichen, `String.length` in
|
||||
* JavaScript zählt UTF-16-Einheiten. Bei einem Emoji im Passwort weichen die
|
||||
* beiden um eins ab. Das betrifft nur die Vorabanzeige — abgewiesen oder
|
||||
* angenommen wird in der Datenbank.
|
||||
*/
|
||||
export function passwortBeanstandung(passwort: string): string | null {
|
||||
if (passwort.length < 12) return "Das Passwort muss mindestens 12 Zeichen lang sein.";
|
||||
if (passwort.length > 72) return "Das Passwort darf höchstens 72 Zeichen lang sein.";
|
||||
if (!/\p{Lu}/u.test(passwort)) return "Das Passwort muss mindestens einen Grossbuchstaben enthalten.";
|
||||
if (!/\p{Ll}/u.test(passwort)) return "Das Passwort muss mindestens einen Kleinbuchstaben enthalten.";
|
||||
if (!/\p{Nd}/u.test(passwort)) return "Das Passwort muss mindestens eine Ziffer enthalten.";
|
||||
return null;
|
||||
}
|
||||
|
||||
// Das gemeinsame Initialpasswort steht **nicht** in dieser Datei.
|
||||
//
|
||||
// Sie wird von den Anmelde- und Wechselformularen eingebunden, und die laufen
|
||||
// im Browser. Jede Konstante hier landete damit im ausgelieferten Bündel — und
|
||||
// das Initialpasswort wäre für jede Besucherin der offenen Anmeldeseite
|
||||
// lesbar, ohne dass sie auch nur eine E-Mail-Adresse kennen müsste. Heute
|
||||
// braucht ein Fremder beides.
|
||||
//
|
||||
// Es liegt deshalb in actions/benutzer.ts, also in einem Modul mit
|
||||
// "use server", das nie in den Browser gelangt. Dort wird es auch nur
|
||||
// gebraucht: beim Anlegen und beim Zurücksetzen.
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sql } from "kysely";
|
||||
import type { Tx } from "./db";
|
||||
import { jsonArrayFrom, jsonObjectFrom, zeitstempel } from "./db/json";
|
||||
import { todayIso } from "./format";
|
||||
@@ -30,14 +31,27 @@ export type ShellData = {
|
||||
};
|
||||
|
||||
/**
|
||||
* `null` heisst: angemeldet, aber nicht als HR freigeschaltet.
|
||||
* Drei Ausgänge, nicht zwei.
|
||||
*
|
||||
* Seit es die Anmeldung mit Passwort gibt, ist „darf nicht hinein" nicht mehr
|
||||
* dasselbe wie „hat keinen Zugang": wer sein Startpasswort noch nicht
|
||||
* gewechselt hat, hat einen Zugang und kommt trotzdem an keine Zeile, weil
|
||||
* is_hr_user() das mitprüft (Migration 20260908120000). Unterschieden werden
|
||||
* muss es, weil die beiden Fälle verschiedene Auswege haben — der eine wartet
|
||||
* auf HR, der andere ist in einer Minute erledigt.
|
||||
*/
|
||||
export type ShellErgebnis =
|
||||
| { status: "kein_zugang" }
|
||||
| { status: "passwort_wechseln" }
|
||||
| { status: "ok"; daten: ShellData };
|
||||
|
||||
/**
|
||||
* Die Zugangsprüfung fragt gleichzeitig mit dem Rest statt davor. Das liest
|
||||
* ein paar Zeilen mehr, als eine gesperrte Person sehen dürfte, wirft sie aber
|
||||
* weg, ohne sie je auszuliefern — und die eigentliche Grenze ist ohnehin RLS,
|
||||
* nicht die Reihenfolge hier.
|
||||
*/
|
||||
export async function loadShellData(tx: Tx, userId: string): Promise<ShellData | null> {
|
||||
export async function loadShellData(tx: Tx, userId: string): Promise<ShellErgebnis> {
|
||||
const asOf = todayIso();
|
||||
|
||||
const gelesen = await tx
|
||||
@@ -45,6 +59,10 @@ export async function loadShellData(tx: Tx, userId: string): Promise<ShellData |
|
||||
jsonObjectFrom(
|
||||
eb.selectFrom("profiles").select(["full_name", "email", "role", "is_active"]).where("id", "=", userId)
|
||||
).as("profile"),
|
||||
// Eine Spalte mehr in derselben Abfrage, keine zusätzliche Rundreise.
|
||||
// Direkt aus app_passwoerter zu lesen ginge nicht: die Tabelle ist für
|
||||
// die Anwendungsrolle gesperrt, an sie kommt nur diese Funktion.
|
||||
sql<boolean>`app_muss_passwort_wechseln()`.as("passwortWechseln"),
|
||||
...orgMapsAbfragen(eb),
|
||||
jsonArrayFrom(offeneStellenAbfrage(eb, asOf)).as("open"),
|
||||
jsonArrayFrom(offeneNotizenAbfrage(eb)).as("notes"),
|
||||
@@ -60,17 +78,28 @@ export async function loadShellData(tx: Tx, userId: string): Promise<ShellData |
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const profile = gelesen.profile;
|
||||
if (profile?.role !== "hr" || profile?.is_active !== true) return null;
|
||||
|
||||
// Der Passwortwechsel steht **vor** der HR-Prüfung, obwohl er der seltenere
|
||||
// Fall ist: er ist der einzige, den die betroffene Person selbst beheben
|
||||
// kann. Wer beides hat — offener Wechsel und keine Freischaltung — bekommt
|
||||
// danach immer noch „Kein HR-Zugriff", aber wenigstens in dieser Reihenfolge
|
||||
// und nicht als Sackgasse.
|
||||
if (gelesen.passwortWechseln) return { status: "passwort_wechseln" };
|
||||
|
||||
if (profile?.role !== "hr" || profile?.is_active !== true) return { status: "kein_zugang" };
|
||||
|
||||
const orgMaps = buildOrgMaps(gelesen.units as never, gelesen.locations as never);
|
||||
|
||||
return {
|
||||
profile,
|
||||
// Die zweite Rundreise: was sie fragt, hängt davon ab, welche Stellen
|
||||
// offen sind — das lässt sich nicht in die erste ziehen.
|
||||
openPositions: await resolveOpenPositions(tx, orgMaps, gelesen.open as OffeneStelle[], asOf),
|
||||
locations: gelesen.locations as Location[],
|
||||
drafts: gelesen.drafts as ShellData["drafts"],
|
||||
openNotes: baueOffeneNotizen(gelesen.notes as NotizZeile[]),
|
||||
status: "ok",
|
||||
daten: {
|
||||
profile,
|
||||
// Die zweite Rundreise: was sie fragt, hängt davon ab, welche Stellen
|
||||
// offen sind — das lässt sich nicht in die erste ziehen.
|
||||
openPositions: await resolveOpenPositions(tx, orgMaps, gelesen.open as OffeneStelle[], asOf),
|
||||
locations: gelesen.locations as Location[],
|
||||
drafts: gelesen.drafts as ShellData["drafts"],
|
||||
openNotes: baueOffeneNotizen(gelesen.notes as NotizZeile[]),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -592,6 +592,10 @@ export type Database = {
|
||||
delete_employee_dependent: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
add_employee_note: { Args: { payload: Record<string, unknown> }; Returns: string };
|
||||
complete_employee_note: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
// Eigenes Passwort wechseln. Die einzige mutierende Funktion **ohne**
|
||||
// require_hr_admin() — sonst käme niemand aus dem erzwungenen Wechsel
|
||||
// heraus, denn solange er aussteht, ist is_hr_user() false.
|
||||
app_passwort_aendern: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
// Nimmt eine irrtümliche Stammdaten-/Vertragsänderung zurück. Der einzige
|
||||
// Weg an der fehlenden delete-Policy auf employee_history vorbei.
|
||||
delete_history_entry: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
|
||||
Reference in New Issue
Block a user