feat(PROJ-24): Mandanten-Logo Upload
- DB: logo_data (BYTEA) + logo_content_type Spalten in tenants-Tabelle - Backend: SetLogo/GetLogo/DeleteLogo im tenantstore - API: Logo-Endpunkte für superadmin (beliebiger Mandant) und domain_admin (eigener Mandant), max. 2 MB, PNG/JPEG/GIF/WebP/SVG - Frontend: Logo-Dialog in Mandantentabelle (superadmin), Logo-Upload-Sektion im LDAP-Tab (domain_admin) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+226
-1
@@ -45,6 +45,11 @@ import {
|
||||
getLabelRules,
|
||||
createLabelRule,
|
||||
deleteLabelRule,
|
||||
getTenantLogoUrl,
|
||||
uploadTenantLogo,
|
||||
deleteTenantLogo,
|
||||
uploadMyTenantLogo,
|
||||
deleteMyTenantLogo,
|
||||
type User,
|
||||
type AuditEntry,
|
||||
type SMTPStatus,
|
||||
@@ -58,6 +63,7 @@ import {
|
||||
type LDAPTestResult,
|
||||
type TenantLDAPConfig,
|
||||
type Tenant,
|
||||
type TenantDefaultUser,
|
||||
type TenantDomain,
|
||||
type MailLabel,
|
||||
type LabelRule,
|
||||
@@ -206,6 +212,9 @@ export default function AdminPage() {
|
||||
const [newTenantSlug, setNewTenantSlug] = useState("");
|
||||
const [tenantCreateLoading, setTenantCreateLoading] = useState(false);
|
||||
const [tenantCreateError, setTenantCreateError] = useState("");
|
||||
const [tenantCreatedUsers, setTenantCreatedUsers] = useState<TenantDefaultUser[]>([]);
|
||||
const [tenantCreatedName, setTenantCreatedName] = useState("");
|
||||
const [tenantCredDialogOpen, setTenantCredDialogOpen] = useState(false);
|
||||
const [tenantDeleteId, setTenantDeleteId] = useState<number | null>(null);
|
||||
const [tenantDeleteLoading, setTenantDeleteLoading] = useState(false);
|
||||
const [domainDialogTenant, setDomainDialogTenant] = useState<Tenant | null>(null);
|
||||
@@ -239,6 +248,17 @@ export default function AdminPage() {
|
||||
// Superadmin: tenant LDAP dialog
|
||||
const [tenantLdapDialogId, setTenantLdapDialogId] = useState<number | null>(null);
|
||||
|
||||
// Logo dialog (superadmin: any tenant)
|
||||
const [logoDialogTenant, setLogoDialogTenant] = useState<Tenant | null>(null);
|
||||
const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | null>(null);
|
||||
const [logoUploading, setLogoUploading] = useState(false);
|
||||
const [logoError, setLogoError] = useState("");
|
||||
|
||||
// Logo for domain_admin own tenant
|
||||
const [ownLogoPreviewUrl, setOwnLogoPreviewUrl] = useState<string | null>(null);
|
||||
const [ownLogoUploading, setOwnLogoUploading] = useState(false);
|
||||
const [ownLogoError, setOwnLogoError] = useState("");
|
||||
|
||||
// Tenant users dialog
|
||||
const [tenantUsersDialogId, setTenantUsersDialogId] = useState<number | null>(null);
|
||||
const [tenantUsersDialogName, setTenantUsersDialogName] = useState("");
|
||||
@@ -713,8 +733,11 @@ export default function AdminPage() {
|
||||
setTenantCreateLoading(true);
|
||||
setTenantCreateError("");
|
||||
try {
|
||||
await createTenant(newTenantName, newTenantSlug);
|
||||
const result = await createTenant(newTenantName, newTenantSlug);
|
||||
setTenantDialogOpen(false);
|
||||
setTenantCreatedName(result.name);
|
||||
setTenantCreatedUsers(result.default_users ?? []);
|
||||
setTenantCredDialogOpen(true);
|
||||
setNewTenantName("");
|
||||
setNewTenantSlug("");
|
||||
await loadTenants();
|
||||
@@ -801,6 +824,89 @@ export default function AdminPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Logo handlers (superadmin: any tenant)
|
||||
async function openLogoDialog(t: Tenant) {
|
||||
setLogoDialogTenant(t);
|
||||
setLogoError("");
|
||||
setLogoPreviewUrl(null);
|
||||
if (t.has_logo) {
|
||||
try {
|
||||
const res = await fetch(getTenantLogoUrl(t.id), { credentials: "include" });
|
||||
if (res.ok) {
|
||||
const blob = await res.blob();
|
||||
setLogoPreviewUrl(URL.createObjectURL(blob));
|
||||
}
|
||||
} catch {
|
||||
// preview not critical
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogoUpload(file: File) {
|
||||
if (!logoDialogTenant) return;
|
||||
setLogoUploading(true);
|
||||
setLogoError("");
|
||||
try {
|
||||
await uploadTenantLogo(logoDialogTenant.id, file);
|
||||
const res = await fetch(getTenantLogoUrl(logoDialogTenant.id), { credentials: "include" });
|
||||
if (res.ok) {
|
||||
const blob = await res.blob();
|
||||
setLogoPreviewUrl(URL.createObjectURL(blob));
|
||||
}
|
||||
setTenants((prev) => prev.map((t) => t.id === logoDialogTenant.id ? { ...t, has_logo: true } : t));
|
||||
} catch (err: unknown) {
|
||||
setLogoError(err instanceof Error ? err.message : "Upload fehlgeschlagen.");
|
||||
} finally {
|
||||
setLogoUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogoDelete() {
|
||||
if (!logoDialogTenant) return;
|
||||
setLogoUploading(true);
|
||||
setLogoError("");
|
||||
try {
|
||||
await deleteTenantLogo(logoDialogTenant.id);
|
||||
setLogoPreviewUrl(null);
|
||||
setTenants((prev) => prev.map((t) => t.id === logoDialogTenant.id ? { ...t, has_logo: false } : t));
|
||||
} catch (err: unknown) {
|
||||
setLogoError(err instanceof Error ? err.message : "Löschen fehlgeschlagen.");
|
||||
} finally {
|
||||
setLogoUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Logo handlers (domain_admin: own tenant)
|
||||
async function handleOwnLogoUpload(file: File) {
|
||||
setOwnLogoUploading(true);
|
||||
setOwnLogoError("");
|
||||
try {
|
||||
await uploadMyTenantLogo(file);
|
||||
const res = await fetch(`/api/tenant/logo`, { credentials: "include" });
|
||||
if (res.ok) {
|
||||
const blob = await res.blob();
|
||||
setOwnLogoPreviewUrl(URL.createObjectURL(blob));
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setOwnLogoError(err instanceof Error ? err.message : "Upload fehlgeschlagen.");
|
||||
} finally {
|
||||
setOwnLogoUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOwnLogoDelete() {
|
||||
setOwnLogoUploading(true);
|
||||
setOwnLogoError("");
|
||||
try {
|
||||
await deleteMyTenantLogo();
|
||||
setOwnLogoPreviewUrl(null);
|
||||
} catch (err: unknown) {
|
||||
setOwnLogoError(err instanceof Error ? err.message : "Löschen fehlgeschlagen.");
|
||||
} finally {
|
||||
setOwnLogoUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Tenant LDAP handlers (domain_admin)
|
||||
const loadTenantLDAP = useCallback(async () => {
|
||||
setTenantLdapLoading(true);
|
||||
@@ -817,6 +923,16 @@ export default function AdminPage() {
|
||||
} finally {
|
||||
setTenantLdapLoading(false);
|
||||
}
|
||||
// Load own tenant logo preview
|
||||
try {
|
||||
const res = await fetch("/api/tenant/logo", { credentials: "include" });
|
||||
if (res.ok) {
|
||||
const blob = await res.blob();
|
||||
setOwnLogoPreviewUrl(URL.createObjectURL(blob));
|
||||
}
|
||||
} catch {
|
||||
// no logo or not available
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function handleSaveTenantLDAP(e: React.FormEvent) {
|
||||
@@ -2393,6 +2509,48 @@ export default function AdminPage() {
|
||||
{tenantLdapConfig.updated_by ? ` von ${tenantLdapConfig.updated_by}` : ""}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Logo section for domain_admin */}
|
||||
<div className="space-y-3 pt-2">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold">Mandanten-Logo</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">Logo deines Mandanten hochladen (PNG, JPEG, GIF, WebP oder SVG, max. 2 MB).</p>
|
||||
</div>
|
||||
{ownLogoError && <p className="text-sm text-destructive">{ownLogoError}</p>}
|
||||
{ownLogoPreviewUrl ? (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center justify-center rounded border p-3 bg-muted/30">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={ownLogoPreviewUrl} alt="Logo" className="max-h-20 max-w-40 object-contain" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml"
|
||||
disabled={ownLogoUploading}
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleOwnLogoUpload(f); }}
|
||||
className="w-auto"
|
||||
/>
|
||||
<Button variant="destructive" size="sm" disabled={ownLogoUploading} onClick={handleOwnLogoDelete}>
|
||||
{ownLogoUploading ? "Bitte warten..." : "Logo entfernen"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center justify-center rounded border p-6 bg-muted/30 text-sm text-muted-foreground w-40">
|
||||
Kein Logo
|
||||
</div>
|
||||
<Input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml"
|
||||
disabled={ownLogoUploading}
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleOwnLogoUpload(f); }}
|
||||
className="w-auto"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Mandanten ── */}
|
||||
@@ -2509,6 +2667,9 @@ export default function AdminPage() {
|
||||
<Button size="sm" variant="outline" onClick={() => setTenantLdapDialogId(t.id)}>
|
||||
LDAP
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => openLogoDialog(t)}>
|
||||
Logo
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => handleToggleTenant(t)}>
|
||||
{t.active ? "Deaktivieren" : "Aktivieren"}
|
||||
</Button>
|
||||
@@ -2546,6 +2707,70 @@ export default function AdminPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Logo upload dialog */}
|
||||
<Dialog open={logoDialogTenant !== null} onOpenChange={(open) => { if (!open) { setLogoDialogTenant(null); setLogoPreviewUrl(null); } }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Logo — {logoDialogTenant?.name}</DialogTitle>
|
||||
<DialogDescription>Logo hochladen (PNG, JPEG, GIF, WebP oder SVG, max. 2 MB).</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{logoPreviewUrl && (
|
||||
<div className="flex items-center justify-center rounded border p-4 bg-muted/30">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={logoPreviewUrl} alt="Logo" className="max-h-32 max-w-full object-contain" />
|
||||
</div>
|
||||
)}
|
||||
{!logoPreviewUrl && (
|
||||
<div className="flex items-center justify-center rounded border p-8 bg-muted/30 text-sm text-muted-foreground">
|
||||
Kein Logo gesetzt
|
||||
</div>
|
||||
)}
|
||||
{logoError && <p className="text-sm text-destructive">{logoError}</p>}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml"
|
||||
disabled={logoUploading}
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleLogoUpload(f); }}
|
||||
/>
|
||||
{logoPreviewUrl && (
|
||||
<Button variant="destructive" size="sm" disabled={logoUploading} onClick={handleLogoDelete}>
|
||||
Entfernen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => { setLogoDialogTenant(null); setLogoPreviewUrl(null); }}>Schließen</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Default credentials dialog after tenant creation */}
|
||||
<Dialog open={tenantCredDialogOpen} onOpenChange={setTenantCredDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Mandant „{tenantCreatedName}“ erstellt</DialogTitle>
|
||||
<DialogDescription>
|
||||
Folgende Standard-Benutzer wurden angelegt. Passwörter bitte sofort notieren — sie werden nur einmalig angezeigt.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
{tenantCreatedUsers.map((u) => (
|
||||
<div key={u.username} className="rounded border p-3 text-sm font-mono space-y-1">
|
||||
<div><span className="text-muted-foreground">Benutzer:</span> {u.username}</div>
|
||||
<div><span className="text-muted-foreground">Passwort:</span> {u.password}</div>
|
||||
<div><span className="text-muted-foreground">Rolle:</span> {u.role}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setTenantCredDialogOpen(false)}>Verstanden</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Domain management dialog */}
|
||||
<Dialog open={domainDialogTenant !== null} onOpenChange={(open) => { if (!open) setDomainDialogTenant(null); }}>
|
||||
<DialogContent>
|
||||
|
||||
+56
-2
@@ -693,6 +693,7 @@ export interface Tenant {
|
||||
user_count?: number;
|
||||
ldap_enabled?: boolean;
|
||||
ldap_url?: string;
|
||||
has_logo?: boolean;
|
||||
}
|
||||
|
||||
export interface TenantDomain {
|
||||
@@ -710,8 +711,18 @@ export async function getTenantUsers(tenantId: number): Promise<User[]> {
|
||||
return request<User[]>(`/api/tenants/${tenantId}/users`);
|
||||
}
|
||||
|
||||
export async function createTenant(name: string, slug: string): Promise<Tenant> {
|
||||
return request<Tenant>("/api/tenants", {
|
||||
export interface TenantDefaultUser {
|
||||
username: string;
|
||||
password: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface CreateTenantResponse extends Tenant {
|
||||
default_users: TenantDefaultUser[];
|
||||
}
|
||||
|
||||
export async function createTenant(name: string, slug: string): Promise<CreateTenantResponse> {
|
||||
return request<CreateTenantResponse>("/api/tenants", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, slug }),
|
||||
});
|
||||
@@ -754,6 +765,49 @@ export async function removeTenantDomain(
|
||||
});
|
||||
}
|
||||
|
||||
// ── Tenant Logo ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function getTenantLogoUrl(tenantId: number): string {
|
||||
return `${API_BASE}/api/tenants/${tenantId}/logo`;
|
||||
}
|
||||
|
||||
export async function uploadTenantLogo(tenantId: number, file: File): Promise<void> {
|
||||
const form = new FormData();
|
||||
form.append("logo", file);
|
||||
const res = await fetch(`${API_BASE}/api/tenants/${tenantId}/logo`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(body || `Upload failed: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTenantLogo(tenantId: number): Promise<void> {
|
||||
await request<void>(`/api/tenants/${tenantId}/logo`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// domain_admin: own tenant logo
|
||||
export async function uploadMyTenantLogo(file: File): Promise<void> {
|
||||
const form = new FormData();
|
||||
form.append("logo", file);
|
||||
const res = await fetch(`${API_BASE}/api/tenant/logo`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(body || `Upload failed: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteMyTenantLogo(): Promise<void> {
|
||||
await request<void>("/api/tenant/logo", { method: "DELETE" });
|
||||
}
|
||||
|
||||
// ── PROJ-23: Pro-Mandant LDAP (tenant_ldap) ──────────────────────────────
|
||||
|
||||
export interface TenantLDAPConfig extends LDAPConfig {
|
||||
|
||||
Reference in New Issue
Block a user