Finanzen: nur fuer Admins, Rueckkehrseite fuer die Bankfreigabe
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c910bedcec
commit
0069a62a09
6 files changed
+201
-3
No files matched your search
+5
-1
@@ -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>} />
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user