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>
This commit is contained in:
@@ -18,6 +18,7 @@ export function ResellerCompaniesPage() {
|
||||
const [showNew, setShowNew] = useState(false)
|
||||
const [form, setForm] = useState({ ...empty })
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [cred, setCred] = useState<{ email: string; password: string } | null>(null)
|
||||
|
||||
async function load() {
|
||||
try { setCompanies(await api.get<Tenant[]>('/reseller/companies')) }
|
||||
@@ -32,8 +33,13 @@ export function ResellerCompaniesPage() {
|
||||
async function createCompany() {
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
await api.post('/reseller/companies', form)
|
||||
setShowNew(false); setForm({ ...empty }); await load()
|
||||
const body = { ...form, admin_email: form.admin_email.trim() || undefined }
|
||||
const res = await api.post<Tenant & { initial_password?: string | null; admin_email?: string | null }>(
|
||||
'/reseller/companies', body,
|
||||
)
|
||||
setShowNew(false); setForm({ ...empty })
|
||||
if (res.initial_password) setCred({ 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) }
|
||||
@@ -120,8 +126,8 @@ export function ResellerCompaniesPage() {
|
||||
<L label="Plan"><input className={inp} value={form.plan} onChange={e => setForm({ ...form, plan: e.target.value })} /></L>
|
||||
</div>
|
||||
<hr />
|
||||
<p className="text-xs text-gray-500">Erster Administrator (erhält Einladung per E-Mail):</p>
|
||||
<L label="Admin E-Mail"><input className={inp} value={form.admin_email} onChange={e => setForm({ ...form, admin_email: e.target.value })} /></L>
|
||||
<p className="text-xs text-gray-500">Erster Administrator. Mit E-Mail wird eine Einladung verschickt – ohne E-Mail erhältst du ein einmaliges Temporär-Passwort zum Weitergeben.</p>
|
||||
<L 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 })} /></L>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<L label="Vorname"><input className={inp} value={form.admin_first_name} onChange={e => setForm({ ...form, admin_first_name: e.target.value })} /></L>
|
||||
<L label="Nachname"><input className={inp} value={form.admin_last_name} onChange={e => setForm({ ...form, admin_last_name: e.target.value })} /></L>
|
||||
@@ -137,6 +143,37 @@ export function ResellerCompaniesPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cred && <CredDialog title="Zugangsdaten Firmen-Administrator" cred={cred} onClose={() => setCred(null)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CredDialog({ title, cred, onClose }: {
|
||||
title: string; cred: { email: string; password: string }; onClose: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-[60] p-4">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-md p-6 space-y-4">
|
||||
<h2 className="text-lg font-bold text-gray-900">{title}</h2>
|
||||
<div className="rounded-lg border-2 border-amber-300 bg-amber-50 p-4 space-y-3">
|
||||
<p className="text-xs font-semibold text-amber-800">⚠ Jetzt notieren – das Passwort wird nur einmal angezeigt.</p>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Login</p>
|
||||
<p className="font-mono text-sm break-all">{cred.email}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Temporäres Passwort</p>
|
||||
<p className="font-mono text-sm break-all select-all">{cred.password}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={() => navigator.clipboard?.writeText(`${cred.email} / ${cred.password}`)}
|
||||
className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">Kopieren</button>
|
||||
<button onClick={onClose}
|
||||
className="px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700">Verstanden</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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
|
||||
@@ -27,6 +28,7 @@ export function TenantsPage() {
|
||||
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 {
|
||||
@@ -49,10 +51,15 @@ export function TenantsPage() {
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
const q = form.reseller_id ? `?reseller_id=${form.reseller_id}` : ''
|
||||
const { reseller_id, ...body } = form
|
||||
const { reseller_id, ...rest } = form
|
||||
void reseller_id
|
||||
await api.post(`/admin/tenants${q}`, body)
|
||||
setShowNew(false); setForm({ ...empty, reseller_id: '' }); await load()
|
||||
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) }
|
||||
@@ -71,8 +78,11 @@ export function TenantsPage() {
|
||||
async function createReseller() {
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
await api.post('/admin/resellers', rForm)
|
||||
setShowNewReseller(false); setRForm({ ...emptyReseller }); await load()
|
||||
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) }
|
||||
@@ -204,8 +214,8 @@ export function TenantsPage() {
|
||||
</select>
|
||||
</Field>
|
||||
<hr className="my-1" />
|
||||
<p className="text-xs text-gray-500">Erster Administrator (erhält Einladung per E-Mail):</p>
|
||||
<Field label="Admin E-Mail"><input className={inp} value={form.admin_email} onChange={e => setForm({ ...form, admin_email: e.target.value })} /></Field>
|
||||
<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>
|
||||
@@ -215,14 +225,16 @@ export function TenantsPage() {
|
||||
|
||||
{showNewReseller && (
|
||||
<Modal title="Neuen Reseller anlegen" onClose={() => setShowNewReseller(false)} onSubmit={createReseller} busy={busy}>
|
||||
<p className="text-xs text-gray-500">Der Reseller erhält eine Einladung per E-Mail und kann danach eigene Firmen anlegen und verwalten.</p>
|
||||
<Field label="E-Mail"><input className={inp} value={rForm.email} onChange={e => setRForm({ ...rForm, email: e.target.value })} /></Field>
|
||||
<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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user