Finanzen: nur fuer Admins, Rueckkehrseite fuer die Bankfreigabe

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-01 15:12:18 +00:00
1 parent c910bedcec
commit 0069a62a09
6 files changed
+201 -3

No files matched your search

+5 -1
View File
@@ -14,6 +14,8 @@ import LegalPage from './pages/LegalPage'
import AssignProjectPage from './pages/AssignProjectPage'
import ServicesPage from './pages/ServicesPage'
import ServerpulsPage from './pages/ServerpulsPage'
import BankCallbackPage from './pages/BankCallbackPage'
import AdminRoute from './components/AdminRoute'
import { safeReturnPath } from './lib/returnPath'
function ProtectedRoute({ children }) {
@@ -48,7 +50,9 @@ function AppRoutes() {
<Route path="/customers" element={<ProtectedRoute><CustomersPage /></ProtectedRoute>} />
<Route path="/customers/:id" element={<ProtectedRoute><CustomerDetailPage /></ProtectedRoute>} />
<Route path="/leads" element={<Navigate to="/tickets" replace />} />
<Route path="/finance" element={<ProtectedRoute><FinancePage /></ProtectedRoute>} />
{/* Finanzen: nur Admins, alle anderen gehen zu /tickets */}
<Route path="/finance" element={<ProtectedRoute><AdminRoute><FinancePage /></AdminRoute></ProtectedRoute>} />
<Route path="/finanzen/bank-callback" element={<ProtectedRoute><AdminRoute><BankCallbackPage /></AdminRoute></ProtectedRoute>} />
<Route path="/rechtliches" element={<ProtectedRoute><LegalPage /></ProtectedRoute>} />
<Route path="/serverpuls" element={<ProtectedRoute><ServerpulsPage /></ProtectedRoute>} />
<Route path="/planboard" element={<ProtectedRoute><PlanboardPage /></ProtectedRoute>} />
+20
View File
@@ -0,0 +1,20 @@
import { Navigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
// Nur fuer Admins; wird innerhalb von ProtectedRoute verwendet. Alle anderen landen bei den Tickets.
export default function AdminRoute({ children }) {
const { loading, isAdmin } = useAuth()
if (loading) {
return (
<div className="text-center p-4" style={{ marginTop: 80 }}>
<div className="spinner"></div>
<p>Loading...</p>
</div>
)
}
if (!isAdmin) return <Navigate to="/tickets" replace />
return children
}
+4 -2
View File
@@ -61,7 +61,7 @@ export default function Sidebar() {
{ label: 'Kunden', href: '/customers', icon: <IconUsers /> },
// Leads liegen auf Eis (Lead-Automatik gestoppt 2026-09-30): grau, nicht klickbar
{ label: 'Leads', href: '/leads', icon: <IconTargetArrow />, paused: true },
{ label: 'Finanzen', href: '/finance', icon: <IconReportMoney /> },
...(isAdmin ? [{ label: 'Finanzen', href: '/finance', also: '/finanzen', icon: <IconReportMoney /> }] : []),
{ label: 'Rechtliches', href: '/rechtliches', icon: <IconScale /> },
...(isAdmin ? [{ label: 'Serverpuls', href: '/serverpuls', icon: <IconActivityHeartbeat />, dot: pulseLevel }] : []),
...(isAdmin ? [{ label: 'Admin', href: '/admin', icon: <IconSettings /> }] : []),
@@ -69,6 +69,8 @@ export default function Sidebar() {
const isActive = (href) =>
location.pathname === href || location.pathname.startsWith(href + '/')
// Finanzen bleibt auch auf der Rueckkehrseite der Bankfreigabe (/finanzen/...) markiert
const isLinkActive = (link) => isActive(link.href) || (link.also ? isActive(link.also) : false)
return (
<aside className={`side ${collapsed ? 'collapsed' : ''}`}>
@@ -96,7 +98,7 @@ export default function Sidebar() {
<Link
key={link.href}
to={link.href}
className={`side-link ${isActive(link.href) ? 'active' : ''}`}
className={`side-link ${isLinkActive(link) ? 'active' : ''}`}
title={link.label}
>
{link.icon}
+38
View File
@@ -0,0 +1,38 @@
// Rueckkehr von der Bankfreigabe (Finom): Adresse auswerten und Ergebnis fuer die Seite aufbereiten.
const FALLBACK_ERROR = 'Die Bankfreigabe konnte nicht abgeschlossen werden.'
/**
* '?state=a&code=b' -> { code, state, error }; null, wenn weder code noch error vorhanden sind.
* error_description wird an error angehaengt ('access_denied: abgebrochen').
*/
export function parseCallback(search) {
if (typeof search !== 'string') return null
const params = new URLSearchParams(search)
const code = params.get('code') || ''
const state = params.get('state') || ''
let error = params.get('error') || ''
const description = params.get('error_description') || ''
if (!code && !error) return null
if (error && description) error = `${error}: ${description}`
return { code, state, error }
}
/** Antwort von completeBankAuth -> { ok: true, validUntil } (alles andere gilt als Fehler). */
export function successResult(response) {
if (response && response.ok === true) {
return { ok: true, validUntil: typeof response.validUntil === 'string' ? response.validUntil : '' }
}
return { ok: false, message: FALLBACK_ERROR }
}
/** Fehler von completeBankAuth -> { ok: false, message } mit der deutschen Meldung des Servers. */
export function failureResult(err) {
const message = typeof err?.message === 'string' && err.message.trim() ? err.message : FALLBACK_ERROR
return { ok: false, message }
}
/** Prueft ein Ergebnis, das aus dem Verlaufszustand des Browsers gelesen wurde. */
export function isResult(value) {
return Boolean(value) && typeof value === 'object' && typeof value.ok === 'boolean'
}
+75
View File
@@ -0,0 +1,75 @@
import { useEffect, useRef, useState } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { PageHeader, Card, Button } from '../components/ui'
import { completeBankAuth } from '../lib/financeApi'
import { dateDe } from '../lib/financeFormat'
import { parseCallback, successResult, failureResult, isResult } from '../lib/bankCallback'
const PATH = '/finanzen/bank-callback'
// Rueckkehrseite der Bankfreigabe: code/state aus der Adresse gehen einmalig ans Portal,
// danach verschwinden sie aus der Adresszeile; angezeigt werden nur Ergebnis und Meldung.
export default function BankCallbackPage() {
const location = useLocation()
const navigate = useNavigate()
// Beim ersten Rendern einlesen: danach ist der Suchteil aus der Adresse entfernt
const [params] = useState(() => parseCallback(location.search))
const [result, setResult] = useState(() => (isResult(location.state) ? location.state : null))
const startedRef = useRef(false)
const aliveRef = useRef(true)
useEffect(() => {
aliveRef.current = true
// useRef-Schutz: auch bei doppelten Effekten (StrictMode) genau ein Aufruf
if (params && !result && !startedRef.current) {
startedRef.current = true
completeBankAuth(params)
.then(successResult, failureResult)
.then((outcome) => {
if (!aliveRef.current) return
setResult(outcome)
navigate(PATH, { replace: true, state: outcome })
})
}
return () => { aliveRef.current = false }
}, []) // eslint-disable-line react-hooks/exhaustive-deps
let body
if (result) {
body = (
<>
<p role={result.ok ? 'status' : 'alert'} style={{ fontWeight: 600 }}>
{result.ok
? `Bankzugang erneuert, gültig bis ${dateDe(result.validUntil)}`
: result.message}
</p>
<div style={{ marginTop: 16 }}>
<Button as={Link} variant="primary" to="/finance">Zu den Finanzen</Button>
</div>
</>
)
} else if (params) {
body = (
<div className="text-center" role="status">
<div className="spinner" />
<p className="muted">Bankzugang wird eingerichtet …</p>
</div>
)
} else {
body = (
<>
<p className="muted">Zu dieser Seite liegt keine Rückmeldung der Bank vor.</p>
<div style={{ marginTop: 16 }}>
<Button as={Link} variant="primary" to="/finance">Zu den Finanzen</Button>
</div>
</>
)
}
return (
<div className="page">
<PageHeader title="Bankzugang" />
<Card>{body}</Card>
</div>
)
}
+59
View File
@@ -0,0 +1,59 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const { parseCallback, successResult, failureResult, isResult } = await loadModule('src/lib/bankCallback.js')
test('parseCallback: code und state', () => {
assert.deepEqual(parseCallback('?state=a&code=b'), { code: 'b', state: 'a', error: '' })
})
test('parseCallback: Abbruch bei der Bank, error_description wird angehaengt', () => {
assert.deepEqual(
parseCallback('?state=a&error=access_denied&error_description=abgebrochen'),
{ code: '', state: 'a', error: 'access_denied: abgebrochen' },
)
assert.equal(parseCallback('?error=access_denied').error, 'access_denied')
})
test('parseCallback: ohne code und error -> null', () => {
assert.equal(parseCallback(''), null)
assert.equal(parseCallback('?'), null)
assert.equal(parseCallback('?state=a'), null)
assert.equal(parseCallback('?error_description=nur-text'), null)
assert.equal(parseCallback(undefined), null)
assert.equal(parseCallback(null), null)
assert.equal(parseCallback(42), null)
})
test('parseCallback: URL-Kodierung, erster Wert gewinnt, Suchteil auch ohne ?', () => {
assert.deepEqual(parseCallback('?code=a%2Bb%3D&state=x%20y'), { code: 'a+b=', state: 'x y', error: '' })
assert.equal(parseCallback('?code=erst&code=zweit').code, 'erst')
assert.deepEqual(parseCallback('code=b&state=a'), { code: 'b', state: 'a', error: '' })
})
test('successResult: gueltig bis uebernehmen', () => {
assert.deepEqual(successResult({ ok: true, validUntil: '2099-03-29T00:00:00.000Z' }), { ok: true, validUntil: '2099-03-29T00:00:00.000Z' })
assert.deepEqual(successResult({ ok: true }), { ok: true, validUntil: '' })
})
test('successResult: Antwort ohne ok -> Fehlerergebnis mit deutschem Text', () => {
const r = successResult({ ok: false })
assert.equal(r.ok, false)
assert.match(r.message, /Bankfreigabe/)
assert.equal(successResult(null).ok, false)
})
test('failureResult: Meldung des Servers, sonst Standardtext', () => {
assert.deepEqual(failureResult({ message: 'Die Freigabe ist abgelaufen.' }), { ok: false, message: 'Die Freigabe ist abgelaufen.' })
assert.match(failureResult(new Error('')).message, /Bankfreigabe/)
assert.match(failureResult(undefined).message, /Bankfreigabe/)
})
test('isResult: nur gueltige Ergebnisse aus dem Verlaufszustand', () => {
assert.equal(isResult({ ok: true, validUntil: 'x' }), true)
assert.equal(isResult({ ok: false, message: 'm' }), true)
assert.equal(isResult(null), false)
assert.equal(isResult({}), false)
assert.equal(isResult({ ok: 'ja' }), false)
})