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:
1 parent
c6c022678f
commit
777d5272bf
6 files changed
+279
-19
No files matched your search
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in new issue
Block a user