feat(portal): Token-Verwaltung für das Buchhaltungsportal in den Firmeneinstellungen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
43cdece01f
commit
8dfee59bf0
2 files changed
+149
No files matched your search
@@ -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 */}
|
||||
|
||||
Reference in new issue
Block a user