Erstzugang schuetzen, Kettenstatus richtigstellen, CCs nachtraeglich aendern

Der Zugang aus der Erstinstallation ist ab Werk unantastbar: weder Rolle noch
Zustand lassen sich aendern, sein Passwort nicht von aussen zuruecksetzen, und
loeschen laesst er sich nicht - auch nicht durch sich selbst. So bleibt ein
Weg zurueck, wenn sich jemand aus Versehen die Rechte entzieht. Sein eigenes
Passwort aendert er weiterhin unter "Konto". BOOTSTRAP_ADMIN_EDITABLE=true
hebt den Schutz auf. Welcher Zugang gemeint ist, traegt der Start nach: den
Namen kennt nur er, die Migration nicht.

Ein CC behielt nach dem Verschieben seines Redesigns den Vermerk "Redesignt",
obwohl ueber ihm nichts mehr stand. Der Vermerk stammte von einem Nachfolger,
der inzwischen woanders lag. Jetzt folgt er der Kette: wer einen Nachfolger
hat, wurde ersetzt - wer ans Ende rueckt, nicht mehr. Ein Platz entsteht
dabei nicht aus dem Nichts, die Deckung wird geprueft wie sonst auch.

Neu bearbeitbar: Name, Beschreibung und der verknuepfte Forums-Antrag eines
CCs. Ein Antrag, den der Abgleich noch nicht kennt, wird dabei vorgemerkt -
sonst staende er beim naechsten Lauf als offener Antrag da, obwohl er laengst
eingetragen ist. Ebenso laesst sich nachtraeglich umstellen, ob ein Redesign
bezahlt oder Kulanz war; umgebucht wird ueber eine Gegenbuchung, ueberschrieben
wird nichts.

