Merge branch 'phase/012-014' (Schluessel, Personen, Private Key)

# Conflicts:
#	src/components/vorgang/useVorgang.ts
#	src/worker/handler.ts
This commit is contained in:
2026-10-12 10:54:09 +02:00
76 changed files with 5899 additions and 325 deletions
+103
View File
@@ -0,0 +1,103 @@
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
/**
* Lesemodelle fuer Personen (#013). Jede angemeldete Rolle darf sie sehen; Schluessel nur mit
* festem `select` ohne Chiffrat-Felder.
*/
export type PersonFilter = { readonly q?: string; readonly archivierte?: boolean };
export async function personenListe(filter: PersonFilter = {}) {
const q = filter.q?.trim().slice(0, 100);
const where: Prisma.PersonWhereInput = {
...(filter.archivierte ? {} : { archiviertAm: null }),
...(q
? {
OR: [
{ name: { contains: q, mode: "insensitive" } },
{ email: { contains: q, mode: "insensitive" } },
{ organisation: { contains: q, mode: "insensitive" } },
],
}
: {}),
};
const personen = await prisma.person.findMany({
where,
orderBy: { name: "asc" },
select: {
id: true,
name: true,
email: true,
organisation: true,
archiviertAm: true,
benutzer: { select: { id: true, name: true } },
_count: { select: { schluessel: { where: { status: "AKTIV" } } } },
},
});
return personen.map(({ _count, ...rest }) => ({ ...rest, aktiveSchluessel: _count.schluessel }));
}
export type PersonListenEintrag = Awaited<ReturnType<typeof personenListe>>[number];
export async function personDetail(id: string) {
const person = await prisma.person.findUnique({
where: { id },
select: {
id: true,
name: true,
email: true,
organisation: true,
notiz: true,
archiviertAm: true,
angelegtAm: true,
benutzerId: true,
benutzer: { select: { id: true, name: true, email: true, rolle: true } },
schluessel: {
orderBy: [{ status: "asc" }, { name: "asc" }],
select: {
id: true,
name: true,
typ: true,
kategorie: true,
status: true,
publicKey: true,
fingerprint: true,
ablaufAm: true,
selbstHinterlegt: true,
_count: {
select: {
publicVerwendungen: { where: { zustand: { not: "ENTFERNT" } } },
privateVerwendungen: { where: { zustand: { not: "ENTFERNT" } } },
},
},
},
},
},
});
if (!person) return null;
return {
...person,
schluessel: person.schluessel.map(({ _count, ...rest }) => ({
...rest,
verwendungen: _count.publicVerwendungen + _count.privateVerwendungen,
})),
};
}
export type PersonDetail = NonNullable<Awaited<ReturnType<typeof personDetail>>>;
/**
* Konten, die sich mit einer Person verknuepfen lassen: noch keiner Person zugeordnet, oder
* der Person `personId` selbst. Der Notfall-Admin ist kein Mensch mit eigenen Keys.
*/
export async function kontenAuswahl(personId?: string) {
return prisma.benutzer.findMany({
where: {
istNotfall: false,
OR: [{ person: null }, ...(personId ? [{ person: { id: personId } }] : [])],
},
orderBy: { name: "asc" },
select: { id: true, name: true, email: true },
});
}
+168
View File
@@ -0,0 +1,168 @@
import type { Prisma } from "@/generated/prisma/client";
import type { SchluesselKategorie, SchluesselStatus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
/**
* Lesemodelle fuer Schluessel (#012). Alles hier darf jede angemeldete Rolle sehen.
*
* Feste `select`-Listen ohne Chiffrat-Felder (CLAUDE.md, „Feste Regeln“). Ob ein Private Key
* gespeichert ist, verraet `masterKeyVersion`: der DB-CHECK `schluessel_umschlag_vollstaendig`
* setzt sie genau dann, wenn auch ein Chiffrat da ist – so muss das Chiffrat nie gelesen
* werden, nicht einmal fuer ein „ist null?“.
*/
export const SCHLUESSEL_LISTE_SELECT = {
id: true,
name: true,
slug: true,
typ: true,
kategorie: true,
status: true,
publicKey: true,
fingerprint: true,
ablaufAm: true,
masterKeyVersion: true,
passphraseGeschuetzt: true,
person: { select: { id: true, name: true } },
_count: {
select: {
publicVerwendungen: { where: { zustand: { not: "ENTFERNT" } } },
privateVerwendungen: { where: { zustand: { not: "ENTFERNT" } } },
},
},
} satisfies Prisma.SchluesselSelect;
export const SCHLUESSEL_DETAIL_SELECT = {
id: true,
name: true,
slug: true,
typ: true,
kategorie: true,
status: true,
publicKey: true,
fingerprint: true,
kommentar: true,
masterKeyVersion: true,
privatGeloeschtAm: true,
passphraseGeschuetzt: true,
instanzStandard: true,
selbstHinterlegt: true,
tags: true,
ablaufAm: true,
zuletztVerwendetAm: true,
angelegtAm: true,
personId: true,
vorlageId: true,
person: { select: { id: true, name: true } },
vorlage: { select: { id: true, name: true } },
erstelltVon: { select: { name: true } },
publicVerwendungen: {
where: { zustand: { not: "ENTFERNT" } },
orderBy: { angelegtAm: "asc" },
select: {
id: true,
zielbenutzer: true,
optionen: true,
zustand: true,
zuletztAngewendetAm: true,
host: { select: { id: true, name: true } },
hostgruppe: { select: { id: true, name: true } },
},
},
privateVerwendungen: {
where: { zustand: { not: "ENTFERNT" } },
orderBy: { angelegtAm: "asc" },
select: {
id: true,
zielbenutzer: true,
pfad: true,
sshConfig: true,
zustand: true,
zuletztAngewendetAm: true,
host: { select: { id: true, name: true } },
},
},
} satisfies Prisma.SchluesselSelect;
export type SchluesselFilter = {
readonly q?: string;
readonly kategorie?: string;
readonly status?: string;
};
const KATEGORIEN: readonly SchluesselKategorie[] = ["PERSON", "DIENST", "DEPLOYMENT"];
const STATI: readonly SchluesselStatus[] = ["AKTIV", "ROTIERT", "WIDERRUFEN", "ABGELAUFEN"];
function alsKategorie(wert: string | undefined): SchluesselKategorie | undefined {
return KATEGORIEN.find((k) => k === wert);
}
function alsStatus(wert: string | undefined): SchluesselStatus | undefined {
return STATI.find((s) => s === wert);
}
export async function schluesselListe(filter: SchluesselFilter = {}) {
const q = filter.q?.trim().slice(0, 100);
const kategorie = alsKategorie(filter.kategorie);
const status = alsStatus(filter.status);
const where: Prisma.SchluesselWhereInput = {
...(kategorie ? { kategorie } : {}),
...(status ? { status } : {}),
...(q
? {
OR: [
{ name: { contains: q, mode: "insensitive" } },
{ slug: { contains: q.toLowerCase() } },
{ fingerprint: { contains: q } },
{ kommentar: { contains: q, mode: "insensitive" } },
{ tags: { has: q.toLowerCase() } },
{ person: { name: { contains: q, mode: "insensitive" } } },
],
}
: {}),
};
const zeilen = await prisma.schluessel.findMany({
where,
orderBy: [{ name: "asc" }],
select: SCHLUESSEL_LISTE_SELECT,
});
return zeilen.map(({ _count, masterKeyVersion, ...rest }) => ({
...rest,
privatGespeichert: masterKeyVersion !== null,
verwendungen: _count.publicVerwendungen + _count.privateVerwendungen,
}));
}
export type SchluesselListenEintrag = Awaited<ReturnType<typeof schluesselListe>>[number];
export async function schluesselDetail(id: string) {
const schluessel = await prisma.schluessel.findUnique({ where: { id }, select: SCHLUESSEL_DETAIL_SELECT });
if (!schluessel) return null;
const { masterKeyVersion, ...rest } = schluessel;
return { ...rest, privatGespeichert: masterKeyVersion !== null };
}
export type SchluesselDetail = NonNullable<Awaited<ReturnType<typeof schluesselDetail>>>;
/** Nur Slug und Public Key – fuer den Download `<slug>.pub`. */
export async function publicKeyExport(id: string) {
return prisma.schluessel.findUnique({ where: { id }, select: { slug: true, publicKey: true } });
}
// ---------------------------------------------------------------- Auswahllisten
export async function personenAuswahl() {
return prisma.person.findMany({
where: { archiviertAm: null },
orderBy: { name: "asc" },
select: { id: true, name: true, organisation: true },
});
}
export async function vorlagenAuswahl() {
return prisma.vorlage.findMany({
where: { archiviertAm: null },
orderBy: { name: "asc" },
select: { id: true, name: true, standardLaufzeitTage: true },
});
}