diff --git a/.env.example b/.env.example index a55c519..41780c6 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,14 @@ BOOTSTRAP_ADMIN_PASSWORD= # (Burning Board) zugeschnitten. # FORUM_BASE_URL=https://senseigaming.de/forum +# Der Zugang aus der Erstinstallation ist geschützt: weder Rolle noch Zustand +# lassen sich ändern, sein Passwort nicht von aussen zurücksetzen, und löschen +# lässt er sich nicht - auch nicht durch sich selbst. So bleibt ein Weg in die +# Anwendung offen, wenn sich jemand die Rechte entzieht. Sein eigenes Passwort +# ändert er weiterhin selbst unter "Konto". Auf true gesetzt gilt er wie jeder +# andere Zugang. +# BOOTSTRAP_ADMIN_EDITABLE=false + # Die wiederkehrenden Abgleiche werden in der Anwendung eingestellt, unter # Verwaltung -> Zeitplan: ob sie laufen, in welchem Abstand, und beim Forum # ab welcher Stunde. Hier steht dazu nichts mehr. diff --git a/README.md b/README.md index 31d7fe7..6c15e33 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,16 @@ desselben Spielers. Ein nachgetragener CC wird also schlicht dorthin gezogen, wo *Herauslösen* stellt einen Eintrag wieder allein, wenn sich herausstellt, dass er gar nicht in diese Kette gehörte. -Das Verschieben **ordnet nur ein**: Es entscheidet nichts, bucht nichts und ändert an keinem -Status etwas. Wer aktiv war, bleibt aktiv. +Eines folgt daraus von selbst: **Wer einen Nachfolger hat, wurde durch ihn ersetzt.** Anders ist +eine Kette nicht zu lesen. Deshalb trägt jedes Glied außer dem obersten den Vermerk *Redesignt*, +und wer ans Ende rückt, verliert ihn wieder - er stammte ja von einem Nachfolger, der jetzt +woanders steht. Ein Platz entsteht dabei nicht aus dem Nichts: Wird ein CC dadurch wieder aktiv, +muss er gedeckt sein wie jeder andere. + +Über *Bearbeiten* lassen sich Name, Beschreibung und der verknüpfte Forums-Antrag nachtragen - +die Thread-Nummer genügt, die volle Adresse geht auch. Über *Bezahlart* wird umgestellt, ob ein +Redesign aus einem gekauften Paket oder aus Kulanz kam; umgebucht wird über eine Gegenbuchung, +in der Historie bleibt beides sichtbar. Am Fuß einer Kette kann ein CC stehen, der jemand anderem gehört - der Gruppen-CC, aus dem sich der Spieler herausredesignt hat. Er war darin, und das bleibt sichtbar; verschieben oder @@ -433,6 +441,13 @@ den Lauf abzubrechen - eine krumme Zeile darf nicht neunhundert gute aufhalten. Es gibt **keine öffentliche Registrierung**. Konten legt ausschließlich ein Administrator an. +Der Zugang aus der **Erstinstallation** ist geschützt: Weder seine Rolle noch sein Zustand +lassen sich ändern, sein Passwort nicht von außen zurücksetzen, und löschen lässt er sich nicht - +auch nicht durch sich selbst. Damit bleibt ein Weg in die Anwendung offen, wenn sich jemand aus +Versehen die Rechte entzieht oder ein Konto abhandenkommt. Sein eigenes Passwort ändert er +weiterhin selbst, unter *Konto*. Wer das nicht will, setzt `BOOTSTRAP_ADMIN_EDITABLE=true`; dann +gilt er wie jeder andere Zugang. + - **Viewer** - darf nur lesen. - **Editor** - Spieler anlegen und bearbeiten, gekaufte Pakete eintragen, CCs erstellen, redesignen und reaktivieren, Datei-Import, GExtension- und Forums-Abgleich samt Entscheidung diff --git a/compose.yaml b/compose.yaml index f6303c8..c20e8a3 100644 --- a/compose.yaml +++ b/compose.yaml @@ -11,6 +11,8 @@ services: # Erstzugang; nach der ersten Anmeldung wird ein Wechsel erzwungen. BOOTSTRAP_ADMIN_USER: ${BOOTSTRAP_ADMIN_USER:-admin} BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-} + # Dieser Zugang bleibt unantastbar, solange das hier nicht "true" ist. + BOOTSTRAP_ADMIN_EDITABLE: ${BOOTSTRAP_ADMIN_EDITABLE:-false} TRUST_PROXY: "true" # Nur nötig, wenn der GExtension-Abgleich eine andere Instanz ansprechen soll. GEXTENSION_BASE_URL: ${GEXTENSION_BASE_URL:-https://gextension.senseigaming.de} diff --git a/server/prisma/migrations/20250112000000_geschuetzter_erstzugang/migration.sql b/server/prisma/migrations/20250112000000_geschuetzter_erstzugang/migration.sql new file mode 100644 index 0000000..b0be545 --- /dev/null +++ b/server/prisma/migrations/20250112000000_geschuetzter_erstzugang/migration.sql @@ -0,0 +1,5 @@ +-- Der Zugang aus der Erstinstallation laesst sich schuetzen, damit sich +-- niemand - auch er selbst nicht - den letzten Weg in die Anwendung verbaut. +-- Welcher Zugang das ist, traegt der Start nach: er kennt den Namen aus +-- BOOTSTRAP_ADMIN_USER, die Migration nicht. +ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "isProtected" BOOLEAN NOT NULL DEFAULT false; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index cc3226b..d063ac0 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -88,6 +88,13 @@ model User { totpEnabled Boolean @default(false) isActive Boolean @default(true) mustChangePassword Boolean @default(false) + + /// Der Zugang aus der Erstinstallation. Solange er geschützt ist, lassen + /// sich weder seine Rolle noch sein Zustand ändern, sein Passwort nicht von + /// aussen zurücksetzen und er selbst nicht löschen - auch nicht durch ihn + /// selbst. Damit bleibt ein Weg in die Anwendung offen, wenn sich jemand + /// aus Versehen oder mit Absicht die Rechte entzieht. + isProtected Boolean @default(false) lastLoginAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/server/src/bootstrap.ts b/server/src/bootstrap.ts index e4ee3b8..725aa89 100644 --- a/server/src/bootstrap.ts +++ b/server/src/bootstrap.ts @@ -42,6 +42,7 @@ export async function bootstrap(log: { passwordHash: await hashPassword(password), role: 'ADMIN', mustChangePassword: true, + isProtected: true, }, }); @@ -56,6 +57,14 @@ export async function bootstrap(log: { log.warn('─'.repeat(60)); } + // Bestehende Installationen kennen die Markierung noch nicht. Welcher + // Zugang der erste war, weiss nur der Start: der Name steht in der + // Umgebung, nicht in der Datenbank. + await prisma.user.updateMany({ + where: { username: env.BOOTSTRAP_ADMIN_USER, isProtected: false }, + data: { isProtected: true }, + }); + const mappingCount = await prisma.productMapping.count(); if (mappingCount === 0) { const created = await prisma.productMapping.createMany({ data: DEFAULT_MAPPINGS }); diff --git a/server/src/env.ts b/server/src/env.ts index fa2c52c..7175643 100644 --- a/server/src/env.ts +++ b/server/src/env.ts @@ -35,6 +35,17 @@ const schema = z.object({ // WoltLab (Burning Board) zugeschnitten. FORUM_BASE_URL: z.string().url().default('https://senseigaming.de/forum'), + // Der Zugang aus der Erstinstallation ist ab Werk geschützt: weder Rolle + // noch Zustand lassen sich ändern, sein Passwort nicht von aussen + // zurücksetzen, und löschen lässt er sich nicht - auch nicht durch sich + // selbst. So bleibt ein Weg in die Anwendung offen, wenn sich jemand aus + // Versehen die Rechte entzieht. Auf "true" gesetzt, gilt er wie jeder + // andere Zugang. + BOOTSTRAP_ADMIN_EDITABLE: z + .enum(['true', 'false']) + .default('false') + .transform((value) => value === 'true'), + // Aufbewahrung der Audit-Einträge in Tagen. 0 schaltet das Aufräumen ab. // Die Guthaben-Historie ist davon nicht betroffen, die bleibt dauerhaft. AUDIT_RETENTION_DAYS: z.coerce.number().int().min(0).default(365), diff --git a/server/src/routes/admin.routes.ts b/server/src/routes/admin.routes.ts index 67dbf9a..6f27ee2 100644 --- a/server/src/routes/admin.routes.ts +++ b/server/src/routes/admin.routes.ts @@ -17,10 +17,33 @@ const userSelect = { isActive: true, totpEnabled: true, mustChangePassword: true, + isProtected: true, lastLoginAt: true, createdAt: true, } satisfies Prisma.UserSelect; +/** + * Hält den Zugang aus der Erstinstallation aus dem Spiel. + * + * An ihm lässt sich nichts ändern, was ihn aussperren könnte: keine Rolle, + * kein Zustand, kein Passwort von aussen, keine Löschung - und zwar auch + * dann nicht, wenn er es selbst versucht. Damit bleibt ein Weg zurück offen, + * wenn sich jemand die Rechte entzieht oder ein Konto abhanden kommt. Sein + * eigenes Passwort ändert er weiterhin selbst, über den Bereich "Konto". + * + * Wer das nicht will, setzt BOOTSTRAP_ADMIN_EDITABLE auf true; dann gilt er + * wie jeder andere Zugang. + */ +function assertNichtGeschuetzt(target: { isProtected: boolean; username: string }): void { + if (!target.isProtected || env.BOOTSTRAP_ADMIN_EDITABLE) return; + + throw conflict( + 'PROTECTED_ACCOUNT', + `"${target.username}" ist der Zugang aus der Erstinstallation und bleibt unangetastet. ` + + 'Wer das ändern will, setzt BOOTSTRAP_ADMIN_EDITABLE=true in der Umgebung.', + ); +} + // Verhindert, dass sich die Installation selbst den letzten Zugang entzieht. async function assertNotLastAdmin(userId: number): Promise { const target = await prisma.user.findUnique({ where: { id: userId } }); @@ -42,7 +65,16 @@ export async function adminRoutes(app: FastifyInstance): Promise { orderBy: [{ isActive: 'desc' }, { username: 'asc' }], select: userSelect, }); - return { items }; + + // Ob der Schutz gerade greift, entscheidet die Umgebung. Die Oberfläche + // bekommt das Ergebnis, nicht die Regel - sie soll nicht nachbauen + // müssen, was hier schon entschieden ist. + return { + items: items.map((user) => ({ + ...user, + isLocked: user.isProtected && !env.BOOTSTRAP_ADMIN_EDITABLE, + })), + }; }); app.post('/api/admin/users', async (request, reply) => { @@ -102,6 +134,7 @@ export async function adminRoutes(app: FastifyInstance): Promise { const target = await prisma.user.findUnique({ where: { id } }); if (!target) throw notFound('Nutzer'); + assertNichtGeschuetzt(target); const losesAdmin = (body.role !== undefined && body.role !== Role.ADMIN) || body.isActive === false; @@ -138,6 +171,7 @@ export async function adminRoutes(app: FastifyInstance): Promise { const target = await prisma.user.findUnique({ where: { id } }); if (!target) throw notFound('Nutzer'); + assertNichtGeschuetzt(target); await prisma.user.update({ where: { id }, @@ -166,6 +200,7 @@ export async function adminRoutes(app: FastifyInstance): Promise { const target = await prisma.user.findUnique({ where: { id } }); if (!target) throw notFound('Nutzer'); + assertNichtGeschuetzt(target); await assertNotLastAdmin(id); await prisma.user.delete({ where: { id } }); diff --git a/server/src/routes/characters.routes.ts b/server/src/routes/characters.routes.ts index d023f1c..95e8447 100644 --- a/server/src/routes/characters.routes.ts +++ b/server/src/routes/characters.routes.ts @@ -24,6 +24,7 @@ import { removeLink, moveInChain, detachFromChain, + changeRedesignKind, } from '../services/characters.js'; import { parseThreadId } from '../lib/forum.js'; import { linkThreadToCharacter } from '../services/forum.js'; @@ -74,6 +75,8 @@ export async function characterRoutes(app: FastifyInstance): Promise { z.object({ name: z.string().trim().min(1).max(160).optional(), loreText: z.string().trim().max(5000).nullable().optional(), + /// Thread-Nummer oder volle Adresse; leer löst die Verknüpfung. + threadRef: z.string().trim().max(500).nullable().optional(), }), request.body, ); @@ -178,6 +181,21 @@ export async function characterRoutes(app: FastifyInstance): Promise { return { ok: true }; }); + /** + * Nachträglich umstellen, woraus ein Redesign bezahlt wurde. Umgebucht + * wird im Kontobuch, nicht überschrieben. + */ + app.post('/api/characters/:id/redesign-kind', { preHandler: requireEditor }, async (request) => { + const { id } = parseWith(idParam, request.params); + const body = parseWith( + z.object({ + useKind: z.enum([CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ]), + }), + request.body, + ); + return changeRedesignKind(id, body.useKind, contextOf(request)); + }); + // --- Ketten --- /** diff --git a/server/src/services/characters.ts b/server/src/services/characters.ts index b9ca5f4..a6451f1 100644 --- a/server/src/services/characters.ts +++ b/server/src/services/characters.ts @@ -3,6 +3,7 @@ import { CharacterStatus, CreditKind, CreditSource, + ForumClassification, InactiveReason, type Character, type Prisma, @@ -417,15 +418,24 @@ export async function reactivatePaid( return { character, balances, overdrawn: isOverdrawn(balances) }; } +/** + * Ändert die Angaben eines CCs: Name, Beschreibung und den Antrag, aus dem + * er hervorging. + * + * Der Antrag wird über seine Thread-Nummer oder die volle Adresse angegeben; + * ein leeres Feld löst die bestehende Verknüpfung. Kennt der Abgleich den + * Thread noch nicht, wird er vorgemerkt - sonst stünde er beim nächsten Lauf + * als offener Antrag da, obwohl er längst eingetragen ist. + */ export async function updateCharacter( characterId: number, - input: { name?: string; loreText?: string | null }, + input: { name?: string; loreText?: string | null; threadRef?: string | null }, ctx: ActionContext, ): Promise { - await loadCharacter(characterId); + const vorher = await loadCharacter(characterId); - return prisma.$transaction(async (tx) => { - const updated = await tx.character.update({ + const updated = await prisma.$transaction(async (tx) => { + const geaendert = await tx.character.update({ where: { id: characterId }, data: { ...(input.name !== undefined ? { name: input.name } : {}), @@ -437,11 +447,94 @@ export async function updateCharacter( action: 'character.update', targetType: 'character', targetId: characterId, - details: { name: updated.name }, + details: { name: geaendert.name, vorher: vorher.name }, }); - return updated; + return geaendert; }); + + if (input.threadRef !== undefined) { + await setCharacterThread(characterId, input.threadRef, ctx); + } + + return updated; +} + +/** + * Hängt einen Forums-Antrag an einen CC oder löst ihn wieder. + * + * Angenommen wird die Thread-Nummer, der Pfad oder die volle Adresse - beim + * Eintippen von Hand landet mal das eine, mal das andere im Feld. + */ +export async function setCharacterThread( + characterId: number, + ref: string | null, + ctx: ActionContext, +): Promise { + const character = await loadCharacter(characterId); + const { parseThreadId } = await import('../lib/forum.js'); + const { linkThreadToCharacter } = await import('./forum.js'); + + const bisher = await prisma.forumThread.findFirst({ + where: { linkedCharacterId: characterId }, + select: { threadId: true }, + }); + + const eingabe = ref?.trim() ?? ''; + const threadId = eingabe.length === 0 ? null : parseThreadId(eingabe); + + if (eingabe.length > 0 && threadId === null) { + throw badRequest( + 'INVALID_THREAD', + 'Das ist keine Thread-Nummer und keine Forums-Adresse', + ); + } + if (bisher?.threadId === threadId) return; + + // Die alte Verknüpfung fällt weg. Der Thread bleibt als entschieden + // vermerkt: er wurde ja bearbeitet, nur hängt er jetzt an keinem CC mehr. + if (bisher) { + await prisma.forumThread.update({ + where: { threadId: bisher.threadId }, + data: { linkedCharacterId: null }, + }); + } + + if (threadId === null) { + await recordAudit(prisma, ctx, { + action: 'character.thread_unlinked', + targetType: 'character', + targetId: characterId, + details: { name: character.name, threadId: bisher?.threadId ?? null }, + }); + return; + } + + const vorhanden = await prisma.forumThread.findUnique({ where: { threadId } }); + if (vorhanden) { + await prisma.forumThread.update({ + where: { threadId }, + data: { + linkedCharacterId: characterId, + needsClassification: false, + classifiedById: ctx.actorId, + classifiedAt: vorhanden.classifiedAt ?? new Date(), + playerId: vorhanden.playerId ?? character.playerId, + }, + }); + + await recordAudit(prisma, ctx, { + action: 'character.thread_linked', + targetType: 'character', + targetId: characterId, + details: { name: character.name, threadId }, + }); + return; + } + + // Unbekannter Thread: über denselben Weg vormerken wie bei einem von Hand + // eingetragenen Antrag, damit er nicht als offen wieder auftaucht. + await linkThreadToCharacter(threadId, characterId, ForumClassification.NEW_CC, ctx); } /** @@ -637,6 +730,96 @@ export async function reviveDeadCharacter( } +/** + * Stellt nachträglich um, woraus ein Redesign bezahlt wurde. + * + * Ob ein Redesign aus einem gekauften Paket oder aus Kulanz kam, stellt sich + * manchmal erst später heraus - etwa wenn eine Absprache auftaucht, von der + * beim Eintragen niemand wusste. Umgebucht wird nicht durch Überschreiben, + * sondern wie jede Korrektur im Kontobuch: die alte Zeile wird + * gegengebucht, die neue kommt dazu. So bleibt sichtbar, dass hier etwas + * geändert wurde, und wann. + */ +export async function changeRedesignKind( + characterId: number, + useKind: RedesignKind, + ctx: ActionContext, +): Promise { + const character = await loadCharacter(characterId); + + if (!REDESIGN_KINDS.includes(useKind)) { + throw badRequest('INVALID_KIND', 'Ein Redesign kostet bezahltes oder Kulanz-Redesign-Guthaben'); + } + + // Die Buchung, mit der dieser CC bezahlt wurde. Stornierte zählen nicht + // mehr; offen ist immer höchstens eine. + const buchung = await prisma.creditLedger.findFirst({ + where: { + characterId, + source: CreditSource.CONSUME, + kind: { in: [...REDESIGN_KINDS] }, + reversedBy: null, + }, + orderBy: { id: 'desc' }, + }); + if (!buchung) { + throw conflict( + 'NO_REDESIGN_BOOKING', + 'Für diesen CC ist kein offenes Redesign gebucht - hier gibt es nichts umzustellen', + ); + } + if (buchung.kind === useKind) { + throw conflict('SAME_KIND', 'Dieses Redesign ist bereits so gebucht'); + } + + const grund = + useKind === CreditKind.REDESIGN_KULANZ + ? 'Umgestellt auf Kulanz' + : 'Umgestellt auf bezahltes Redesign'; + + await prisma.$transaction(async (tx) => { + await tx.creditLedger.create({ + data: { + playerId: buchung.playerId, + kind: buchung.kind, + amount: -buchung.amount, + source: CreditSource.REVERSAL, + groupId: buchung.groupId, + characterId, + reversalOfId: buchung.id, + note: `${grund}: ${character.name}`, + createdById: ctx.actorId, + occurredAt: buchung.occurredAt, + }, + }); + + await tx.creditLedger.create({ + data: { + playerId: buchung.playerId, + kind: useKind, + amount: buchung.amount, + source: CreditSource.CONSUME, + groupId: randomUUID(), + characterId, + note: buchung.note ?? `Redesign: ${character.name}`, + createdById: ctx.actorId, + // Bezahlt wurde damals, nicht heute. + occurredAt: buchung.occurredAt, + }, + }); + + await recordAudit(tx, ctx, { + action: 'character.redesign_kind_changed', + targetType: 'character', + targetId: characterId, + details: { name: character.name, from: buchung.kind, to: useKind }, + }); + }); + + const balances = await getBalances(character.playerId); + return { character, balances, overdrawn: isOverdrawn(balances) }; +} + // --- Ketten --- /** @@ -653,16 +836,38 @@ async function verketteNeu(tx: Prisma.TransactionClient, lineageId: string): Pro const glieder = await tx.character.findMany({ where: { lineageId }, orderBy: [{ chainPosition: 'asc' }, { createdAt: 'asc' }, { id: 'asc' }], - select: { id: true, predecessorId: true }, + select: { id: true, predecessorId: true, status: true, inactiveReason: true }, }); let vorheriges: number | null = null; - for (const glied of glieder) { + for (const [index, glied] of glieder.entries()) { + const letztes = index === glieder.length - 1; + const daten: Prisma.CharacterUpdateInput = {}; + if (glied.predecessorId !== vorheriges) { - await tx.character.update({ - where: { id: glied.id }, - data: { predecessorId: vorheriges }, - }); + daten.predecessor = vorheriges === null ? { disconnect: true } : { connect: { id: vorheriges } }; + } + + // Was einen Nachfolger hat, wurde durch ihn ersetzt - anders ist eine + // Kette nicht zu lesen. Ein Glied mittendrin kann also nicht aktiv sein + // und auch nicht "geputzt": es ist redesignt, Punkt. + if (!letztes && glied.inactiveReason !== InactiveReason.REWORKED) { + daten.status = CharacterStatus.INACTIVE; + daten.inactiveReason = InactiveReason.REWORKED; + daten.inactiveNote = null; + } + + // Umgekehrt: Wer ans Ende rückt, ist von niemandem mehr ersetzt worden. + // Die Markierung wäre dann schlicht falsch - sie stammt von einem + // Nachfolger, der inzwischen woanders steht. + if (letztes && glied.inactiveReason === InactiveReason.REWORKED) { + daten.status = CharacterStatus.ACTIVE; + daten.inactiveReason = null; + daten.inactiveNote = null; + } + + if (Object.keys(daten).length > 0) { + await tx.character.update({ where: { id: glied.id }, data: daten }); } vorheriges = glied.id; } @@ -727,6 +932,11 @@ export async function moveInChain( await verketteNeu(tx, input.lineageId); if (alteLinie !== input.lineageId) await verketteNeu(tx, alteLinie); + // Das Neuordnen kann einen CC ans Ende rücken und damit wieder aktiv + // machen. Ein Platz entsteht dadurch nicht: er muss gedeckt sein wie + // jeder andere. + await assertSlotsReichen(tx, character.playerId, 'Dieses Verschieben'); + await recordAudit(tx, ctx, { action: 'character.moved_in_chain', targetType: 'character', @@ -755,12 +965,23 @@ export async function detachFromChain( const neueLinie = randomUUID(); await prisma.$transaction(async (tx) => { + // Allein in einer neuen Linie ist er zugleich Anfang und Ende. Eine + // Redesign-Markierung stammte von einem Nachfolger, der jetzt woanders + // steht - sie wäre nur noch falsch. await tx.character.update({ where: { id: characterId }, - data: { lineageId: neueLinie, chainPosition: 0, predecessorId: null }, + data: { + lineageId: neueLinie, + chainPosition: 0, + predecessorId: null, + ...(character.inactiveReason === InactiveReason.REWORKED + ? { status: CharacterStatus.ACTIVE, inactiveReason: null, inactiveNote: null } + : {}), + }, }); await verketteNeu(tx, character.lineageId); + await assertSlotsReichen(tx, character.playerId, 'Dieses Herauslösen'); await recordAudit(tx, ctx, { action: 'character.detached', diff --git a/server/test/http.test.ts b/server/test/http.test.ts index 6b55715..7d9041d 100644 --- a/server/test/http.test.ts +++ b/server/test/http.test.ts @@ -219,6 +219,115 @@ describe('Rollen', () => { }); }); +describe('Der Zugang aus der Erstinstallation', () => { + /** Legt einen geschützten Admin an, wie es der erste Start tut. */ + async function erstzugang() { + return db.prisma.user.create({ + data: { + username: 'erstzugang', + passwordHash: 'x', + role: 'ADMIN', + isProtected: true, + }, + }); + } + + it('lässt sich seine Rolle nicht nehmen', async () => { + const geschuetzt = await erstzugang(); + const credentials = await login('chef'); + + const response = await app.inject({ + method: 'PATCH', + url: `/api/admin/users/${geschuetzt.id}`, + headers: authHeaders(credentials), + payload: { role: 'VIEWER' }, + }); + + assert.equal(response.statusCode, 409); + assert.equal(response.json().error, 'PROTECTED_ACCOUNT'); + + const unveraendert = await db.prisma.user.findUniqueOrThrow({ where: { id: geschuetzt.id } }); + assert.equal(unveraendert.role, 'ADMIN'); + }); + + it('lässt sich nicht sperren', async () => { + const geschuetzt = await db.prisma.user.findFirstOrThrow({ where: { isProtected: true } }); + const credentials = await login('chef'); + + const response = await app.inject({ + method: 'PATCH', + url: `/api/admin/users/${geschuetzt.id}`, + headers: authHeaders(credentials), + payload: { isActive: false }, + }); + + assert.equal(response.statusCode, 409); + }); + + it('bekommt sein Passwort nicht von aussen zurückgesetzt', async () => { + const geschuetzt = await db.prisma.user.findFirstOrThrow({ where: { isProtected: true } }); + const credentials = await login('chef'); + + const response = await app.inject({ + method: 'POST', + url: `/api/admin/users/${geschuetzt.id}/reset-password`, + headers: authHeaders(credentials), + payload: { newPassword: 'EinLangesPasswort1' }, + }); + + assert.equal(response.statusCode, 409); + }); + + it('lässt sich nicht löschen', async () => { + const geschuetzt = await db.prisma.user.findFirstOrThrow({ where: { isProtected: true } }); + const credentials = await login('chef'); + + const response = await app.inject({ + method: 'DELETE', + url: `/api/admin/users/${geschuetzt.id}`, + headers: authHeaders(credentials), + }); + + assert.equal(response.statusCode, 409); + assert.ok(await db.prisma.user.findUnique({ where: { id: geschuetzt.id } })); + }); + + it('ist in der Liste als geschützt ausgewiesen', async () => { + const credentials = await login('chef'); + + const response = await app.inject({ + method: 'GET', + url: '/api/admin/users', + headers: authHeaders(credentials), + }); + + const eintrag = response + .json() + .items.find((u: { username: string }) => u.username === 'erstzugang'); + assert.equal(eintrag.isLocked, true, 'die Oberfläche soll die Knöpfe gar nicht erst zeigen'); + }); + + it('lässt gewöhnliche Konten unangetastet', async () => { + const credentials = await login('chef'); + const normal = await db.prisma.user.findFirstOrThrow({ where: { username: 'gast' } }); + + const response = await app.inject({ + method: 'PATCH', + url: `/api/admin/users/${normal.id}`, + headers: authHeaders(credentials), + payload: { role: 'EDITOR' }, + }); + + assert.equal(response.statusCode, 200, 'der Schutz gilt nur für den Erstzugang'); + await app.inject({ + method: 'PATCH', + url: `/api/admin/users/${normal.id}`, + headers: authHeaders(credentials), + payload: { role: 'VIEWER' }, + }); + }); +}); + describe('Erstpasswort', () => { it('sperrt schreibende Aktionen bis zum Wechsel', async () => { const credentials = await login('neu'); diff --git a/server/test/ketten.test.ts b/server/test/ketten.test.ts index b7fc69e..6d69b61 100644 --- a/server/test/ketten.test.ts +++ b/server/test/ketten.test.ts @@ -152,15 +152,23 @@ describe('Lücken in einer Redesign-Kette schliessen', () => { verkettungStimmt(zweite); }); - it('löst einen Eintrag wieder heraus', async () => { + it('holt einen falsch einsortierten Eintrag wieder heraus', async () => { + // Zwei eigenständige CCs, einer davon versehentlich in die Kette des + // anderen gezogen. Beide sind aus einem eigenen Paket bezahlt. const spieler = await spielerMitPaketen('Loeser', 2); const eins = await characters.createCharacter(spieler, { name: 'Bleibt' }, ctx); - await credits.bookRedesignPackage(spieler, ctx); - const zwei = await characters.redesignCharacter( - eins.character.id, - { name: 'Geht wieder', useKind: 'REDESIGN_PAID' }, + const zwei = await characters.createCharacter(spieler, { name: 'Gehoert woanders hin' }, ctx); + + await characters.moveInChain( + zwei.character.id, + { lineageId: eins.character.lineageId, afterId: eins.character.id }, ctx, ); + // In der Kette gilt: wer einen Nachfolger hat, wurde ersetzt. + const eingereiht = await db.prisma.character.findUniqueOrThrow({ + where: { id: eins.character.id }, + }); + assert.equal(eingereiht.inactiveReason, 'REWORKED'); const { lineageId } = await characters.detachFromChain(zwei.character.id, ctx); assert.notEqual(lineageId, eins.character.lineageId); @@ -168,12 +176,67 @@ describe('Lücken in einer Redesign-Kette schliessen', () => { const alte = await kette(spieler, eins.character.lineageId); assert.deepEqual(alte.map((g) => g.name), ['Bleibt']); + // Und ohne Nachfolger ist "redesignt" hinfällig. + const zurueck = await db.prisma.character.findUniqueOrThrow({ + where: { id: eins.character.id }, + }); + assert.equal(zurueck.status, 'ACTIVE'); + assert.equal(zurueck.inactiveReason, null); + const allein = await db.prisma.character.findUniqueOrThrow({ where: { id: zwei.character.id }, }); assert.equal(allein.predecessorId, null, 'allein heisst ohne Vorgänger'); }); + it('lässt ein Redesign nicht ohne eigenen Platz allein stehen', async () => { + // Ein Redesign hat keine eigene CC-Buchung - es tauscht einen Platz aus. + // Allein gestellt braucht es einen, und der muss gedeckt sein. + const spieler = await spielerMitPaketen('Knapp', 1); + const eins = await characters.createCharacter(spieler, { name: 'Original' }, ctx); + await credits.bookRedesignPackage(spieler, ctx); + const zwei = await characters.redesignCharacter( + eins.character.id, + { name: 'Neue Fassung', useKind: 'REDESIGN_PAID' }, + ctx, + ); + + await assert.rejects( + () => characters.detachFromChain(zwei.character.id, ctx), + (error: Error & { code?: string }) => error.code === 'NO_FREE_SLOT', + 'sonst stünden zwei aktive CCs auf einem Paket', + ); + }); + + it('räumt die Redesign-Markierung ab, wenn der Nachfolger wegzieht', async () => { + // Genau der Fall aus dem Betrieb: das Redesign gehörte in eine andere + // Kette. Was zurückbleibt, ist von niemandem mehr ersetzt worden. + const spieler = await spielerMitPaketen('Umsortiert', 2); + const platzA = await characters.createCharacter(spieler, { name: 'Platz A' }, ctx); + const platzB = await characters.createCharacter(spieler, { name: 'Platz B' }, ctx); + + await credits.bookRedesignPackage(spieler, ctx); + const redesign = await characters.redesignCharacter( + platzB.character.id, + { name: 'Redesign von B', useKind: 'REDESIGN_PAID' }, + ctx, + ); + + // B ist jetzt redesignt. Nun stellt sich heraus: das Redesign gehört zu A. + await characters.moveInChain( + redesign.character.id, + { lineageId: platzA.character.lineageId, afterId: platzA.character.id }, + ctx, + ); + + const b = await db.prisma.character.findUniqueOrThrow({ where: { id: platzB.character.id } }); + assert.equal(b.status, 'ACTIVE', 'ohne Nachfolger ist B wieder der aktuelle CC'); + assert.equal(b.inactiveReason, null); + + const a = await db.prisma.character.findUniqueOrThrow({ where: { id: platzA.character.id } }); + assert.equal(a.inactiveReason, 'REWORKED', 'A hat jetzt einen Nachfolger'); + }); + it('lässt keinen CC in die Kette eines anderen Spielers', async () => { const einer = await spielerMitPaketen('Einer', 1); const anderer = await spielerMitPaketen('Anderer', 1); @@ -206,7 +269,7 @@ describe('Lücken in einer Redesign-Kette schliessen', () => { ); }); - it('rührt den Status nicht an', async () => { + it('macht aus einem eingereihten CC einen redesignten', async () => { const spieler = await spielerMitPaketen('Unberuehrt', 2); const aktiv = await characters.createCharacter(spieler, { name: 'Aktiv' }, ctx); const ziel = await characters.createCharacter(spieler, { name: 'Ziel' }, ctx); @@ -220,7 +283,10 @@ describe('Lücken in einer Redesign-Kette schliessen', () => { const danach = await db.prisma.character.findUniqueOrThrow({ where: { id: aktiv.character.id }, }); - assert.equal(danach.status, 'ACTIVE', 'verschieben ordnet nur ein, es entscheidet nichts'); + assert.equal(danach.status, 'ACTIVE', 'am Ende der Kette bleibt er der aktuelle'); + + const davor = await db.prisma.character.findUniqueOrThrow({ where: { id: ziel.character.id } }); + assert.equal(davor.inactiveReason, 'REWORKED', 'wer einen Nachfolger bekommt, ist ersetzt'); }); }); @@ -277,3 +343,154 @@ describe('Verweise an einem CC', () => { assert.equal(await db.prisma.characterLink.count({ where: { characterId: cc.character.id } }), 0); }); }); + +describe('Redesign nachträglich umbuchen', () => { + it('stellt von bezahlt auf Kulanz um, ohne die Geschichte zu verbiegen', async () => { + const spieler = await spielerMitPaketen('Umbucher', 1); + const eins = await characters.createCharacter(spieler, { name: 'Erste Fassung' }, ctx); + const zwei = await characters.redesignCharacter( + eins.character.id, + { name: 'Zweite Fassung', useKind: 'REDESIGN_PAID' }, + ctx, + ); + assert.equal(zwei.balances.REDESIGN_PAID, 0, 'das bezahlte ist verbraucht'); + + await credits.bookKulanz(spieler, 1, 'Absprache aufgetaucht', ctx); + const danach = await characters.changeRedesignKind(zwei.character.id, 'REDESIGN_KULANZ', ctx); + + assert.equal(danach.balances.REDESIGN_PAID, 1, 'das bezahlte ist wieder frei'); + assert.equal(danach.balances.REDESIGN_KULANZ, 0, 'dafür ist die Kulanz verbraucht'); + + // Korrigiert wird durch Gegenbuchung, nicht durch Überschreiben. + const zeilen = await db.prisma.creditLedger.findMany({ + where: { characterId: zwei.character.id }, + orderBy: { id: 'asc' }, + }); + assert.equal(zeilen.length, 3, 'Verbrauch, Storno, neuer Verbrauch'); + assert.equal(zeilen[1]?.source, 'REVERSAL'); + assert.equal(zeilen[2]?.kind, 'REDESIGN_KULANZ'); + }); + + it('und wieder zurück', async () => { + const spieler = await spielerMitPaketen('Hin und her', 1); + const eins = await characters.createCharacter(spieler, { name: 'A' }, ctx); + await credits.bookKulanz(spieler, 1, 'Erstmal Kulanz', ctx); + const zwei = await characters.redesignCharacter( + eins.character.id, + { name: 'B', useKind: 'REDESIGN_KULANZ' }, + ctx, + ); + + const danach = await characters.changeRedesignKind(zwei.character.id, 'REDESIGN_PAID', ctx); + assert.equal(danach.balances.REDESIGN_KULANZ, 1); + assert.equal(danach.balances.REDESIGN_PAID, 0, 'das aus dem CC-Paket ist jetzt dran'); + }); + + it('weist dieselbe Art ab', async () => { + const spieler = await spielerMitPaketen('Unveraendert', 1); + const eins = await characters.createCharacter(spieler, { name: 'X' }, ctx); + const zwei = await characters.redesignCharacter( + eins.character.id, + { name: 'Y', useKind: 'REDESIGN_PAID' }, + ctx, + ); + + await assert.rejects( + () => characters.changeRedesignKind(zwei.character.id, 'REDESIGN_PAID', ctx), + (error: Error & { code?: string }) => error.code === 'SAME_KIND', + ); + }); + + it('weist einen CC ohne Redesign-Buchung ab', async () => { + const spieler = await spielerMitPaketen('Ohne Redesign', 1); + const eins = await characters.createCharacter(spieler, { name: 'Direkt aus dem Paket' }, ctx); + + await assert.rejects( + () => characters.changeRedesignKind(eins.character.id, 'REDESIGN_KULANZ', ctx), + (error: Error & { code?: string }) => error.code === 'NO_REDESIGN_BOOKING', + 'ein CC aus dem Paket ist kein Redesign', + ); + }); +}); + +describe('Angaben eines CCs nachtragen', () => { + it('ändert Namen und Beschreibung', async () => { + const spieler = await spielerMitPaketen('Korrigierer', 1); + const cc = await characters.createCharacter(spieler, { name: 'Tipfehler' }, ctx); + + await characters.updateCharacter( + cc.character.id, + { name: 'Tippfehler behoben', loreText: 'Jetzt mit Beschreibung' }, + ctx, + ); + + const danach = await db.prisma.character.findUniqueOrThrow({ where: { id: cc.character.id } }); + assert.equal(danach.name, 'Tippfehler behoben'); + assert.equal(danach.loreText, 'Jetzt mit Beschreibung'); + }); + + it('hängt einen Antrag an, den der Abgleich schon kennt', async () => { + const spieler = await spielerMitPaketen('Verknuepfer', 1); + const cc = await characters.createCharacter(spieler, { name: 'Mit Antrag' }, ctx); + + await db.prisma.forumThread.create({ + data: { + threadId: 7771, + title: 'Sein Antrag', + url: 'https://forum.example.org/index.php?thread/7771-sein-antrag/', + currentBoard: 'CC_ARCHIV', + }, + }); + + await characters.updateCharacter( + cc.character.id, + { threadRef: 'https://forum.example.org/index.php?thread/7771-sein-antrag/' }, + ctx, + ); + + const thread = await db.prisma.forumThread.findUniqueOrThrow({ where: { threadId: 7771 } }); + assert.equal(thread.linkedCharacterId, cc.character.id); + assert.equal(thread.needsClassification, false, 'er ist damit erledigt'); + }); + + it('merkt einen unbekannten Antrag vor', async () => { + const spieler = await spielerMitPaketen('Vormerker', 1); + const cc = await characters.createCharacter(spieler, { name: 'Noch ungelesen' }, ctx); + + // Nur die Nummer, und der Abgleich kennt den Thread nicht. + await characters.updateCharacter(cc.character.id, { threadRef: '8888' }, ctx); + + const thread = await db.prisma.forumThread.findUniqueOrThrow({ where: { threadId: 8888 } }); + assert.equal(thread.linkedCharacterId, cc.character.id); + assert.equal( + thread.needsClassification, + false, + 'sonst stünde er beim nächsten Lauf als offener Antrag da', + ); + }); + + it('löst eine Verknüpfung wieder', async () => { + const spieler = await spielerMitPaketen('Loeser2', 1); + const cc = await characters.createCharacter(spieler, { name: 'Doch nicht' }, ctx); + await characters.updateCharacter(cc.character.id, { threadRef: '9999' }, ctx); + + await characters.updateCharacter(cc.character.id, { threadRef: null }, ctx); + + const thread = await db.prisma.forumThread.findUniqueOrThrow({ where: { threadId: 9999 } }); + assert.equal(thread.linkedCharacterId, null); + + const liste = await characters.listCharacters(spieler); + const eintrag = liste.find((c) => c.id === cc.character.id); + assert.deepEqual(eintrag?.forumThreads, [], 'am CC hängt kein Antrag mehr'); + }); + + it('weist eine Eingabe ab, die keine Thread-Angabe ist', async () => { + const spieler = await spielerMitPaketen('Vertipper', 1); + const cc = await characters.createCharacter(spieler, { name: 'Sauber' }, ctx); + + await assert.rejects( + () => characters.updateCharacter(cc.character.id, { threadRef: 'keine ahnung' }, ctx), + (error: Error & { code?: string }) => error.code === 'INVALID_THREAD', + ); + }); +}); diff --git a/web/src/components/ChainView.tsx b/web/src/components/ChainView.tsx index bf0e4bc..47a4f62 100644 --- a/web/src/components/ChainView.tsx +++ b/web/src/components/ChainView.tsx @@ -27,7 +27,9 @@ export type ChainAction = | 'reactivateFree' | 'reactivatePaid' | 'reviveDead' - | 'links'; + | 'links' + | 'redesignKind' + | 'edit'; interface Kette { lineageId: string; @@ -487,6 +489,19 @@ function ChainEntry({ )} + + {character.predecessorId !== null && ( + + )} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index b52d73b..5bcabdd 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -202,6 +202,9 @@ export interface AdminUser { mustChangePassword: boolean; lastLoginAt: string | null; createdAt: string; + /// Der Zugang aus der Erstinstallation, solange er geschützt ist: an ihm + /// lässt sich nichts ändern, auch nicht durch ihn selbst. + isLocked?: boolean; } export interface ProductMapping { diff --git a/web/src/lib/useRefreshOnFocus.ts b/web/src/lib/useRefreshOnFocus.ts new file mode 100644 index 0000000..036bb2c --- /dev/null +++ b/web/src/lib/useRefreshOnFocus.ts @@ -0,0 +1,26 @@ +import { useEffect } from 'react'; + +/** + * Lädt neu, sobald der Tab wieder in den Vordergrund kommt. + * + * An dieser Anwendung arbeiten mehrere Leute gleichzeitig. Wer eine Seite + * offen hat, während ein anderer etwas einträgt, sieht sonst einen Stand von + * vor einer halben Stunde - und merkt es nicht. Beim Zurückkehren an den Tab + * zu laden trifft genau den Moment, in dem jemand wieder hinsieht, ohne im + * Hintergrund dauernd Anfragen zu schicken. + */ +export function useRefreshOnFocus(reload: () => void | Promise): void { + useEffect(() => { + const beiRueckkehr = () => { + if (document.visibilityState === 'visible') void reload(); + }; + + document.addEventListener('visibilitychange', beiRueckkehr); + window.addEventListener('focus', beiRueckkehr); + + return () => { + document.removeEventListener('visibilitychange', beiRueckkehr); + window.removeEventListener('focus', beiRueckkehr); + }; + }, [reload]); +} diff --git a/web/src/pages/Admin.tsx b/web/src/pages/Admin.tsx index 285f81c..4d9c9b6 100644 --- a/web/src/pages/Admin.tsx +++ b/web/src/pages/Admin.tsx @@ -159,12 +159,20 @@ function UsersTab({ onError, onDone }: TabProps) { {row.username} {row.id === me?.id && du} + {row.isLocked && ( + + geschützt + + )} {row.mustChangePassword && Erstpasswort} setUseKind(wert)} + /> + + {label} + ({stand} verfügbar) + + + ))} + + +
+ + +
+ + ); +} + +/** + * Ändert die Angaben eines CCs. Der Antrag wird über seine Nummer oder die + * volle Adresse angegeben - beim Eintippen landet mal das eine, mal das + * andere im Feld, also nimmt das Feld beides. + */ +function EditCharacterModal({ + character, + busy, + onClose, + onSubmit, +}: { + character: Character; + busy: boolean; + onClose: () => void; + onSubmit: (values: { name: string; loreText: string | null; threadRef: string | null }) => void; +}) { + const [name, setName] = useState(character.name); + const [lore, setLore] = useState(character.loreText ?? ''); + const [thread, setThread] = useState(character.forumThreads?.[0]?.url ?? ''); + + return ( + +
{ + event.preventDefault(); + onSubmit({ + name: name.trim(), + loreText: lore.trim() || null, + threadRef: thread.trim() || null, + }); + }} + > + + setName(event.target.value)} + required + autoFocus + /> + + + setThread(event.target.value)} + placeholder="Adresse oder Thread-Nummer, leer = kein Antrag" + /> +

+ Der Name des CCs führt in der Liste dorthin. Ein unbekannter Thread wird vorgemerkt, + damit er beim nächsten Abgleich nicht als offener Antrag auftaucht. +

+ + +