Phase B1: Anmeldung, Benutzer, Gruppen und Rollen
Umschaltbar ueber AUTH_ENABLED in der .env. Steht sie auf false, verhaelt
sich alles wie bisher - geprueft mit den 200 bestehenden Pruefungen.
Kern ist eine Actor- und Policy-Schicht: getActor() liefert bei
ausgeschalteter Anmeldung einen system-Actor mit Vollzugriff, sonst den
angemeldeten Benutzer. Dadurch braucht keine einzige Route ein
'if (anmeldungAktiv)' - sie fragen alle nur can() bzw. requirePermission().
- Datenmodell: User, Session, Group, Membership; Codes bekommen Gruppe und
Ersteller. Die Migration legt eine Standardgruppe an und ordnet ihr
bestehende Codes zu, bevor die Spalte verpflichtend wird - sonst
scheiterte sie auf jeder bereits benutzten Installation.
- Passwoerter mit scrypt aus der Node-Standardbibliothek. Bewusst kein
Argon2: das braeuchte ein natives Modul passend zur Container-Plattform.
- Sitzungen in der Datenbank, im Cookie nur ein Zufallstoken; gespeichert
wird dessen SHA-256. Deaktivieren oder ein neues Passwort beendet alle
offenen Sitzungen sofort.
- Aussperrschutz: der letzte aktive Administrator kann sich nicht selbst
entmachten, deaktivieren oder loeschen. Der Schalter liegt in der .env,
nicht in der Oberflaeche - so bleibt der Weg zurueck immer offen.
- Ersteinrichtung legt den ersten Administrator an und schliesst sich danach.
- Die Weiterleitung /r/{slug} bleibt oeffentlich, sonst liesse sich kein
gedruckter Code mehr scannen.
Zwei Fehler dabei gefunden und behoben:
- 'return handleLogin(...)' innerhalb eines try-Blocks gibt das Promise
zurueck, bevor es sich aufloest. Die Ablehnung kam nie im catch an und
wurde zu einem nackten 500er statt einer 401. Jetzt mit await.
- Die Fehlerklassen wurden ueber instanceof erkannt. type-module.ts landet
aber sowohl im Server- als auch im Client-Buendel, es gibt also zwei
Klassenidentitaeten - saubere 400er wurden dadurch still zu 500ern. Die
Erkennung laeuft jetzt ueber eine Eigenschaft.
Geprueft: 51 neue Pruefungen fuer Anmeldung und die vollstaendige
Rollenmatrix, dazu die 200 bestehenden bei ausgeschalteter Anmeldung.
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { LoginForm, SetupForm } from "@/components/auth-forms";
|
||||
import { Icon } from "@/components/icons";
|
||||
import { Notice } from "@/components/ui";
|
||||
import { getActor, isAuthEnabled, isSetupOpen } from "@/lib/auth/actor";
|
||||
import { MIN_PASSWORD_LENGTH } from "@/lib/auth/password";
|
||||
|
||||
export const metadata: Metadata = { title: "Anmelden" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function SignInPage() {
|
||||
// Ohne Anmeldung gibt es hier nichts zu tun.
|
||||
if (!isAuthEnabled()) redirect("/");
|
||||
|
||||
const actor = await getActor();
|
||||
if (actor.kind === "user") redirect("/");
|
||||
|
||||
const setup = await isSetupOpen();
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-md flex-col gap-6 px-4 py-14 sm:px-6">
|
||||
<div className="text-center">
|
||||
<span className="mx-auto flex size-12 items-center justify-center rounded-xl bg-indigo-600 text-white">
|
||||
<Icon name={setup ? "lock" : "qr"} className="size-6" />
|
||||
</span>
|
||||
<h1 className="mt-4 text-2xl font-bold tracking-tight text-slate-900">
|
||||
{setup ? "Ersteinrichtung" : "Anmelden"}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
{setup
|
||||
? "Es gibt noch keinen Benutzer. Lege jetzt den ersten Administrator an — danach ist diese Seite geschlossen."
|
||||
: "Bitte melde dich an, um QR-Codes zu erstellen und zu verwalten."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{setup ? <SetupForm minLength={MIN_PASSWORD_LENGTH} /> : <LoginForm />}
|
||||
|
||||
{setup ? (
|
||||
<Notice tone="info">
|
||||
Der erste Benutzer wird Administrator der Installation und darf
|
||||
Gruppen, Benutzer und die QR-Typen verwalten.
|
||||
</Notice>
|
||||
) : (
|
||||
<p className="text-center text-sm text-slate-500">
|
||||
Passwort vergessen? Ein Administrator kann es unter Verwaltung →
|
||||
Benutzer zurücksetzen.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getActor } from "@/lib/auth/actor";
|
||||
import { DEFAULT_GROUP_ID } from "@/lib/auth/groups";
|
||||
import { requirePermission } from "@/lib/auth/policy";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||
import { QrRequestError } from "@/lib/qr/type-module";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Context = { params: Promise<{ id: string }> };
|
||||
|
||||
const updateSchema = z.object({
|
||||
name: z.string().trim().min(1).max(80).optional(),
|
||||
description: z.string().trim().max(300).nullable().optional(),
|
||||
/**
|
||||
* Vollständige Mitgliederliste. Wer fehlt, verliert die Mitgliedschaft —
|
||||
* so bleibt der Zustand nach dem Speichern eindeutig.
|
||||
*/
|
||||
members: z
|
||||
.array(
|
||||
z.object({
|
||||
userId: z.string().trim().min(1),
|
||||
role: z.enum(["ADMIN", "EDITOR", "VIEWER"], {
|
||||
error: "Ungültige Rolle.",
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
context: Context,
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const actor = await getActor();
|
||||
const { id } = await context.params;
|
||||
|
||||
const group = await prisma.group.findUnique({ where: { id } });
|
||||
if (!group) {
|
||||
throw new QrRequestError("Diese Gruppe existiert nicht (mehr).", 404);
|
||||
}
|
||||
|
||||
const parsed = updateSchema.safeParse(await request.json());
|
||||
if (!parsed.success) {
|
||||
return jsonError(firstZodMessage(parsed.error));
|
||||
}
|
||||
|
||||
// Name und Beschreibung sind Sache der Instanz-Verwaltung, Mitglieder
|
||||
// darf auch ein Administrator der Gruppe selbst pflegen.
|
||||
if (parsed.data.name !== undefined || parsed.data.description !== undefined) {
|
||||
requirePermission(actor, "instance:manage");
|
||||
}
|
||||
if (parsed.data.members !== undefined) {
|
||||
requirePermission(actor, "group:manage", { groupId: id });
|
||||
}
|
||||
|
||||
if (parsed.data.name !== undefined || parsed.data.description !== undefined) {
|
||||
await prisma.group.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(parsed.data.name !== undefined ? { name: parsed.data.name } : {}),
|
||||
...(parsed.data.description !== undefined
|
||||
? { description: parsed.data.description || null }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (parsed.data.members !== undefined) {
|
||||
const members = parsed.data.members;
|
||||
|
||||
const known = await prisma.user.findMany({
|
||||
where: { id: { in: members.map((m) => m.userId) } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (known.length !== members.length) {
|
||||
throw new QrRequestError(
|
||||
"Mindestens einer der ausgewählten Benutzer existiert nicht mehr.",
|
||||
);
|
||||
}
|
||||
|
||||
// In einer Transaktion, damit die Gruppe nie ohne Mitglieder dasteht,
|
||||
// weil das Setzen der neuen Liste fehlgeschlagen ist.
|
||||
await prisma.$transaction([
|
||||
prisma.membership.deleteMany({
|
||||
where: { groupId: id, userId: { notIn: members.map((m) => m.userId) } },
|
||||
}),
|
||||
...members.map((member) =>
|
||||
prisma.membership.upsert({
|
||||
where: {
|
||||
userId_groupId: { userId: member.userId, groupId: id },
|
||||
},
|
||||
create: { userId: member.userId, groupId: id, role: member.role },
|
||||
update: { role: member.role },
|
||||
}),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
return Response.json({ ok: true });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
context: Context,
|
||||
): Promise<Response> {
|
||||
try {
|
||||
requirePermission(await getActor(), "instance:manage");
|
||||
const { id } = await context.params;
|
||||
|
||||
if (id === DEFAULT_GROUP_ID) {
|
||||
throw new QrRequestError(
|
||||
"Die Standardgruppe lässt sich nicht löschen — ohne sie hätten Codes bei ausgeschalteter Anmeldung keine Heimat.",
|
||||
);
|
||||
}
|
||||
|
||||
const group = await prisma.group.findUnique({
|
||||
where: { id },
|
||||
include: { _count: { select: { codes: true } } },
|
||||
});
|
||||
if (!group) {
|
||||
throw new QrRequestError("Diese Gruppe existiert nicht (mehr).", 404);
|
||||
}
|
||||
|
||||
if (group._count.codes > 0) {
|
||||
throw new QrRequestError(
|
||||
`Diese Gruppe enthält noch ${group._count.codes} ${group._count.codes === 1 ? "Code" : "Codes"}. Lösche oder verschiebe sie zuerst — sonst würden gedruckte Codes ins Leere führen.`,
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.group.delete({ where: { id } });
|
||||
return new Response(null, { status: 204 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getActor } from "@/lib/auth/actor";
|
||||
import { can, requirePermission, visibleGroupIds } from "@/lib/auth/policy";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||
import { QrRequestError } from "@/lib/qr/type-module";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const createSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, { error: "Bitte gib einen Namen für die Gruppe ein." })
|
||||
.max(80, { error: "Der Name darf höchstens 80 Zeichen lang sein." }),
|
||||
description: z.string().trim().max(300).optional(),
|
||||
});
|
||||
|
||||
/** Gruppen samt Mitgliedern — gefiltert nach dem, was der Actor sehen darf. */
|
||||
export async function GET(): Promise<Response> {
|
||||
try {
|
||||
const actor = await getActor();
|
||||
if (actor.kind === "anonymous") {
|
||||
throw new QrRequestError("Bitte melde dich an.", 401);
|
||||
}
|
||||
|
||||
const visible = visibleGroupIds(actor);
|
||||
const groups = await prisma.group.findMany({
|
||||
where: visible === null ? undefined : { id: { in: visible } },
|
||||
orderBy: { name: "asc" },
|
||||
include: {
|
||||
memberships: {
|
||||
include: { user: { select: { id: true, email: true, name: true } } },
|
||||
orderBy: { createdAt: "asc" },
|
||||
},
|
||||
_count: { select: { codes: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return Response.json({
|
||||
groups: groups.map((group) => ({
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
description: group.description,
|
||||
codeCount: group._count.codes,
|
||||
canManage: can(actor, "group:manage", { groupId: group.id }),
|
||||
members: group.memberships.map((m) => ({
|
||||
userId: m.user.id,
|
||||
email: m.user.email,
|
||||
name: m.user.name,
|
||||
role: m.role,
|
||||
})),
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest): Promise<Response> {
|
||||
try {
|
||||
requirePermission(await getActor(), "instance:manage");
|
||||
|
||||
const parsed = createSchema.safeParse(await request.json());
|
||||
if (!parsed.success) {
|
||||
return jsonError(firstZodMessage(parsed.error));
|
||||
}
|
||||
|
||||
const existing = await prisma.group.findUnique({
|
||||
where: { name: parsed.data.name },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) {
|
||||
throw new QrRequestError("Eine Gruppe mit diesem Namen gibt es schon.", 409);
|
||||
}
|
||||
|
||||
const group = await prisma.group.create({
|
||||
data: {
|
||||
name: parsed.data.name,
|
||||
description: parsed.data.description || null,
|
||||
},
|
||||
});
|
||||
|
||||
return Response.json({ group: { id: group.id, name: group.name } }, { status: 201 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getActor } from "@/lib/auth/actor";
|
||||
import { requirePermission } from "@/lib/auth/policy";
|
||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||
import {
|
||||
getQrTypeAvailability,
|
||||
@@ -18,6 +20,7 @@ const updateSchema = z.object({
|
||||
/** Zustand aller QR-Typen für die Verwaltung. */
|
||||
export async function GET(): Promise<Response> {
|
||||
try {
|
||||
requirePermission(await getActor(), "instance:manage");
|
||||
const types = await getQrTypeAvailability();
|
||||
return Response.json({
|
||||
types: types.map(({ qrType, implemented, enabled, usable }) => ({
|
||||
@@ -38,6 +41,8 @@ export async function GET(): Promise<Response> {
|
||||
/** Einen Typ freigeben oder abschalten. */
|
||||
export async function PATCH(request: NextRequest): Promise<Response> {
|
||||
try {
|
||||
requirePermission(await getActor(), "instance:manage");
|
||||
|
||||
const parsed = updateSchema.safeParse(await request.json());
|
||||
if (!parsed.success) {
|
||||
return jsonError(firstZodMessage(parsed.error));
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getActor } from "@/lib/auth/actor";
|
||||
import {
|
||||
describePasswordRules,
|
||||
hashPassword,
|
||||
MIN_PASSWORD_LENGTH,
|
||||
} from "@/lib/auth/password";
|
||||
import { requirePermission } from "@/lib/auth/policy";
|
||||
import { destroyAllSessions } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||
import { QrRequestError } from "@/lib/qr/type-module";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Context = { params: Promise<{ id: string }> };
|
||||
|
||||
const updateSchema = z.object({
|
||||
name: z.string().trim().max(120).nullable().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
isInstanceAdmin: z.boolean().optional(),
|
||||
password: z
|
||||
.string()
|
||||
.min(MIN_PASSWORD_LENGTH, {
|
||||
error: `Das Passwort ist zu kurz. ${describePasswordRules()}`,
|
||||
})
|
||||
.optional(),
|
||||
/** Sperre nach Fehlversuchen aufheben. */
|
||||
unlock: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Verhindert, dass sich die Installation selbst aussperrt: es muss immer
|
||||
* mindestens ein aktiver Administrator übrig bleiben.
|
||||
*/
|
||||
async function assertNotLastAdmin(userId: string, action: string) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { isInstanceAdmin: true, isActive: true },
|
||||
});
|
||||
if (!user?.isInstanceAdmin || !user.isActive) return;
|
||||
|
||||
const others = await prisma.user.count({
|
||||
where: { isInstanceAdmin: true, isActive: true, id: { not: userId } },
|
||||
});
|
||||
if (others === 0) {
|
||||
throw new QrRequestError(
|
||||
`Das ist der letzte aktive Administrator — ${action} würde die Installation unbedienbar machen. Lege zuerst einen weiteren Administrator an.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
context: Context,
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const actor = await getActor();
|
||||
requirePermission(actor, "instance:manage");
|
||||
|
||||
const { id } = await context.params;
|
||||
const parsed = updateSchema.safeParse(await request.json());
|
||||
if (!parsed.success) {
|
||||
return jsonError(firstZodMessage(parsed.error));
|
||||
}
|
||||
|
||||
const target = await prisma.user.findUnique({ where: { id } });
|
||||
if (!target) {
|
||||
throw new QrRequestError("Dieser Benutzer existiert nicht (mehr).", 404);
|
||||
}
|
||||
|
||||
const data: {
|
||||
name?: string | null;
|
||||
isActive?: boolean;
|
||||
isInstanceAdmin?: boolean;
|
||||
passwordHash?: string;
|
||||
failedLogins?: number;
|
||||
lockedUntil?: Date | null;
|
||||
} = {};
|
||||
|
||||
if (parsed.data.name !== undefined) {
|
||||
data.name = parsed.data.name || null;
|
||||
}
|
||||
|
||||
if (parsed.data.isActive === false) {
|
||||
await assertNotLastAdmin(id, "das Deaktivieren");
|
||||
data.isActive = false;
|
||||
} else if (parsed.data.isActive === true) {
|
||||
data.isActive = true;
|
||||
}
|
||||
|
||||
if (parsed.data.isInstanceAdmin === false) {
|
||||
await assertNotLastAdmin(id, "der Entzug der Administratorrechte");
|
||||
data.isInstanceAdmin = false;
|
||||
} else if (parsed.data.isInstanceAdmin === true) {
|
||||
data.isInstanceAdmin = true;
|
||||
}
|
||||
|
||||
if (parsed.data.password) {
|
||||
data.passwordHash = await hashPassword(parsed.data.password);
|
||||
data.failedLogins = 0;
|
||||
data.lockedUntil = null;
|
||||
}
|
||||
|
||||
if (parsed.data.unlock) {
|
||||
data.failedLogins = 0;
|
||||
data.lockedUntil = null;
|
||||
}
|
||||
|
||||
if (Object.keys(data).length === 0) {
|
||||
return jsonError("Es wurden keine Änderungen übermittelt.");
|
||||
}
|
||||
|
||||
const updated = await prisma.user.update({ where: { id }, data });
|
||||
|
||||
// Deaktivieren oder ein neues Passwort beenden laufende Sitzungen —
|
||||
// sonst bliebe der Betroffene angemeldet.
|
||||
if (data.isActive === false || data.passwordHash) {
|
||||
await destroyAllSessions(id);
|
||||
}
|
||||
|
||||
return Response.json({
|
||||
user: {
|
||||
id: updated.id,
|
||||
email: updated.email,
|
||||
isActive: updated.isActive,
|
||||
isInstanceAdmin: updated.isInstanceAdmin,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
context: Context,
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const actor = await getActor();
|
||||
requirePermission(actor, "instance:manage");
|
||||
|
||||
const { id } = await context.params;
|
||||
|
||||
if (actor.kind === "user" && actor.user.id === id) {
|
||||
throw new QrRequestError(
|
||||
"Du kannst dein eigenes Konto nicht löschen.",
|
||||
);
|
||||
}
|
||||
|
||||
const target = await prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!target) {
|
||||
throw new QrRequestError("Dieser Benutzer existiert nicht (mehr).", 404);
|
||||
}
|
||||
|
||||
await assertNotLastAdmin(id, "das Löschen");
|
||||
|
||||
// Die Codes des Benutzers bleiben bestehen; nur der Ersteller entfällt
|
||||
// (onDelete: SetNull). Gedruckte Codes dürfen nicht daran hängen, wer
|
||||
// sie einmal angelegt hat.
|
||||
await prisma.user.delete({ where: { id } });
|
||||
return new Response(null, { status: 204 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getActor } from "@/lib/auth/actor";
|
||||
import {
|
||||
describePasswordRules,
|
||||
hashPassword,
|
||||
MIN_PASSWORD_LENGTH,
|
||||
} from "@/lib/auth/password";
|
||||
import { requirePermission } from "@/lib/auth/policy";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||
import { QrRequestError } from "@/lib/qr/type-module";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const createSchema = z.object({
|
||||
email: z
|
||||
.string()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.min(1, { error: "Bitte gib eine E-Mail-Adresse ein." })
|
||||
.max(254, { error: "Die Adresse ist zu lang." })
|
||||
.refine((value) => /^[^\s@]+@[^\s@.]+\.[^\s@]+$/.test(value), {
|
||||
error: "Das ist keine gültige E-Mail-Adresse.",
|
||||
}),
|
||||
name: z.string().trim().max(120).optional(),
|
||||
password: z.string().min(MIN_PASSWORD_LENGTH, {
|
||||
error: `Das Passwort ist zu kurz. ${describePasswordRules()}`,
|
||||
}),
|
||||
isInstanceAdmin: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export async function GET(): Promise<Response> {
|
||||
try {
|
||||
requirePermission(await getActor(), "instance:manage");
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
orderBy: [{ isInstanceAdmin: "desc" }, { email: "asc" }],
|
||||
include: {
|
||||
memberships: { include: { group: { select: { id: true, name: true } } } },
|
||||
_count: { select: { sessions: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return Response.json({
|
||||
users: users.map((user) => ({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
isInstanceAdmin: user.isInstanceAdmin,
|
||||
isActive: user.isActive,
|
||||
isLocked: Boolean(
|
||||
user.lockedUntil && user.lockedUntil.getTime() > Date.now(),
|
||||
),
|
||||
lastLoginAt: user.lastLoginAt?.toISOString() ?? null,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
activeSessions: user._count.sessions,
|
||||
memberships: user.memberships.map((m) => ({
|
||||
groupId: m.group.id,
|
||||
groupName: m.group.name,
|
||||
role: m.role,
|
||||
})),
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest): Promise<Response> {
|
||||
try {
|
||||
requirePermission(await getActor(), "instance:manage");
|
||||
|
||||
const parsed = createSchema.safeParse(await request.json());
|
||||
if (!parsed.success) {
|
||||
return jsonError(firstZodMessage(parsed.error));
|
||||
}
|
||||
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { email: parsed.data.email },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) {
|
||||
throw new QrRequestError(
|
||||
"Diese E-Mail-Adresse ist bereits vergeben.",
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: parsed.data.email,
|
||||
name: parsed.data.name || null,
|
||||
passwordHash: await hashPassword(parsed.data.password),
|
||||
isInstanceAdmin: parsed.data.isInstanceAdmin,
|
||||
},
|
||||
});
|
||||
|
||||
return Response.json(
|
||||
{ user: { id: user.id, email: user.email } },
|
||||
{ status: 201 },
|
||||
);
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getActor, isAuthEnabled, isSetupOpen } from "@/lib/auth/actor";
|
||||
import {
|
||||
describePasswordRules,
|
||||
hashPassword,
|
||||
MIN_PASSWORD_LENGTH,
|
||||
verifyPassword,
|
||||
} from "@/lib/auth/password";
|
||||
import { createSession, destroySession } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||
import { QrRequestError } from "@/lib/qr/type-module";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Sperrdauer nach zu vielen Fehlversuchen. */
|
||||
const MAX_ATTEMPTS = 8;
|
||||
const LOCK_MINUTES = 15;
|
||||
|
||||
const emailSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.min(1, { error: "Bitte gib deine E-Mail-Adresse ein." })
|
||||
.max(254, { error: "Die Adresse ist zu lang." })
|
||||
.refine((value) => /^[^\s@]+@[^\s@.]+\.[^\s@]+$/.test(value), {
|
||||
error: "Das ist keine gültige E-Mail-Adresse.",
|
||||
});
|
||||
|
||||
const loginSchema = z.object({
|
||||
action: z.literal("login"),
|
||||
email: emailSchema,
|
||||
password: z.string().min(1, { error: "Bitte gib dein Passwort ein." }),
|
||||
});
|
||||
|
||||
const setupSchema = z.object({
|
||||
action: z.literal("setup"),
|
||||
email: emailSchema,
|
||||
name: z.string().trim().max(120).optional(),
|
||||
password: z.string().min(MIN_PASSWORD_LENGTH, {
|
||||
error: `Das Passwort ist zu kurz. ${describePasswordRules()}`,
|
||||
}),
|
||||
});
|
||||
|
||||
const logoutSchema = z.object({ action: z.literal("logout") });
|
||||
|
||||
const bodySchema = z.discriminatedUnion("action", [
|
||||
loginSchema,
|
||||
setupSchema,
|
||||
logoutSchema,
|
||||
]);
|
||||
|
||||
/** Wer ist angemeldet? Von der Oberfläche für den Kopfbereich genutzt. */
|
||||
export async function GET(): Promise<Response> {
|
||||
try {
|
||||
const actor = await getActor();
|
||||
return Response.json({
|
||||
authEnabled: isAuthEnabled(),
|
||||
setupOpen: await isSetupOpen(),
|
||||
user:
|
||||
actor.kind === "user"
|
||||
? {
|
||||
id: actor.user.id,
|
||||
email: actor.user.email,
|
||||
name: actor.user.name,
|
||||
isInstanceAdmin: actor.user.isInstanceAdmin,
|
||||
memberships: actor.user.memberships,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest): Promise<Response> {
|
||||
try {
|
||||
const parsed = bodySchema.safeParse(await request.json());
|
||||
if (!parsed.success) {
|
||||
return jsonError(firstZodMessage(parsed.error));
|
||||
}
|
||||
|
||||
if (parsed.data.action === "logout") {
|
||||
await destroySession();
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
if (!isAuthEnabled()) {
|
||||
throw new QrRequestError(
|
||||
"Die Anmeldung ist ausgeschaltet. Setze AUTH_ENABLED=true in der .env, um sie zu aktivieren.",
|
||||
);
|
||||
}
|
||||
|
||||
// Das `await` ist hier nicht optional: ohne es verlässt das Promise den
|
||||
// try-Block, bevor es sich auflöst — eine Ablehnung käme dann nie im
|
||||
// catch an und würde als nackter 500er beim Aufrufer landen.
|
||||
if (parsed.data.action === "setup") {
|
||||
return await handleSetup(parsed.data);
|
||||
}
|
||||
return await handleLogin(parsed.data);
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Legt den ersten Benutzer an — nur solange es noch keinen gibt. */
|
||||
async function handleSetup(
|
||||
input: z.infer<typeof setupSchema>,
|
||||
): Promise<Response> {
|
||||
if (!(await isSetupOpen())) {
|
||||
throw new QrRequestError(
|
||||
"Die Ersteinrichtung ist bereits abgeschlossen. Bitte melde dich an.",
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: input.email,
|
||||
name: input.name && input.name.length > 0 ? input.name : null,
|
||||
passwordHash: await hashPassword(input.password),
|
||||
isInstanceAdmin: true,
|
||||
},
|
||||
});
|
||||
|
||||
await createSession(user.id);
|
||||
return Response.json({ ok: true, email: user.email }, { status: 201 });
|
||||
}
|
||||
|
||||
async function handleLogin(
|
||||
input: z.infer<typeof loginSchema>,
|
||||
): Promise<Response> {
|
||||
const user = await prisma.user.findUnique({ where: { email: input.email } });
|
||||
|
||||
// Immer dieselbe Meldung, egal ob die Adresse existiert — sonst ließe sich
|
||||
// damit herausfinden, welche Konten es gibt.
|
||||
const invalid = new QrRequestError(
|
||||
"E-Mail-Adresse oder Passwort stimmt nicht.",
|
||||
401,
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
// Trotzdem einmal rechnen, damit die Antwortzeit nichts verrät.
|
||||
await verifyPassword(input.password, "scrypt$32768$8$1$AAAA$AAAA");
|
||||
throw invalid;
|
||||
}
|
||||
|
||||
if (!user.isActive) {
|
||||
throw new QrRequestError(
|
||||
"Dieses Konto ist deaktiviert. Bitte wende dich an einen Administrator.",
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
if (user.lockedUntil && user.lockedUntil.getTime() > Date.now()) {
|
||||
const minutes = Math.ceil(
|
||||
(user.lockedUntil.getTime() - Date.now()) / 60000,
|
||||
);
|
||||
throw new QrRequestError(
|
||||
`Zu viele Fehlversuche. Bitte versuche es in ${minutes} Minute${minutes === 1 ? "" : "n"} erneut.`,
|
||||
429,
|
||||
);
|
||||
}
|
||||
|
||||
if (!(await verifyPassword(input.password, user.passwordHash))) {
|
||||
const failedLogins = user.failedLogins + 1;
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
failedLogins,
|
||||
lockedUntil:
|
||||
failedLogins >= MAX_ATTEMPTS
|
||||
? new Date(Date.now() + LOCK_MINUTES * 60 * 1000)
|
||||
: null,
|
||||
},
|
||||
});
|
||||
throw invalid;
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { failedLogins: 0, lockedUntil: null, lastLoginAt: new Date() },
|
||||
});
|
||||
|
||||
await createSession(user.id);
|
||||
return Response.json({ ok: true, email: user.email });
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { getActor } from "@/lib/auth/actor";
|
||||
import { requirePermission } from "@/lib/auth/policy";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { shortUrl } from "@/lib/env";
|
||||
import { errorResponse } from "@/lib/http";
|
||||
@@ -32,6 +34,10 @@ export async function GET(
|
||||
throw new QrRequestError("Dieser QR-Code existiert nicht (mehr).", 404);
|
||||
}
|
||||
|
||||
// Das Bild verrät die Kurz-URL und damit das Ziel — es gehört hinter
|
||||
// dieselbe Prüfung wie die Liste.
|
||||
requirePermission(await getActor(), "codes:read", { groupId: code.groupId });
|
||||
|
||||
const design = designSchema.parse({
|
||||
fgColor: code.fgColor,
|
||||
bgColor: code.bgColor,
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { getActor } from "@/lib/auth/actor";
|
||||
import { requirePermission } from "@/lib/auth/policy";
|
||||
import { toJsonInput, toQrCodeDto } from "@/lib/codes";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||
@@ -14,6 +16,11 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
type Context = { params: Promise<{ id: string }> };
|
||||
|
||||
const WITH_RELATIONS = {
|
||||
group: { select: { name: true } },
|
||||
createdBy: { select: { name: true, email: true } },
|
||||
} as const;
|
||||
|
||||
const updateSchema = z.object({
|
||||
data: z.record(z.string(), z.unknown()).optional(),
|
||||
design: designSchema.optional(),
|
||||
@@ -26,7 +33,10 @@ const updateSchema = z.object({
|
||||
});
|
||||
|
||||
async function loadCode(id: string) {
|
||||
const code = await prisma.qrCode.findUnique({ where: { id } });
|
||||
const code = await prisma.qrCode.findUnique({
|
||||
where: { id },
|
||||
include: WITH_RELATIONS,
|
||||
});
|
||||
if (!code) {
|
||||
throw new QrRequestError("Dieser QR-Code existiert nicht (mehr).", 404);
|
||||
}
|
||||
@@ -39,7 +49,10 @@ export async function GET(
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
return Response.json({ code: toQrCodeDto(await loadCode(id)) });
|
||||
const actor = await getActor();
|
||||
const code = await loadCode(id);
|
||||
requirePermission(actor, "codes:read", { groupId: code.groupId });
|
||||
return Response.json({ code: toQrCodeDto(code) });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
@@ -52,12 +65,16 @@ export async function PATCH(
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
const actor = await getActor();
|
||||
|
||||
const parsed = updateSchema.safeParse(await request.json());
|
||||
if (!parsed.success) {
|
||||
return jsonError(firstZodMessage(parsed.error));
|
||||
}
|
||||
|
||||
const existing = await loadCode(id);
|
||||
requirePermission(actor, "codes:update", { groupId: existing.groupId });
|
||||
|
||||
const update: Prisma.QrCodeUpdateInput = {};
|
||||
|
||||
if (parsed.data.data !== undefined) {
|
||||
@@ -91,7 +108,11 @@ export async function PATCH(
|
||||
return jsonError("Es wurden keine Änderungen übermittelt.");
|
||||
}
|
||||
|
||||
const updated = await prisma.qrCode.update({ where: { id }, data: update });
|
||||
const updated = await prisma.qrCode.update({
|
||||
where: { id },
|
||||
data: update,
|
||||
include: WITH_RELATIONS,
|
||||
});
|
||||
return Response.json({ code: toQrCodeDto(updated) });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
@@ -104,7 +125,10 @@ export async function DELETE(
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
await loadCode(id);
|
||||
const actor = await getActor();
|
||||
const existing = await loadCode(id);
|
||||
requirePermission(actor, "codes:delete", { groupId: existing.groupId });
|
||||
|
||||
await prisma.qrCode.delete({ where: { id } });
|
||||
return new Response(null, { status: 204 });
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getActor } from "@/lib/auth/actor";
|
||||
import { resolveTargetGroup } from "@/lib/auth/groups";
|
||||
import { requirePermission, visibleGroupIds } from "@/lib/auth/policy";
|
||||
import { toJsonInput, toQrCodeDto } from "@/lib/codes";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||
@@ -21,6 +24,8 @@ const createSchema = z.object({
|
||||
.trim()
|
||||
.max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." })
|
||||
.optional(),
|
||||
/** In welcher Gruppe der Code angelegt wird. Ohne Anmeldung bedeutungslos. */
|
||||
groupId: z.string().trim().min(1).optional(),
|
||||
});
|
||||
|
||||
function isUniqueViolation(error: unknown): boolean {
|
||||
@@ -32,10 +37,24 @@ function isUniqueViolation(error: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/** Alle dynamischen Codes, neueste zuerst. */
|
||||
/** Die dynamischen Codes, die der Anfragende sehen darf — neueste zuerst. */
|
||||
export async function GET(): Promise<Response> {
|
||||
try {
|
||||
const codes = await prisma.qrCode.findMany({ orderBy: { createdAt: "desc" } });
|
||||
const actor = await getActor();
|
||||
if (actor.kind === "anonymous") {
|
||||
throw new QrRequestError("Bitte melde dich an.", 401);
|
||||
}
|
||||
|
||||
const visible = visibleGroupIds(actor);
|
||||
const codes = await prisma.qrCode.findMany({
|
||||
where: visible === null ? undefined : { groupId: { in: visible } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
group: { select: { name: true } },
|
||||
createdBy: { select: { name: true, email: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return Response.json({ codes: codes.map(toQrCodeDto) });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
@@ -45,12 +64,19 @@ export async function GET(): Promise<Response> {
|
||||
/** Legt einen dynamischen Code an und vergibt dafür eine freie Kurz-URL. */
|
||||
export async function POST(request: NextRequest): Promise<Response> {
|
||||
try {
|
||||
const actor = await getActor();
|
||||
|
||||
const parsed = createSchema.safeParse(await request.json());
|
||||
if (!parsed.success) {
|
||||
return jsonError(firstZodMessage(parsed.error));
|
||||
}
|
||||
|
||||
const { type, design, label } = parsed.data;
|
||||
|
||||
// Zielgruppe zuerst: davon hängt ab, ob überhaupt angelegt werden darf.
|
||||
const groupId = await resolveTargetGroup(actor, parsed.data.groupId);
|
||||
requirePermission(actor, "codes:create", { groupId });
|
||||
|
||||
const qrType = await requireAvailableQrType(type);
|
||||
|
||||
const validated = qrType.validate(parsed.data.data);
|
||||
@@ -70,8 +96,14 @@ export async function POST(request: NextRequest): Promise<Response> {
|
||||
type: qrType.id,
|
||||
label: label && label.length > 0 ? label : null,
|
||||
data: toJsonInput(validated.data),
|
||||
groupId,
|
||||
createdById: actor.kind === "user" ? actor.user.id : null,
|
||||
...design,
|
||||
},
|
||||
include: {
|
||||
group: { select: { name: true } },
|
||||
createdBy: { select: { name: true, email: true } },
|
||||
},
|
||||
});
|
||||
return Response.json({ code: toQrCodeDto(created) }, { status: 201 });
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getActor, requireSignedIn } from "@/lib/auth/actor";
|
||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||
import { CONTENT_TYPES, renderPng, renderSvg } from "@/lib/qr/render";
|
||||
import {
|
||||
@@ -17,6 +18,10 @@ const downloadSchema = qrRequestSchema.extend({
|
||||
/** Liefert den fertigen QR-Code als PNG- oder SVG-Datei zum Herunterladen. */
|
||||
export async function POST(request: NextRequest): Promise<Response> {
|
||||
try {
|
||||
// Der Generator steht nur Angemeldeten offen; die Weiterleitung
|
||||
// unter /r/{slug} bleibt davon unberührt und öffentlich.
|
||||
requireSignedIn(await getActor());
|
||||
|
||||
const parsed = downloadSchema.safeParse(await request.json());
|
||||
if (!parsed.success) {
|
||||
return jsonError(firstZodMessage(parsed.error));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { getActor, requireSignedIn } from "@/lib/auth/actor";
|
||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||
import { renderSvg } from "@/lib/qr/render";
|
||||
import { qrRequestSchema, resolveQrRequest } from "@/lib/qr/request";
|
||||
@@ -10,6 +11,10 @@ import { qrRequestSchema, resolveQrRequest } from "@/lib/qr/request";
|
||||
*/
|
||||
export async function POST(request: NextRequest): Promise<Response> {
|
||||
try {
|
||||
// Der Generator steht nur Angemeldeten offen; die Weiterleitung
|
||||
// unter /r/{slug} bleibt davon unberührt und öffentlich.
|
||||
requireSignedIn(await getActor());
|
||||
|
||||
const parsed = qrRequestSchema.safeParse(await request.json());
|
||||
if (!parsed.success) {
|
||||
return jsonError(firstZodMessage(parsed.error));
|
||||
|
||||
@@ -4,6 +4,7 @@ import { notFound } from "next/navigation";
|
||||
|
||||
import { Icon } from "@/components/icons";
|
||||
import { QrGenerator } from "@/components/qr-generator";
|
||||
import { requirePageActor } from "@/lib/auth/guard";
|
||||
import { getQrTypeState } from "@/lib/qr/availability";
|
||||
import { getQrType } from "@/lib/qr/registry";
|
||||
|
||||
@@ -46,6 +47,7 @@ function Hinweis({
|
||||
}
|
||||
|
||||
export default async function CreatePage({ params }: Props) {
|
||||
await requirePageActor();
|
||||
const { type } = await params;
|
||||
const state = await getQrTypeState(type);
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Icon } from "@/components/icons";
|
||||
|
||||
export const metadata: Metadata = { title: "Kein Zugriff" };
|
||||
|
||||
export default function NoAccessPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-md px-4 py-20 text-center sm:px-6">
|
||||
<span className="mx-auto flex size-14 items-center justify-center rounded-2xl bg-slate-100 text-slate-400">
|
||||
<Icon name="lock" className="size-7" />
|
||||
</span>
|
||||
<h1 className="mt-5 text-2xl font-bold text-slate-900">Kein Zugriff</h1>
|
||||
<p className="mt-2 text-slate-600">
|
||||
Für diesen Bereich fehlt dir die nötige Berechtigung. Wenn du meinst,
|
||||
dass das ein Versehen ist, wende dich an einen Administrator.
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="mt-6 inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-indigo-700"
|
||||
>
|
||||
Zur Startseite
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+50
-20
@@ -1,7 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
|
||||
import { LogoutButton } from "@/components/auth-forms";
|
||||
import { Icon } from "@/components/icons";
|
||||
import { actorLabel, getActor, isAuthEnabled } from "@/lib/auth/actor";
|
||||
import { can } from "@/lib/auth/policy";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -13,7 +16,18 @@ export const metadata: Metadata = {
|
||||
"QR-Codes erstellen: statisch oder dynamisch, mit eigenen Farben, frei wählbarer Größe und Download als PNG oder SVG.",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||
// Der Kopfbereich hängt von der Anmeldung ab und darf nicht vorgerendert
|
||||
// werden.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const NAV_LINK =
|
||||
"rounded-lg px-3 py-2 text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900";
|
||||
|
||||
export default async function RootLayout({ children }: LayoutProps<"/">) {
|
||||
const actor = await getActor();
|
||||
const signedIn = actor.kind !== "anonymous";
|
||||
const showAdmin = can(actor, "instance:manage");
|
||||
|
||||
return (
|
||||
<html lang="de" className="h-full">
|
||||
<body className="flex min-h-full flex-col">
|
||||
@@ -30,25 +44,41 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||
Navigation nicht mehr in eine Zeile. */}
|
||||
<span className="hidden sm:inline">QR-Code-Generator</span>
|
||||
</Link>
|
||||
<nav className="flex items-center gap-0.5 text-sm font-medium sm:gap-1">
|
||||
<Link
|
||||
href="/"
|
||||
className="rounded-lg px-3 py-2 text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900"
|
||||
>
|
||||
Erstellen
|
||||
</Link>
|
||||
<Link
|
||||
href="/meine-codes"
|
||||
className="rounded-lg px-3 py-2 text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900"
|
||||
>
|
||||
Meine Codes
|
||||
</Link>
|
||||
<Link
|
||||
href="/verwaltung/typen"
|
||||
className="rounded-lg px-3 py-2 text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900"
|
||||
>
|
||||
Verwaltung
|
||||
</Link>
|
||||
|
||||
<nav className="flex flex-wrap items-center gap-0.5 text-sm font-medium sm:gap-1">
|
||||
{signedIn ? (
|
||||
<>
|
||||
<Link href="/" className={NAV_LINK}>
|
||||
Erstellen
|
||||
</Link>
|
||||
<Link href="/meine-codes" className={NAV_LINK}>
|
||||
Meine Codes
|
||||
</Link>
|
||||
{showAdmin ? (
|
||||
<Link href="/verwaltung" className={NAV_LINK}>
|
||||
Verwaltung
|
||||
</Link>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{isAuthEnabled() && actor.kind === "user" ? (
|
||||
<>
|
||||
<span
|
||||
className="ml-1 hidden max-w-[14rem] truncate text-slate-500 sm:inline"
|
||||
title={actor.user.email}
|
||||
>
|
||||
{actorLabel(actor)}
|
||||
</span>
|
||||
<LogoutButton />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{isAuthEnabled() && actor.kind === "anonymous" ? (
|
||||
<Link href="/anmelden" className={NAV_LINK}>
|
||||
Anmelden
|
||||
</Link>
|
||||
) : null}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import { CodesList } from "@/components/codes-list";
|
||||
import { requirePageActor } from "@/lib/auth/guard";
|
||||
|
||||
export const metadata: Metadata = { title: "Meine Codes" };
|
||||
|
||||
export default function MyCodesPage() {
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MyCodesPage() {
|
||||
await requirePageActor();
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 sm:py-10">
|
||||
<div className="mb-7">
|
||||
|
||||
@@ -2,6 +2,7 @@ import Link from "next/link";
|
||||
|
||||
import { Icon } from "@/components/icons";
|
||||
import { Badge, Notice } from "@/components/ui";
|
||||
import { requirePageActor } from "@/lib/auth/guard";
|
||||
import { getVisibleQrTypes } from "@/lib/qr/availability";
|
||||
|
||||
// Welche Typen sichtbar sind, steht in der Datenbank und ist jederzeit
|
||||
@@ -9,6 +10,7 @@ import { getVisibleQrTypes } from "@/lib/qr/availability";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function HomePage() {
|
||||
await requirePageActor();
|
||||
const types = await getVisibleQrTypes();
|
||||
const usableCount = types.filter((entry) => entry.usable).length;
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Icon } from "@/components/icons";
|
||||
import { UserAdmin } from "@/components/user-admin";
|
||||
import { requirePagePermission } from "@/lib/auth/guard";
|
||||
import { MIN_PASSWORD_LENGTH } from "@/lib/auth/password";
|
||||
|
||||
export const metadata: Metadata = { title: "Benutzer" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function UsersPage() {
|
||||
const actor = await requirePagePermission("instance:manage");
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
|
||||
<Link
|
||||
href="/verwaltung"
|
||||
className="mb-5 inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-slate-600 transition-colors hover:text-slate-900"
|
||||
>
|
||||
<Icon name="arrowLeft" className="size-4" />
|
||||
Verwaltung
|
||||
</Link>
|
||||
|
||||
<div className="mb-7">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
|
||||
Benutzer
|
||||
</h1>
|
||||
<p className="mt-2 text-slate-600">
|
||||
Konten anlegen, Passwörter setzen und Zugänge deaktivieren. Wer in
|
||||
welcher Gruppe mitarbeitet, legst du unter{" "}
|
||||
<Link href="/verwaltung/gruppen" className="font-medium underline">
|
||||
Gruppen
|
||||
</Link>{" "}
|
||||
fest.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UserAdmin
|
||||
minPasswordLength={MIN_PASSWORD_LENGTH}
|
||||
currentUserId={actor.kind === "user" ? actor.user.id : null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
|
||||
import { GroupAdmin } from "@/components/group-admin";
|
||||
import { Icon } from "@/components/icons";
|
||||
import { requirePageActor } from "@/lib/auth/guard";
|
||||
import { can } from "@/lib/auth/policy";
|
||||
|
||||
export const metadata: Metadata = { title: "Gruppen" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function GroupsPage() {
|
||||
// Bewusst nur „angemeldet“: ein Gruppen-Administrator darf seine eigene
|
||||
// Gruppe pflegen, auch ohne Rechte an der Installation.
|
||||
const actor = await requirePageActor();
|
||||
const canCreate = can(actor, "instance:manage");
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
|
||||
<Link
|
||||
href={canCreate ? "/verwaltung" : "/"}
|
||||
className="mb-5 inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-slate-600 transition-colors hover:text-slate-900"
|
||||
>
|
||||
<Icon name="arrowLeft" className="size-4" />
|
||||
{canCreate ? "Verwaltung" : "Zur Typ-Auswahl"}
|
||||
</Link>
|
||||
|
||||
<div className="mb-7">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
|
||||
Gruppen
|
||||
</h1>
|
||||
<p className="mt-2 text-slate-600">
|
||||
Eine Gruppe bündelt Benutzer und die Codes, die ihnen gehören. Jedes
|
||||
Mitglied hat darin eine Rolle: Administrator, Bearbeiter oder
|
||||
Betrachter.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<GroupAdmin canCreate={canCreate} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Icon, type IconName } from "@/components/icons";
|
||||
import { Notice } from "@/components/ui";
|
||||
import { isAuthEnabled } from "@/lib/auth/actor";
|
||||
import { requirePagePermission } from "@/lib/auth/guard";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export const metadata: Metadata = { title: "Verwaltung" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function Tile({
|
||||
href,
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
hint,
|
||||
}: {
|
||||
href: string;
|
||||
icon: IconName;
|
||||
title: string;
|
||||
description: string;
|
||||
hint: string;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="flex items-start gap-4 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm transition-all hover:border-indigo-300 hover:shadow-md"
|
||||
>
|
||||
<span className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-indigo-50 text-indigo-600">
|
||||
<Icon name={icon} className="size-6" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="font-semibold text-slate-900">{title}</span>
|
||||
<span className="mt-1 block text-sm text-slate-600">{description}</span>
|
||||
<span className="mt-2 block text-xs text-slate-500 tabular-nums">
|
||||
{hint}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function AdminPage() {
|
||||
await requirePagePermission("instance:manage");
|
||||
|
||||
const [userCount, groupCount, codeCount] = await Promise.all([
|
||||
prisma.user.count(),
|
||||
prisma.group.count(),
|
||||
prisma.qrCode.count(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 sm:py-10">
|
||||
<div className="mb-7">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
|
||||
Verwaltung
|
||||
</h1>
|
||||
<p className="mt-2 text-slate-600">
|
||||
Benutzer, Gruppen und die verfügbaren QR-Typen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!isAuthEnabled() ? (
|
||||
<div className="mb-6">
|
||||
<Notice tone="info">
|
||||
<span className="font-medium">Die Anmeldung ist ausgeschaltet.</span>{" "}
|
||||
Jeder, der die Anwendung erreicht, darf alles. Zum Einschalten
|
||||
<code className="mx-1 rounded bg-white/70 px-1.5 py-0.5 font-mono text-xs">
|
||||
AUTH_ENABLED=true
|
||||
</code>
|
||||
in der <code className="font-mono text-xs">.env</code> setzen und
|
||||
<code className="mx-1 rounded bg-white/70 px-1.5 py-0.5 font-mono text-xs">
|
||||
docker compose up -d
|
||||
</code>
|
||||
ausführen. Beim nächsten Aufruf legst du den ersten Administrator
|
||||
an. Benutzer und Gruppen lassen sich schon jetzt vorbereiten.
|
||||
</Notice>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Tile
|
||||
href="/verwaltung/benutzer"
|
||||
icon="contact"
|
||||
title="Benutzer"
|
||||
description="Konten anlegen, Passwörter zurücksetzen, deaktivieren."
|
||||
hint={`${userCount} ${userCount === 1 ? "Benutzer" : "Benutzer"} angelegt`}
|
||||
/>
|
||||
<Tile
|
||||
href="/verwaltung/gruppen"
|
||||
icon="list"
|
||||
title="Gruppen"
|
||||
description="Gruppen anlegen und Mitglieder mit Rollen zuordnen."
|
||||
hint={`${groupCount} ${groupCount === 1 ? "Gruppe" : "Gruppen"}, ${codeCount} ${codeCount === 1 ? "Code" : "Codes"}`}
|
||||
/>
|
||||
<Tile
|
||||
href="/verwaltung/typen"
|
||||
icon="qr"
|
||||
title="QR-Typen"
|
||||
description="Festlegen, welche Typen auf der Startseite erscheinen."
|
||||
hint="15 Typen insgesamt"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import Link from "next/link";
|
||||
|
||||
import { Icon } from "@/components/icons";
|
||||
import { TypeSettings, type TypeSetting } from "@/components/type-settings";
|
||||
import { requirePagePermission } from "@/lib/auth/guard";
|
||||
import { getQrTypeAvailability } from "@/lib/qr/availability";
|
||||
|
||||
export const metadata: Metadata = { title: "QR-Typen" };
|
||||
@@ -10,6 +11,7 @@ export const metadata: Metadata = { title: "QR-Typen" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function TypeSettingsPage() {
|
||||
await requirePagePermission("instance:manage");
|
||||
const availability = await getQrTypeAvailability();
|
||||
|
||||
const initial: TypeSetting[] = availability.map(
|
||||
|
||||
Reference in New Issue
Block a user