Workers P3: Nachträge aus der P2-Endprüfung (Kennungen, JWT-Sperre, Abmelden, Bestätigung)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-18 09:36:00 +00:00
1 parent c6c022678f
commit 777d5272bf
6 files changed
+279 -19

No files matched your search

+40 -4
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import Button from '../ui/Button'
import Dialog from '../ui/Dialog'
import EmptyState from '../ui/EmptyState'
import { useWorkerTrace, useWorkersFeed } from '../../hooks/useWorkersFeed'
import { indexWorkers, reduceEvents } from '../../lib/workersEvents'
@@ -34,6 +35,7 @@ export default function WorkersBoard({ api, isAdmin, stageRef }) {
const [settingsOpen, setSettingsOpen] = useState(false)
const [pausing, setPausing] = useState(false)
const [pauseError, setPauseError] = useState('')
const [confirmOpen, setConfirmOpen] = useState(false)
useEffect(() => {
aliveRef.current = true
@@ -53,6 +55,12 @@ export default function WorkersBoard({ api, isAdmin, stageRef }) {
const { trace, loading: traceLoading, error: traceError } = useWorkerTrace(api, selectedId)
// Hat inzwischen jemand anderes angehalten, entfällt die Frage; sie darf nach einem späteren Fortsetzen nicht wieder auftauchen
const haltedByState = Boolean(state?.paused)
useEffect(() => {
if (haltedByState) setConfirmOpen(false)
}, [haltedByState])
if (!loaded || !state) {
if (error) {
return (
@@ -82,13 +90,15 @@ export default function WorkersBoard({ api, isAdmin, stageRef }) {
}
}
// Pause für alle Mitarbeiter: der Dienst bekommt den Zielwert, danach wird der Zustand sofort neu geholt
const togglePause = async () => {
// Pause für alle Mitarbeiter: der Dienst bekommt den Zielwert (true = anhalten), danach wird der Zustand sofort neu geholt.
// Der Zielwert wird übergeben und nicht aus dem Zustand abgeleitet: ein offener Dialog darf nie fortsetzen, falls inzwischen
// jemand anderes angehalten hat.
const setPause = async (target) => {
if (pausing) return
setPausing(true)
setPauseError('')
try {
await api.pause(!paused)
await api.pause(target)
refresh()
} catch (err) {
if (aliveRef.current) setPauseError(err?.message || PAUSE_FAILED_TEXT)
@@ -96,12 +106,22 @@ export default function WorkersBoard({ api, isAdmin, stageRef }) {
if (aliveRef.current) setPausing(false)
}
// Anhalten betrifft alle Mitarbeiter sofort und fragt deshalb vorher nach; Fortsetzen ist harmlos und geht ohne Rückfrage
const requestPause = () => {
if (paused) setPause(false)
else setConfirmOpen(true)
}
const confirmHalt = () => {
setConfirmOpen(false)
setPause(true)
}
return (
<>
<div className="wk-toolbar">
<span className="wk-status">{paused ? PAUSED_TEXT : RUNNING_TEXT}</span>
<span className="grow" />
<Button type="button" onClick={togglePause} disabled={pausing}>{pauseLabel(paused)}</Button>
<Button type="button" onClick={requestPause} disabled={pausing}>{pauseLabel(paused)}</Button>
{isAdmin && <Button type="button" onClick={() => setSettingsOpen(true)}>Einstellungen</Button>}
</div>
@@ -133,6 +153,22 @@ export default function WorkersBoard({ api, isAdmin, stageRef }) {
{isAdmin && settingsOpen && (
<WorkersSettingsDialog api={api} onClose={() => setSettingsOpen(false)} onSaved={() => refresh()} />
)}
{confirmOpen && !paused && (
<Dialog
title="Alles anhalten?"
onClose={() => setConfirmOpen(false)}
footer={
<>
<span className="grow" />
<Button type="button" onClick={() => setConfirmOpen(false)}>Abbrechen</Button>
<Button variant="primary" type="button" onClick={confirmHalt}>Anhalten</Button>
</>
}
>
<p>Die Worker hören sofort auf, bis jemand fortsetzt.</p>
</Dialog>
)}
</>
)
}
+2
View File
@@ -12,6 +12,7 @@ import {
} from '../lib/appwrite'
import { createMfaApi, isMoreFactorsError, maskEmail, mfaErrorMessage } from '../lib/mfa'
import { isStaffAccount, NO_STAFF_ACCESS } from '../lib/staffAccess'
import { resetWorkersJwt } from '../lib/workersClient'
const AuthContext = createContext()
const mfa = createMfaApi(client)
@@ -238,6 +239,7 @@ export function AuthProvider({ children }) {
console.error('Logout error:', error)
} finally {
clearAllLocalAppwriteSessions()
resetWorkersJwt() // JWT und Sperre der Hustler-Seite gehören zur beendeten Sitzung
setUser(null)
setEmployee(null)
setMfaPending(null)
+32 -13
View File
@@ -6,13 +6,15 @@ export const DOWN_TEXT = 'Der Hustler-Dienst ist gerade nicht erreichbar.'
export const BAD_REPLY_TEXT = 'Der Hustler-Dienst antwortet nicht richtig.'
export const SESSION_TEXT = 'Anmeldung abgelaufen. Bitte neu anmelden.'
export const FORBIDDEN_TEXT = 'Dafür fehlt dir die Berechtigung.'
export const INVALID_ID_TEXT = 'Ungültige Kennung.'
// Zeitgrenze je Abruf (JWT holen, senden, Antwort lesen): ohne sie bliebe ein Abruf auf toter Verbindung (Rechner im Ruhezustand,
// Netzwechsel) minutenlang offen und der Feed (ein Abruf je Schleife) stünde still, ohne Fehlermeldung.
export const REQUEST_TIMEOUT_MS = 20000
// Nach einer 401-Folge (frisches JWT wird ebenfalls abgelehnt oder lässt sich nicht erzeugen) wird so lange kein frisches JWT mehr
// erzwungen: Appwrite begrenzt createJWT je Nutzer, und die Schleifen (5 s / 15 s) würden das Limit binnen Minuten aufbrauchen
// und damit auch Serverpuls, Finanzen und Vorschauen sperren. 2 Minuten ergeben höchstens 30 Versuche je Stunde und Tab.
// erzwungen, und scheitert createJWT selbst (z. B. Appwrite 429), wird so lange gar kein JWT mehr geholt: Appwrite begrenzt
// createJWT je Nutzer, und die Schleifen (5 s / 15 s) würden das Limit binnen Minuten aufbrauchen und damit auch Serverpuls,
// Finanzen und Vorschauen sperren. 2 Minuten ergeben höchstens 30 Versuche je Stunde und Tab.
export const AUTH_COOLDOWN_MS = 120000
// Pult-Status des Dienstes (Übersicht §4.11)
@@ -26,6 +28,13 @@ const TRACE_LIMIT = 50
const statusError = (message, status) => Object.assign(new Error(message), { status })
// Kennung für einen Pfadabschnitt: nur eine nichtleere Zeichenkette, und nie '.' oder '..' (encodeURIComponent lässt beide stehen,
// der Pfad würde zu einem anderen Abschnitt der API führen). Alles andere wird kodiert, damit '/', '?' und '#' im Abschnitt bleiben.
function pathId(id) {
if (typeof id !== 'string' || id === '' || id === '.' || id === '..') throw statusError(INVALID_ID_TEXT, 400)
return encodeURIComponent(id)
}
// Query aus Paaren; leere Werte (undefined, null, '') fallen weg, der Rest wird kodiert
function query(pairs) {
const parts = []
@@ -61,7 +70,8 @@ export function createWorkersApi({
authCooldownMs = AUTH_COOLDOWN_MS,
now = Date.now,
}) {
let freshBlockedUntil = 0 // bis dahin wird nach einer 401-Antwort kein frisches JWT erzeugt (AUTH_COOLDOWN_MS)
let freshBlockedUntil = 0 // bis dahin wird nach einer 401-Folge kein frisches JWT erzeugt und nach einem gescheiterten createJWT gar keins (AUTH_COOLDOWN_MS)
let mintFailed = false // getJwt (createJWT) ist gescheitert: bis zum Ende der Sperre wird getJwt nicht mehr gerufen, auch nicht bei leerem Cache
// Führt fn(signal) mit Zeitgrenze aus: nach timeoutMs scheitert der Aufruf mit DOWN_TEXT (status 0) und das Signal wird
// abgebrochen. Das gilt auch, wenn fn das Signal nicht beachtet (hängendes createJWT, fetchImpl ohne Abbruch-Unterstützung).
@@ -89,9 +99,17 @@ export function createWorkersApi({
}
async function jwtFor(options) {
if (mintFailed) {
if (now() < freshBlockedUntil) throw statusError(SESSION_TEXT, 401) // gesperrt: kein createJWT, keine Anfrage
mintFailed = false // Sperre vorbei: genau ein neuer Versuch
}
try {
return await getJwt(options)
} catch {
// createJWT selbst scheitert (z. B. Appwrite 429, Sitzung weg): dieselbe Sperre wie nach der zweiten 401.
// Ein hängendes getJwt zählt nicht dazu: es endet in withDeadline mit der Dienst-Meldung, bevor dieser Zweig erreicht wird.
mintFailed = true
freshBlockedUntil = now() + authCooldownMs
throw statusError(SESSION_TEXT, 401)
}
}
@@ -130,13 +148,9 @@ export function createWorkersApi({
if (out.unauthorized) {
// Während der Sperre gilt die 401 sofort als abgelaufene Sitzung (kein createJWT, keine zweite Anfrage)
if (now() < freshBlockedUntil) throw statusError(SESSION_TEXT, 401)
// sonst einmal mit frischem JWT wiederholen, danach gilt die Sitzung als abgelaufen und die Sperre beginnt
try {
out = await attempt(path, method, body, { fresh: true })
} catch (err) {
if (err && err.status === 401) freshBlockedUntil = now() + authCooldownMs // auch das frische JWT ließ sich nicht erzeugen
throw err
}
// sonst einmal mit frischem JWT wiederholen (lässt es sich nicht erzeugen, beginnt die Sperre in jwtFor), danach gilt die
// Sitzung als abgelaufen und die Sperre beginnt
out = await attempt(path, method, body, { fresh: true })
if (out.unauthorized) {
freshBlockedUntil = now() + authCooldownMs
throw statusError(SESSION_TEXT, 401)
@@ -148,7 +162,7 @@ export function createWorkersApi({
return {
state: () => request('/state'),
events: (after, limit = EVENTS_LIMIT) => request(`/events${query([['after', after], ['limit', clampLimit(limit, EVENTS_LIMIT)]])}`),
trace: (workerId, limit = TRACE_LIMIT) => request(`/worker/${encodeURIComponent(workerId)}/trace${query([['limit', clampLimit(limit, TRACE_LIMIT)]])}`),
trace: async (workerId, limit = TRACE_LIMIT) => request(`/worker/${pathId(workerId)}/trace${query([['limit', clampLimit(limit, TRACE_LIMIT)]])}`),
pause: (paused) => request('/pause', { method: 'POST', body: { paused: Boolean(paused) } }),
settings: () => request('/settings'),
saveSettings: (patch) => request('/settings', { method: 'PUT', body: patch }),
@@ -156,9 +170,14 @@ export function createWorkersApi({
pult: async (status = 'offen') => request(`/pult?status=${pultStatusList(status).join(',')}`),
pultAction: async (id, action, body) => {
if (!PULT_ACTIONS.includes(action)) throw statusError(`Unbekannte Aktion: ${action}`, 400)
return request(`/pult/${encodeURIComponent(id)}/${action}`, { method: 'POST', body })
return request(`/pult/${pathId(id)}/${action}`, { method: 'POST', body })
},
botleads: ({ room, q, page, id } = {}) => request(`/botleads${query([['room', room], ['q', q], ['page', page], ['id', id]])}`),
prospect: (id) => request(`/prospect/${encodeURIComponent(id)}`),
prospect: async (id) => request(`/prospect/${pathId(id)}`),
// Beim Abmelden: Sperre aufheben, damit die nächste Anmeldung sofort wieder ein JWT holen darf (den JWT-Cache leert workersClient.js)
resetAuth: () => {
freshBlockedUntil = 0
mintFailed = false
},
}
}
+7
View File
@@ -12,3 +12,10 @@ const getJwt = (options) => {
}
export const workersApi = createWorkersApi({ getJwt })
// Beim Abmelden aufgerufen (AuthContext): das JWT des abgemeldeten Nutzers darf nicht im Cache bleiben, und eine Sperre der
// alten Sitzung (zwei 401 hintereinander oder ein gescheitertes createJWT) darf die nächste Anmeldung nicht aufhalten.
export function resetWorkersJwt() {
jwtCache.invalidate()
workersApi.resetAuth()
}