Zuletzt: Eintraege eines anderen Editors kamen nicht an, solange die Seite
offen stand. Jetzt wird der Stand geholt, sobald der Tab wieder in den
Vordergrund kommt - das trifft den Moment, in dem jemand wieder hinsieht,
ohne im Hintergrund dauernd zu fragen.
This commit is contained in:
2026-09-21 14:36:26 +02:00
parent c82391e73f
commit 2527f6a240
19 changed files with 941 additions and 34 deletions
+16 -1
View File
@@ -27,7 +27,9 @@ export type ChainAction =
| 'reactivateFree'
| 'reactivatePaid'
| 'reviveDead'
| 'links';
| 'links'
| 'redesignKind'
| 'edit';
interface Kette {
lineageId: string;
@@ -487,6 +489,19 @@ function ChainEntry({
</button>
)}
<button type="button" className="btn btn-tiny btn-ghost" onClick={() => onAction('edit', character)}>
Bearbeiten
</button>
{character.predecessorId !== null && (
<button
type="button"
className="btn btn-tiny btn-ghost"
title="Bezahltes Redesign oder Kulanz? Lässt sich nachträglich umbuchen."
onClick={() => onAction('redesignKind', character)}
>
Bezahlart
</button>
)}
<button type="button" className="btn btn-tiny btn-ghost" onClick={() => onAction('links', character)}>
Verweise
</button>
+3
View File
@@ -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 {
+26
View File
@@ -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>): 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]);
}
+25 -9
View File
@@ -159,12 +159,20 @@ function UsersTab({ onError, onDone }: TabProps) {
<td>
<strong>{row.username}</strong>
{row.id === me?.id && <span className="badge">du</span>}
{row.isLocked && (
<span
className="badge"
title="Zugang aus der Erstinstallation - geschützt, damit immer ein Weg zurück bleibt"
>
geschützt
</span>
)}
{row.mustChangePassword && <span className="badge badge-paid">Erstpasswort</span>}
</td>
<td>
<select
value={row.role}
disabled={row.id === me?.id}
disabled={row.id === me?.id || row.isLocked === true}
onChange={(event) =>
void act(
() =>
@@ -185,14 +193,22 @@ function UsersTab({ onError, onDone }: TabProps) {
<td className="muted">{row.totpEnabled ? 'ja' : 'nein'}</td>
<td className="muted small">{formatDate(row.lastLoginAt)}</td>
<td className="row-actions">
<button
type="button"
className="btn btn-tiny btn-ghost"
onClick={() => resetPassword(row)}
>
Passwort
</button>
{row.id !== me?.id && (
{row.isLocked ? (
<span className="muted small">
{row.id === me?.id
? 'Passwort änderst du unter Konto'
: 'Erstzugang - unantastbar'}
</span>
) : (
<button
type="button"
className="btn btn-tiny btn-ghost"
onClick={() => resetPassword(row)}
>
Passwort
</button>
)}
{row.id !== me?.id && !row.isLocked && (
<>
<button
type="button"
+4
View File
@@ -4,6 +4,7 @@ import Modal from '../components/Modal';
import Notice from '../components/Notice';
import { ApiError, api } from '../lib/api';
import RunProgress from '../components/RunProgress';
import { useRefreshOnFocus } from '../lib/useRefreshOnFocus';
import { useScrape } from '../lib/useScrape';
import { useSession } from '../lib/session';
import type {
@@ -83,6 +84,9 @@ export default function Dashboard() {
await Promise.all([load(query, nurProbleme), loadFlagged()]);
}, [load, loadFlagged, query, nurProbleme]);
// Andere tragen ebenfalls ein; beim Zurückkehren an den Tab den Stand holen.
useRefreshOnFocus(nachlesen);
const scrape = useScrape({
onDone: (bericht) => setSyncResult(bericht),
onError: setError,
+3
View File
@@ -5,6 +5,7 @@ import RunProgress from '../components/RunProgress';
import { AssignModal, ClassifyModal } from '../components/ThreadModals';
import { ApiError, api } from '../lib/api';
import { BOARD_LABEL, CLASSIFICATION_LABEL, formatDate } from '../lib/format';
import { useRefreshOnFocus } from '../lib/useRefreshOnFocus';
import { useSession } from '../lib/session';
import type {
ForumScanSummary,
@@ -108,6 +109,8 @@ export default function Forum() {
void load();
}, [load]);
useRefreshOnFocus(load);
// Beim Tippen kurz abwarten, statt jede Taste an den Server zu schicken.
useEffect(() => {
const timer = window.setTimeout(
+184 -1
View File
@@ -18,6 +18,7 @@ import {
formatDate,
} from '../lib/format';
import RunProgress from '../components/RunProgress';
import { useRefreshOnFocus } from '../lib/useRefreshOnFocus';
import { useScrape } from '../lib/useScrape';
import { useSession } from '../lib/session';
import type {
@@ -49,7 +50,9 @@ type DialogState =
| { type: 'problem' }
| { type: 'memberRedesign'; character: Character }
| { type: 'memberDeactivate'; character: Character }
| { type: 'links'; character: Character };
| { type: 'links'; character: Character }
| { type: 'redesignKind'; character: Character }
| { type: 'edit'; character: Character };
export default function PlayerDetail() {
const { id } = useParams<{ id: string }>();
@@ -79,6 +82,9 @@ export default function PlayerDetail() {
void load();
}, [load]);
// Andere tragen ebenfalls ein; beim Zurückkehren an den Tab den Stand holen.
useRefreshOnFocus(load);
const run = async (action: () => Promise<unknown>, message: string) => {
setBusy(true);
setError(null);
@@ -457,6 +463,40 @@ export default function PlayerDetail() {
/>
)}
{dialog.type === 'edit' && (
<EditCharacterModal
character={dialog.character}
busy={busy}
onClose={() => setDialog({ type: 'none' })}
onSubmit={(values) =>
void run(
() =>
api(`/api/characters/${dialog.character.id}`, { method: 'PATCH', body: values }),
`"${values.name}" gespeichert.`,
)
}
/>
)}
{dialog.type === 'redesignKind' && (
<RedesignKindModal
character={dialog.character}
balances={detail.balances}
busy={busy}
onClose={() => setDialog({ type: 'none' })}
onSubmit={(useKind) =>
void run(
() =>
api(`/api/characters/${dialog.character.id}/redesign-kind`, {
method: 'POST',
body: { useKind },
}),
`Bezahlart von "${dialog.character.name}" umgebucht.`,
)
}
/>
)}
{dialog.type === 'links' && (
<LinksModal
character={dialog.character}
@@ -1656,3 +1696,146 @@ function ProblemModal({
</Modal>
);
}
/**
* Stellt um, woraus ein Redesign bezahlt wurde.
*
* Umgebucht wird im Kontobuch: die alte Zeile wird gegengebucht, die neue
* kommt dazu. Überschrieben wird nichts - in der Historie bleibt sichtbar,
* dass hier etwas geändert wurde.
*/
function RedesignKindModal({
character,
balances,
busy,
onClose,
onSubmit,
}: {
character: Character;
balances: { REDESIGN_PAID: number; REDESIGN_KULANZ: number };
busy: boolean;
onClose: () => void;
onSubmit: (useKind: CreditKind) => void;
}) {
const [useKind, setUseKind] = useState<CreditKind>('REDESIGN_KULANZ');
return (
<Modal title={`Bezahlart von "${character.name}"`} onClose={onClose}>
<Notice kind="info">
Ob ein Redesign aus einem gekauften Paket oder aus Kulanz kam, stellt sich manchmal erst
später heraus. Umgebucht wird über eine Gegenbuchung - in der Historie bleibt beides
sichtbar.
</Notice>
<div className="radio-group">
{(
[
['REDESIGN_KULANZ', 'Kulanz-Redesign', balances.REDESIGN_KULANZ],
['REDESIGN_PAID', 'Bezahltes Redesign', balances.REDESIGN_PAID],
] as const
).map(([wert, label, stand]) => (
<label key={wert} className="radio">
<input
type="radio"
name="redesignKind"
checked={useKind === wert}
onChange={() => setUseKind(wert)}
/>
<span>
{label}
<span className={stand < 1 ? 'neg' : 'muted'}> ({stand} verfügbar)</span>
</span>
</label>
))}
</div>
<div className="form-actions">
<button type="button" className="btn btn-ghost" onClick={onClose}>
Abbrechen
</button>
<button
type="button"
className="btn btn-primary"
disabled={busy}
onClick={() => onSubmit(useKind)}
>
{busy ? 'Buche um …' : 'Umbuchen'}
</button>
</div>
</Modal>
);
}
/**
* Ä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 (
<Modal title={`"${character.name}" bearbeiten`} onClose={onClose}>
<form
onSubmit={(event) => {
event.preventDefault();
onSubmit({
name: name.trim(),
loreText: lore.trim() || null,
threadRef: thread.trim() || null,
});
}}
>
<label htmlFor="edit-name">Name</label>
<input
id="edit-name"
value={name}
onChange={(event) => setName(event.target.value)}
required
autoFocus
/>
<label htmlFor="edit-thread">Forums-Antrag</label>
<input
id="edit-thread"
value={thread}
onChange={(event) => setThread(event.target.value)}
placeholder="Adresse oder Thread-Nummer, leer = kein Antrag"
/>
<p className="muted small">
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.
</p>
<label htmlFor="edit-lore">Beschreibung</label>
<textarea
id="edit-lore"
rows={4}
value={lore}
onChange={(event) => setLore(event.target.value)}
/>
<div className="form-actions">
<button type="button" className="btn btn-ghost" onClick={onClose}>
Abbrechen
</button>
<button type="submit" className="btn btn-primary" disabled={busy}>
{busy ? 'Speichere …' : 'Speichern'}
</button>
</div>
</form>
</Modal>
);
}