feat: perinco Pulse - Zeiterfassung fuer perinco
Erste Fassung, live unter https://pulse.perinco.de. Frontend React 19 + TypeScript + Tailwind 4 (Vite), Backend Express mit mysql2 auf MariaDB, Exporte als PDF (pdfkit), Excel (exceljs) und CSV. Aufbau, Designsystem und die sieben Themes kommen aus dem perinco CRM. Fachlich: - Buchung mit Wochen-, Monats- und Jahresansicht, Zeiten als 1:23 oder 1,5 - Projekte mit n Budgettoepfen: Laufzeit, Volumen, Sperre bei Ablauf oder aufgebrauchtem Budget, Buchung nur auf den passenden Topf - Kunden, Team mit Hierarchie, Reports im Harvest-Stil, Monatsexporte - Rollen user/lead/management/admin, doppelt geprueft (Menue und Backend) - SSO gegen Microsoft Entra ID, Uebernahme der AD-Benutzer per Drag & Drop Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,437 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Card, ErrorBox, Field, Spinner, Toggle, inputClass } from '../ui/controls';
|
||||
import { Icon, type IconName } from '../ui/Icon';
|
||||
import { Team } from '../team/Team';
|
||||
import { SsoDirectory } from './SsoDirectory';
|
||||
import { useAuth } from '../../lib/auth';
|
||||
import { download, settingsApi, type AdminSettings } from '../../lib/api';
|
||||
import { useLoader } from '../../lib/hooks';
|
||||
import { useRouter } from '../../lib/router';
|
||||
import { useToast } from '../Toast';
|
||||
|
||||
type Tab = 'allgemein' | 'export' | 'sso' | 'sso-verwaltung' | 'benutzer';
|
||||
const TABS: { key: Tab; label: string; icon: IconName; adminOnly: boolean }[] = [
|
||||
{ key: 'allgemein', label: 'Allgemein', icon: 'settings', adminOnly: true },
|
||||
{ key: 'export', label: 'Export: Kopf & Fuß', icon: 'document', adminOnly: true },
|
||||
{ key: 'sso', label: 'SSO / Entra ID', icon: 'key', adminOnly: true },
|
||||
// Management sieht in den Einstellungen nur diesen Reiter.
|
||||
{ key: 'sso-verwaltung', label: 'SSO-Verwaltung', icon: 'globe', adminOnly: false },
|
||||
{ key: 'benutzer', label: 'Benutzerverwaltung', icon: 'team', adminOnly: true },
|
||||
];
|
||||
|
||||
export const Settings: React.FC = () => {
|
||||
const { route, navigate } = useRouter();
|
||||
const { user } = useAuth();
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const tabs = TABS.filter((t) => isAdmin || !t.adminOnly);
|
||||
const tab = (tabs.find((t) => t.key === route.params[0])?.key ?? tabs[0].key) as Tab;
|
||||
const { data, error, reload, setData } = useLoader(() => (isAdmin ? settingsApi.all() : Promise.resolve(null)), [isAdmin]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto grid max-w-[1400px] gap-4 lg:grid-cols-[240px_1fr]">
|
||||
<nav className="no-scrollbar flex gap-1 overflow-x-auto lg:flex-col">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => navigate(`einstellungen/${t.key}`)}
|
||||
className={`flex shrink-0 items-center gap-2.5 rounded-xl px-3 py-2.5 text-sm font-bold transition ${
|
||||
tab === t.key ? 'bg-surface text-brand-700 shadow-card' : 'text-ink-500 hover:bg-surface hover:text-ink-800'
|
||||
}`}
|
||||
>
|
||||
<Icon name={t.icon} className="h-4 w-4" />
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className="min-w-0">
|
||||
{tab === 'sso-verwaltung' ? (
|
||||
<SsoDirectory />
|
||||
) : tab === 'benutzer' ? (
|
||||
<Team embedded />
|
||||
) : error && !data ? (
|
||||
<ErrorBox message={error} onRetry={reload} />
|
||||
) : !data ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<div key={tab} className="animate-fade">
|
||||
{tab === 'allgemein' && <General settings={data} onSaved={(s) => setData(s)} />}
|
||||
{tab === 'export' && <ExportSettings settings={data} onSaved={(s) => setData(s)} />}
|
||||
{tab === 'sso' && <SsoSettings settings={data} onSaved={reload} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function useSaver() {
|
||||
const { addToast } = useToast();
|
||||
const { reloadSettings } = useAuth();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const save = async (changes: Parameters<typeof settingsApi.save>[0], after?: () => void) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await settingsApi.save(changes);
|
||||
addToast('Einstellungen gespeichert.', 'success');
|
||||
reloadSettings();
|
||||
after?.();
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return { busy, save };
|
||||
}
|
||||
|
||||
const Section: React.FC<{ title: string; hint?: string; children: React.ReactNode; footer?: React.ReactNode }> = ({ title, hint, children, footer }) => (
|
||||
<Card className="animate-rise overflow-hidden">
|
||||
<div className="border-b border-ink-100 px-5 py-4">
|
||||
<h2 className="text-base text-ink-900">{title}</h2>
|
||||
{hint && <p className="mt-0.5 text-xs text-ink-400">{hint}</p>}
|
||||
</div>
|
||||
<div className="space-y-4 p-5">{children}</div>
|
||||
{footer && <div className="flex justify-end gap-2 border-t border-ink-100 bg-ink-50/60 px-5 py-3">{footer}</div>}
|
||||
</Card>
|
||||
);
|
||||
|
||||
const General: React.FC<{ settings: AdminSettings; onSaved: (s: AdminSettings) => void }> = ({ settings, onSaved }) => {
|
||||
const [form, setForm] = useState({
|
||||
weekly_hours: String(settings.weekly_hours),
|
||||
hours_per_day: String(settings.hours_per_day),
|
||||
budget_warn_percent: String(settings.budget_warn_percent),
|
||||
budget_warn_days: String(settings.budget_warn_days),
|
||||
company_name: settings.company_name,
|
||||
project_code_pattern: settings.project_code_pattern,
|
||||
project_name_pattern: settings.project_name_pattern,
|
||||
});
|
||||
const { busy, save } = useSaver();
|
||||
const set = (patch: Partial<typeof form>) => setForm({ ...form, ...patch });
|
||||
const n = (v: string) => Number(v.replace(',', '.'));
|
||||
const example = form.project_code_pattern.replaceAll('{KUERZEL}', 'KUKA').replaceAll('{JAHR}', '2026').replaceAll('{JJ}', '26').replaceAll('{NR}', '003');
|
||||
const exampleName = form.project_name_pattern.replaceAll('{KUNDE}', 'KUKA').replaceAll('{KUNDENNAME}', 'KUKA AG').replaceAll('{PROJEKT}', 'PMO').replaceAll('{CODE}', example);
|
||||
|
||||
const submit = () => {
|
||||
const changes = {
|
||||
weekly_hours: n(form.weekly_hours),
|
||||
hours_per_day: n(form.hours_per_day),
|
||||
budget_warn_percent: n(form.budget_warn_percent),
|
||||
budget_warn_days: n(form.budget_warn_days),
|
||||
company_name: form.company_name,
|
||||
project_code_pattern: form.project_code_pattern,
|
||||
project_name_pattern: form.project_name_pattern,
|
||||
};
|
||||
save(changes, () => onSaved({ ...settings, ...changes }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Section title="Arbeitszeit" hint="Grundlage für Soll-Stunden, Auslastung und die Umrechnung Stunden ↔ Tage">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Field label="Arbeitsstunden pro Woche" hint="Standard für alle, pro Benutzer überschreibbar">
|
||||
<input inputMode="decimal" className={inputClass} value={form.weekly_hours} onChange={(e) => set({ weekly_hours: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Stunden pro Arbeitstag" hint="Für Tagessätze und Budget in Tagen">
|
||||
<input inputMode="decimal" className={inputClass} value={form.hours_per_day} onChange={(e) => set({ hours_per_day: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Firmenname">
|
||||
<input className={inputClass} value={form.company_name} onChange={(e) => set({ company_name: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
</Section>
|
||||
<Section title="Budgetwarnungen" hint="Ab wann Pulse beim Buchen und in der Projektübersicht warnt. Gesperrt wird immer erst bei 100 % oder nach Ablauf.">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Warnen ab Verbrauch (%)">
|
||||
<input inputMode="numeric" className={inputClass} value={form.budget_warn_percent} onChange={(e) => set({ budget_warn_percent: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Warnen ab Tagen vor Ablauf">
|
||||
<input inputMode="numeric" className={inputClass} value={form.budget_warn_days} onChange={(e) => set({ budget_warn_days: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
</Section>
|
||||
<Section
|
||||
title="Projektbezeichnungen"
|
||||
hint="Muster für den automatisch vergebenen Projektcode und den Anzeigenamen"
|
||||
footer={
|
||||
<Button icon="check" busy={busy} onClick={submit}>
|
||||
Speichern
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Projektcode" hint={<>Platzhalter: {'{KUERZEL} {JAHR} {JJ} {NR}'} · Beispiel: <b className="text-ink-700">{example}</b></>}>
|
||||
<input className={`${inputClass} tabular`} value={form.project_code_pattern} onChange={(e) => set({ project_code_pattern: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Anzeigename" hint={<>Platzhalter: {'{KUNDE} {KUNDENNAME} {PROJEKT} {CODE}'} · Beispiel: <b className="text-ink-700">{exampleName}</b></>}>
|
||||
<input className={inputClass} value={form.project_name_pattern} onChange={(e) => set({ project_name_pattern: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ExportSettings: React.FC<{ settings: AdminSettings; onSaved: (s: AdminSettings) => void }> = ({ settings, onSaved }) => {
|
||||
const [ex, setEx] = useState(settings.export);
|
||||
const { busy, save } = useSaver();
|
||||
const { addToast } = useToast();
|
||||
const set = (patch: Partial<typeof ex>) => setEx({ ...ex, ...patch });
|
||||
|
||||
const onLogo = (file: File | undefined) => {
|
||||
if (!file) return;
|
||||
if (!/image\/(png|jpe?g)/.test(file.type)) return addToast('Bitte PNG oder JPG wählen.', 'error');
|
||||
if (file.size > 1_800_000) return addToast('Das Logo ist zu groß (max. 1,8 MB).', 'error');
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => set({ logo: String(reader.result) });
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const preview = async (format: 'pdf' | 'xlsx') => {
|
||||
try {
|
||||
await download(`/settings/export-preview?format=${format}`, `Vorschau.${format}`, format === 'pdf');
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Vorschau fehlgeschlagen.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const text = (key: keyof typeof ex, label: string, hint?: string) => (
|
||||
<Field label={label} hint={hint}>
|
||||
<input className={inputClass} value={String(ex[key] ?? '')} onChange={(e) => set({ [key]: e.target.value })} />
|
||||
</Field>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 2xl:grid-cols-[1fr_520px]">
|
||||
<Section
|
||||
title="Kopf und Fuß der Exporte"
|
||||
hint="Gilt für Stundennachweise und Reports in PDF und Excel"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" icon="document" onClick={() => preview('pdf')}>
|
||||
PDF-Vorschau
|
||||
</Button>
|
||||
<Button variant="secondary" icon="grid" onClick={() => preview('xlsx')}>
|
||||
Excel-Vorschau
|
||||
</Button>
|
||||
<Button icon="check" busy={busy} onClick={() => save({ export: ex }, () => onSaved({ ...settings, export: ex }))}>
|
||||
Speichern
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-xs text-ink-400">Die Vorschau zeigt den zuletzt gespeicherten Stand.</p>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<span className="field-label mb-1.5 block text-[11px] font-bold uppercase tracking-wider text-ink-500">Logo</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-16 w-40 items-center justify-center rounded-xl border border-ink-200 bg-white p-2">
|
||||
<img src={ex.logo || '/logo.png'} alt="Logo" className="max-h-full max-w-full object-contain" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="block cursor-pointer text-xs font-bold text-brand-700 underline">
|
||||
Hochladen
|
||||
<input type="file" accept="image/png,image/jpeg" className="hidden" onChange={(e) => onLogo(e.target.files?.[0])} />
|
||||
</label>
|
||||
{ex.logo && (
|
||||
<button className="block text-xs font-bold text-ink-400 underline" onClick={() => set({ logo: null })}>
|
||||
perinco Logo verwenden
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Field label="Akzentfarbe">
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="color" className="h-10 w-14 cursor-pointer rounded-lg border border-ink-200 bg-surface" value={ex.accent} onChange={(e) => set({ accent: e.target.value })} />
|
||||
<input className={`${inputClass} tabular`} value={ex.accent} onChange={(e) => set({ accent: e.target.value })} />
|
||||
</div>
|
||||
</Field>
|
||||
{text('title', 'Titel', 'z. B. Stundennachweis, Leistungsnachweis')}
|
||||
{text('subtitle', 'Kopfzeile rechts (1. Zeile)')}
|
||||
{text('header_right', 'Kopfzeile rechts (2. Zeile)')}
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Field label="Fuß links">
|
||||
<textarea className={`${inputClass} h-20`} value={ex.footer_left} onChange={(e) => set({ footer_left: e.target.value })} placeholder={'perinco GmbH\nStraße 1 · 12345 Ort'} />
|
||||
</Field>
|
||||
<Field label="Fuß Mitte">
|
||||
<textarea className={`${inputClass} h-20`} value={ex.footer_center} onChange={(e) => set({ footer_center: e.target.value })} placeholder={'Geschäftsführer …\nHRB …'} />
|
||||
</Field>
|
||||
<Field label="Fuß rechts">
|
||||
<textarea className={`${inputClass} h-20`} value={ex.footer_right} onChange={(e) => set({ footer_right: e.target.value })} placeholder={'www.perinco.de\nBank · IBAN'} />
|
||||
</Field>
|
||||
</div>
|
||||
<Toggle checked={ex.show_signature} onChange={(v) => set({ show_signature: v })} label="Unterschriftenfelder" hint="Am Ende des Stundennachweises – zur Abzeichnung durch den Kunden." />
|
||||
{ex.show_signature && (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{text('signature_left', 'Unterschrift links')}
|
||||
{text('signature_right', 'Unterschrift rechts')}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Card className="animate-rise h-fit p-4">
|
||||
<p className="mb-3 text-[11px] font-extrabold uppercase tracking-wider text-ink-400">Live-Skizze (A4)</p>
|
||||
<div className="mx-auto aspect-[210/297] w-full max-w-[420px] rounded-md bg-white p-[6%] text-[#1f1f1f] shadow-lift" style={{ fontFamily: 'Helvetica, Arial, sans-serif' }}>
|
||||
<div className="flex items-start justify-between">
|
||||
<img src={ex.logo || '/logo.png'} alt="" className="h-8 w-auto max-w-[35%] object-contain" />
|
||||
<div className="text-right text-[7px] leading-tight text-[#6e6e6e]">
|
||||
<div>{ex.subtitle}</div>
|
||||
<div>{ex.header_right}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 h-[1.5px]" style={{ background: ex.accent }} />
|
||||
<p className="mt-3 text-[13px] font-bold">{ex.title || 'Stundennachweis'}</p>
|
||||
<p className="text-[8px]" style={{ color: ex.accent }}>
|
||||
September 2026
|
||||
</p>
|
||||
<div className="mt-2 grid grid-cols-4 gap-1">
|
||||
{['72,50', '72:30', '11', '34,00'].map((v) => (
|
||||
<div key={v} className="rounded-sm border-l-2 bg-[#f4f7f1] px-1 py-1 text-[7px] font-bold" style={{ borderColor: ex.accent }}>
|
||||
{v}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 h-2.5 rounded-[1px]" style={{ background: ex.accent }} />
|
||||
{Array.from({ length: 9 }).map((_, i) => (
|
||||
<div key={i} className={`h-2.5 border-b border-[#eee] ${i % 2 ? 'bg-[#f4f7f1]' : ''}`} />
|
||||
))}
|
||||
{ex.show_signature && (
|
||||
<div className="mt-6 grid grid-cols-2 gap-4 text-[6px] text-[#6e6e6e]">
|
||||
<div className="border-t border-[#9a9a9a] pt-0.5">{ex.signature_left}</div>
|
||||
<div className="border-t border-[#9a9a9a] pt-0.5">{ex.signature_right}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-auto" />
|
||||
<div className="mt-8 grid grid-cols-3 border-t border-[#ddd] pt-1 text-[6px] leading-tight text-[#6e6e6e]">
|
||||
<div className="whitespace-pre-line">{ex.footer_left}</div>
|
||||
<div className="whitespace-pre-line text-center">{ex.footer_center}</div>
|
||||
<div className="whitespace-pre-line text-right">
|
||||
{ex.footer_right}
|
||||
{'\n'}Seite 1 von 1
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SsoSettings: React.FC<{ settings: AdminSettings; onSaved: () => void }> = ({ settings, onSaved }) => {
|
||||
const [sso, setSso] = useState({ ...settings.sso, client_secret: '' });
|
||||
const { busy, save } = useSaver();
|
||||
const { addToast } = useToast();
|
||||
const { navigate } = useRouter();
|
||||
const [testing, setTesting] = useState(false);
|
||||
const set = (patch: Partial<typeof sso>) => setSso({ ...sso, ...patch });
|
||||
|
||||
useEffect(() => setSso({ ...settings.sso, client_secret: '' }), [settings.sso]);
|
||||
|
||||
const test = async () => {
|
||||
setTesting(true);
|
||||
try {
|
||||
const r = await settingsApi.ssoTest();
|
||||
addToast(`Verbindung ok – ${r.eligible} von ${r.checked} geprüften Konten zulässig. Beispiel: ${r.sample.join(', ') || '–'}`, 'success');
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Test fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const text = (key: 'tenant_id' | 'client_id' | 'redirect_uri' | 'button_label', label: string, hint?: React.ReactNode, placeholder?: string) => (
|
||||
<Field label={label} hint={hint}>
|
||||
<input
|
||||
name={`pulse-sso-${key}`}
|
||||
autoComplete="off"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
className={`${inputClass} font-mono text-xs`}
|
||||
value={sso[key]}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => set({ [key]: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Section
|
||||
title="Microsoft Entra ID (M365)"
|
||||
hint="Anmeldung per OpenID Connect, Mitarbeiterliste über Microsoft Graph"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" icon="refresh" busy={testing} onClick={test} disabled={!settings.sso.configured}>
|
||||
Verbindung testen
|
||||
</Button>
|
||||
<Button icon="check" busy={busy} onClick={() => save({ sso: { ...sso, client_secret: sso.client_secret || undefined } }, onSaved)}>
|
||||
Speichern
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className={`flex items-center gap-3 rounded-xl px-4 py-3 text-sm font-semibold ${settings.sso.enabled ? 'bg-brand-50 text-brand-800' : 'bg-ink-50 text-ink-600'}`}>
|
||||
<Icon name={settings.sso.enabled ? 'check' : 'info'} className="h-5 w-5" />
|
||||
{settings.sso.enabled
|
||||
? 'SSO ist aktiv – der Anmeldebildschirm zeigt „Mit Microsoft anmelden“.'
|
||||
: settings.sso.configured
|
||||
? 'Zugangsdaten hinterlegt, SSO ist noch nicht eingeschaltet.'
|
||||
: 'Noch nicht eingerichtet – bis dahin melden sich alle lokal an.'}
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{text('tenant_id', 'Tenant-ID (Verzeichnis-ID)', undefined, '00000000-0000-0000-0000-000000000000')}
|
||||
{text('client_id', 'Client-ID (Anwendungs-ID)', undefined, '00000000-0000-0000-0000-000000000000')}
|
||||
<Field label="Client Secret (Wert)" hint={settings.sso.client_secret_set ? 'Hinterlegt (verschlüsselt). Leer lassen = unverändert.' : 'Der „Wert“, nicht die Secret-ID. Wird verschlüsselt gespeichert und nie wieder angezeigt.'}>
|
||||
{/* Kein type="password" - Passwortmanager (Brave) sollen hier nicht anspringen. */}
|
||||
<input
|
||||
name="pulse-sso-client-credential"
|
||||
autoComplete="off"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
className={`${inputClass} font-mono text-xs`}
|
||||
style={{ WebkitTextSecurity: 'disc' } as React.CSSProperties}
|
||||
value={sso.client_secret}
|
||||
onChange={(e) => set({ client_secret: e.target.value })}
|
||||
placeholder={settings.sso.client_secret_set ? '••••••••••••' : ''}
|
||||
/>
|
||||
</Field>
|
||||
{text('redirect_uri', 'Redirect-URI', 'In der App-Registrierung als Web-Plattform eintragen')}
|
||||
{text('button_label', 'Beschriftung des Anmeldeknopfs')}
|
||||
</div>
|
||||
<div className="space-y-3 rounded-2xl border border-ink-200 p-4">
|
||||
<Toggle checked={sso.enabled} onChange={(v) => set({ enabled: v })} label="SSO aktivieren" hint="Zeigt den Microsoft-Anmeldeknopf. Anmelden können sich nur Konten, die in der SSO-Verwaltung übernommen wurden." />
|
||||
<Toggle
|
||||
checked={sso.local_login}
|
||||
onChange={(v) => set({ local_login: v })}
|
||||
label="Lokale Anmeldung direkt anzeigen"
|
||||
hint="Aus: das Formular für externe Benutzer und lokale Konten liegt hinter einem Link. Externe melden sich immer lokal an."
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Mitarbeiter übernehmen">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<p className="min-w-0 flex-1 text-sm text-ink-600">
|
||||
Angezeigt werden nur Konten im Format <code className="rounded bg-ink-100 px-1 text-xs">vorname.nachname@perinco.de</code> bzw.{' '}
|
||||
<code className="rounded bg-ink-100 px-1 text-xs">@icon-experts.de</code> – ohne Admin-Konten, freigegebene Postfächer und Gäste. Übernehmen und Rollen vergeben per Drag & Drop, auch für Manager.
|
||||
</p>
|
||||
<Button icon="globe" onClick={() => navigate('einstellungen/sso-verwaltung')}>
|
||||
Zur SSO-Verwaltung
|
||||
</Button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Einrichtung in Entra ID">
|
||||
<ol className="list-decimal space-y-1.5 pl-5 text-sm text-ink-600">
|
||||
<li>entra.microsoft.com → App-Registrierungen → Neue Registrierung, Name „Pulse“, Kontotyp „Nur Konten in diesem Organisationsverzeichnis“.</li>
|
||||
<li>
|
||||
Umleitungs-URI: Plattform „Web“, <code className="rounded bg-ink-100 px-1 text-xs">{sso.redirect_uri}</code>
|
||||
</li>
|
||||
<li>Zertifikate & Geheimnisse → Neuer geheimer Clientschlüssel → den <b>Wert</b> hier eintragen (Ablaufdatum notieren, max. 24 Monate).</li>
|
||||
<li>API-Berechtigungen → Microsoft Graph: delegiert <code className="rounded bg-ink-100 px-1 text-xs">openid profile email</code>, Anwendung <code className="rounded bg-ink-100 px-1 text-xs">User.Read.All</code> → „Administratorzustimmung erteilen“.</li>
|
||||
<li>Tenant- und Client-ID aus der Übersicht kopieren, speichern, „Verbindung testen“, Mitarbeiter in der SSO-Verwaltung übernehmen, dann SSO aktivieren.</li>
|
||||
</ol>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user