Files
timemaster/frontend/src/pages/TenantsPage.tsx
T
patrickandClaude Opus 4.8 9e1ba2bd91 feat: Mandant/Reseller ohne E-Mail anlegbar (Temp-Passwort statt Einladung)
- admin_email bzw. reseller email optional; ohne E-Mail wird interne Login-
  Kennung (<name>@<slug>.local) + einmaliges Temp-Passwort erzeugt, Account
  sofort aktiv, kein Mailversand
- TenantOut/ResellerOut: initial_password (einmalig) ergänzt
- LoginRequest.email: str statt EmailStr (Kennung muss kein zustellbares
  Postfach sein; .local-Domains sind sonst nicht einloggbar)
- Frontend: E-Mail-Felder optional, CredDialog zeigt Login + Temp-Passwort einmalig
- Test: Anlage ohne E-Mail + Login mit generierten Zugangsdaten; 173/173 grün

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 01:34:23 +02:00

268 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from 'react'
import { api } from '../api/client'
import { Layout } from '../components/Layout'
import { CredDialog } from './ResellerCompaniesPage'
interface Tenant {
id: string; name: string; slug: string; plan: string; is_active: boolean
country: string; reseller_id: string | null; reseller_name: string | null
user_count: number; active_user_count: number; admin_email: string | null
}
interface Reseller {
id: string; email: string; first_name: string; last_name: string
is_active: boolean; company_count: number
}
interface Me { first_name: string; last_name: string; role: string }
const empty = { name: '', country: 'DE', plan: 'trial', admin_email: '', admin_first_name: '', admin_last_name: '' }
const emptyReseller = { email: '', first_name: '', last_name: '' }
export function TenantsPage() {
const [me, setMe] = useState<Me | null>(null)
const [tab, setTab] = useState<'tenants' | 'resellers'>('tenants')
const [tenants, setTenants] = useState<Tenant[]>([])
const [resellers, setResellers] = useState<Reseller[]>([])
const [error, setError] = useState<string | null>(null)
const [showNew, setShowNew] = useState(false)
const [form, setForm] = useState({ ...empty, reseller_id: '' })
const [showNewReseller, setShowNewReseller] = useState(false)
const [rForm, setRForm] = useState({ ...emptyReseller })
const [busy, setBusy] = useState(false)
const [cred, setCred] = useState<{ title: string; email: string; password: string } | null>(null)
async function load() {
try {
const [t, r] = await Promise.all([
api.get<Tenant[]>('/admin/tenants'),
api.get<Reseller[]>('/admin/resellers'),
])
setTenants(t); setResellers(r)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
}
}
useEffect(() => {
api.get<Me>('/auth/me').then(setMe).catch(() => {})
load()
}, [])
async function createTenant() {
setBusy(true); setError(null)
try {
const q = form.reseller_id ? `?reseller_id=${form.reseller_id}` : ''
const { reseller_id, ...rest } = form
void reseller_id
const body = { ...rest, admin_email: rest.admin_email.trim() || undefined }
const res = await api.post<{ initial_password?: string | null; admin_email?: string | null }>(
`/admin/tenants${q}`, body,
)
setShowNew(false); setForm({ ...empty, reseller_id: '' })
if (res.initial_password) setCred({ title: 'Zugangsdaten Firmen-Administrator', email: res.admin_email ?? '', password: res.initial_password })
await load()
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Anlegen')
} finally { setBusy(false) }
}
async function toggleTenant(t: Tenant) {
try { await api.patch(`/admin/tenants/${t.id}`, { is_active: !t.is_active }); await load() }
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler') }
}
async function assignReseller(t: Tenant, reseller_id: string) {
try { await api.patch(`/admin/tenants/${t.id}/reseller`, { reseller_id: reseller_id || null }); await load() }
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler') }
}
async function createReseller() {
setBusy(true); setError(null)
try {
const body = { ...rForm, email: rForm.email.trim() || undefined }
const res = await api.post<{ initial_password?: string | null; email?: string | null }>('/admin/resellers', body)
setShowNewReseller(false); setRForm({ ...emptyReseller })
if (res.initial_password) setCred({ title: 'Zugangsdaten Reseller', email: res.email ?? '', password: res.initial_password })
await load()
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Anlegen')
} finally { setBusy(false) }
}
async function toggleReseller(r: Reseller) {
try { await api.patch(`/admin/resellers/${r.id}?is_active=${!r.is_active}`, {}); await load() }
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler') }
}
return (
<Layout userRole={me?.role ?? ''} userName={me ? `${me.first_name} ${me.last_name}` : ''}>
<div className="max-w-6xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Mandanten</h1>
{tab === 'tenants' ? (
<button onClick={() => setShowNew(true)}
className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700">
+ Firma anlegen
</button>
) : (
<button onClick={() => setShowNewReseller(true)}
className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700">
+ Reseller anlegen
</button>
)}
</div>
<div className="flex gap-1 border-b border-gray-200">
{(['tenants', 'resellers'] as const).map(t => (
<button key={t} onClick={() => setTab(t)}
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${
tab === t ? 'border-blue-600 text-blue-700' : 'border-transparent text-gray-500 hover:text-gray-700'
}`}>
{t === 'tenants' ? `Firmen (${tenants.length})` : `Reseller (${resellers.length})`}
</button>
))}
</div>
{error && <div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>}
{tab === 'tenants' && (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-gray-500 text-xs uppercase">
<tr>
<th className="text-left px-4 py-3">Firma</th>
<th className="text-left px-4 py-3">Plan</th>
<th className="text-left px-4 py-3">Nutzer</th>
<th className="text-left px-4 py-3">Admin</th>
<th className="text-left px-4 py-3">Reseller</th>
<th className="text-left px-4 py-3">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{tenants.map(t => (
<tr key={t.id} className={t.is_active ? '' : 'bg-gray-50 text-gray-400'}>
<td className="px-4 py-3 font-medium text-gray-800">{t.name}<div className="text-xs text-gray-400">{t.slug}</div></td>
<td className="px-4 py-3">{t.plan}</td>
<td className="px-4 py-3">{t.active_user_count}/{t.user_count}</td>
<td className="px-4 py-3 text-xs">{t.admin_email ?? '—'}</td>
<td className="px-4 py-3">
<select value={t.reseller_id ?? ''} onChange={e => assignReseller(t, e.target.value)}
className="text-xs border border-gray-200 rounded px-1 py-1 bg-white">
<option value=""> Plattform </option>
{resellers.map(r => <option key={r.id} value={r.id}>{r.first_name} {r.last_name}</option>)}
</select>
</td>
<td className="px-4 py-3">
<button onClick={() => toggleTenant(t)}
className={`text-xs px-2 py-1 rounded-full font-medium ${
t.is_active ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'
}`}>
{t.is_active ? 'aktiv' : 'deaktiviert'}
</button>
</td>
</tr>
))}
{tenants.length === 0 && <tr><td colSpan={6} className="px-4 py-8 text-center text-gray-400">Keine Firmen.</td></tr>}
</tbody>
</table>
</div>
)}
{tab === 'resellers' && (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-gray-500 text-xs uppercase">
<tr>
<th className="text-left px-4 py-3">Name</th>
<th className="text-left px-4 py-3">E-Mail</th>
<th className="text-left px-4 py-3">Firmen</th>
<th className="text-left px-4 py-3">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{resellers.map(r => (
<tr key={r.id}>
<td className="px-4 py-3 font-medium text-gray-800">{r.first_name} {r.last_name}</td>
<td className="px-4 py-3 text-xs">{r.email}</td>
<td className="px-4 py-3">{r.company_count}</td>
<td className="px-4 py-3">
<button onClick={() => toggleReseller(r)}
className={`text-xs px-2 py-1 rounded-full font-medium ${
r.is_active ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'
}`}>
{r.is_active ? 'aktiv' : 'deaktiviert'}
</button>
</td>
</tr>
))}
{resellers.length === 0 && <tr><td colSpan={4} className="px-4 py-8 text-center text-gray-400">Keine Reseller.</td></tr>}
</tbody>
</table>
</div>
)}
{showNew && (
<Modal title="Neue Firma anlegen" onClose={() => setShowNew(false)} onSubmit={createTenant} busy={busy}>
<Field label="Firmenname"><input className={inp} value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} /></Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Land"><input className={inp} value={form.country} onChange={e => setForm({ ...form, country: e.target.value })} /></Field>
<Field label="Plan"><input className={inp} value={form.plan} onChange={e => setForm({ ...form, plan: e.target.value })} /></Field>
</div>
<Field label="Reseller (optional)">
<select className={inp} value={form.reseller_id} onChange={e => setForm({ ...form, reseller_id: e.target.value })}>
<option value=""> Plattform (kein Reseller) </option>
{resellers.map(r => <option key={r.id} value={r.id}>{r.first_name} {r.last_name}</option>)}
</select>
</Field>
<hr className="my-1" />
<p className="text-xs text-gray-500">Erster Administrator. Mit E-Mail Einladung; ohne E-Mail einmaliges Temp-Passwort.</p>
<Field label="Admin E-Mail (optional)"><input className={inp} placeholder="leer lassen für Temp-Passwort" value={form.admin_email} onChange={e => setForm({ ...form, admin_email: e.target.value })} /></Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Vorname"><input className={inp} value={form.admin_first_name} onChange={e => setForm({ ...form, admin_first_name: e.target.value })} /></Field>
<Field label="Nachname"><input className={inp} value={form.admin_last_name} onChange={e => setForm({ ...form, admin_last_name: e.target.value })} /></Field>
</div>
</Modal>
)}
{showNewReseller && (
<Modal title="Neuen Reseller anlegen" onClose={() => setShowNewReseller(false)} onSubmit={createReseller} busy={busy}>
<p className="text-xs text-gray-500">Mit E-Mail erhält der Reseller eine Einladung ohne E-Mail ein einmaliges Temp-Passwort. Danach kann er eigene Firmen anlegen und verwalten.</p>
<Field label="E-Mail (optional)"><input className={inp} placeholder="leer lassen für Temp-Passwort" value={rForm.email} onChange={e => setRForm({ ...rForm, email: e.target.value })} /></Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Vorname"><input className={inp} value={rForm.first_name} onChange={e => setRForm({ ...rForm, first_name: e.target.value })} /></Field>
<Field label="Nachname"><input className={inp} value={rForm.last_name} onChange={e => setRForm({ ...rForm, last_name: e.target.value })} /></Field>
</div>
</Modal>
)}
{cred && <CredDialog title={cred.title} cred={cred} onClose={() => setCred(null)} />}
</div>
</Layout>
)
}
const inp = 'w-full px-3 py-2 border border-gray-300 rounded-lg text-sm'
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return <label className="block space-y-1"><span className="text-xs font-medium text-gray-600">{label}</span>{children}</label>
}
function Modal({ title, children, onClose, onSubmit, busy }: {
title: string; children: React.ReactNode; onClose: () => void; onSubmit: () => void; busy: boolean
}) {
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4" onClick={onClose}>
<div className="bg-white rounded-xl shadow-xl w-full max-w-md p-6 space-y-4" onClick={e => e.stopPropagation()}>
<h2 className="text-lg font-bold text-gray-900">{title}</h2>
<div className="space-y-3">{children}</div>
<div className="flex justify-end gap-2 pt-2">
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">Abbrechen</button>
<button onClick={onSubmit} 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">
{busy ? 'Speichern…' : 'Anlegen'}
</button>
</div>
</div>
</div>
)
}