feat(portal): Token-Verwaltung für das Buchhaltungsportal in den Firmeneinstellungen
Security Audit / Python Dependency Audit (push) Canceled after 0s
Security Audit / Node.js Dependency Audit (push) Canceled after 0s
Security Audit / Frontend Build (tsc + vite) (push) Canceled after 0s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
patrickandClaude Sonnet 5.5 committed 2026-11-03 22:26:34 +01:00
1 parent 43cdece01f
commit 8dfee59bf0
2 files changed
+149

No files matched your search

+146
View File
@@ -0,0 +1,146 @@
import { useEffect, useState } from 'react'
import { api } from '../api/client'
interface Status { configured: boolean; created_at: string | null }
export function PortalTokenCard() {
const [status, setStatus] = useState<Status | null>(null)
const [plaintext, setPlaintext] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [copied, setCopied] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
api.get<Status>('/companies/me/portal-token').then(setStatus).catch(() => {})
}, [])
async function rotate() {
if (status?.configured && !confirm('Neuen Token generieren? Der bisherige Token wird sofort ungültig und das Buchhaltungsportal muss den neuen eintragen.')) return
setBusy(true)
setError(null)
try {
const res = await api.post<{ token: string; created_at: string }>('/companies/me/portal-token/rotate', {})
setPlaintext(res.token)
setStatus({ configured: true, created_at: res.created_at })
setCopied(false)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Generieren des Tokens')
} finally {
setBusy(false)
}
}
async function revoke() {
if (!confirm('Token wirklich deaktivieren? Das Buchhaltungsportal kann danach keine Summen mehr abrufen.')) return
setBusy(true)
setError(null)
try {
await api.del('/companies/me/portal-token')
setStatus({ configured: false, created_at: null })
setPlaintext(null)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Deaktivieren')
} finally {
setBusy(false)
}
}
async function copy() {
if (!plaintext) return
try {
await navigator.clipboard.writeText(plaintext)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
/* clipboard nicht verfügbar – User kann manuell markieren */
}
}
return (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-5">
<div>
<h2 className="font-semibold text-gray-700">Buchhaltungsportal</h2>
<p className="text-xs text-gray-400 mt-0.5">
Das Buchhaltungsportal ruft Firmensummen zu Resturlaub und Überstunden via Bearer-Token ab (nur lesen, keine
Einzelwerte, keine Krankheitsdaten). Endpoint:{' '}
<code className="font-mono text-[11px] bg-gray-100 px-1 py-0.5 rounded">GET /api/v1/portal/summary</code>.
</p>
</div>
<details className="text-xs">
<summary className="cursor-pointer text-gray-500 hover:text-gray-700 select-none">
Test-Befehl anzeigen (curl)
</summary>
<div className="mt-2 rounded-lg bg-gray-900 text-gray-100 px-3 py-2 font-mono text-[11px] leading-relaxed whitespace-pre overflow-x-auto">
{`curl -H "Authorization: Bearer <TOKEN>" \\\n ${window.location.origin}/api/v1/portal/summary`}
</div>
</details>
<div className="text-sm">
{status?.configured ? (
<div className="flex items-center gap-2 text-green-700">
<span className="inline-block w-2 h-2 rounded-full bg-green-500" />
<span>
Token aktiv
{status.created_at && (
<span className="text-gray-400 ml-1">
(seit {new Date(status.created_at).toLocaleString('de-DE')})
</span>
)}
</span>
</div>
) : (
<div className="flex items-center gap-2 text-gray-500">
<span className="inline-block w-2 h-2 rounded-full bg-gray-300" />
<span>Kein Token konfiguriert</span>
</div>
)}
</div>
{plaintext && (
<div className="rounded-lg border-2 border-amber-300 bg-amber-50 p-4 space-y-2">
<p className="text-xs font-semibold text-amber-800">⚠ Token jetzt kopieren – wird nur einmal angezeigt.</p>
<div className="flex items-stretch gap-2">
<input
type="text"
readOnly
value={plaintext}
onFocus={e => e.currentTarget.select()}
className="flex-1 font-mono text-xs px-3 py-2 border border-amber-300 rounded bg-white"
/>
<button
onClick={copy}
className="px-3 py-2 bg-amber-600 text-white text-xs font-medium rounded hover:bg-amber-700"
>
{copied ? '✓ Kopiert' : 'Kopieren'}
</button>
</div>
<p className="text-xs text-amber-700">
Im Buchhaltungsportal unter timemaster eintragen (Adresse der Firma plus dieser Token).
</p>
</div>
)}
{error && <p className="text-sm text-red-600">{error}</p>}
<div className="flex flex-wrap items-center gap-3">
<button
onClick={rotate}
disabled={busy}
className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{status?.configured ? 'Neuen Token generieren' : 'Token generieren'}
</button>
{status?.configured && (
<button
onClick={revoke}
disabled={busy}
className="px-4 py-2 bg-white border border-red-300 text-red-700 text-sm font-medium rounded-lg hover:bg-red-50 disabled:opacity-50"
>
Deaktivieren
</button>
)}
</div>
</div>
)
}
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { api } from '../api/client'
import { Layout } from '../components/Layout'
import { PortalTokenCard } from '../components/PortalTokenCard'
interface UserOut {
id: string; first_name: string; last_name: string; email: string; role: string
@@ -643,6 +644,8 @@ export function CompanySettingsPage() {
</div>
)}
{isAdmin && <PortalTokenCard />}
{/* QR-Stempeln wurde in eine eigene Seite ausgelagert: /settings/qr-stamp */}
{/* Mobile-Einstellungen */}