Zentrale Anmeldung

This commit is contained in:
2026-03-21 22:19:52 +01:00
parent 491605b328
commit 456224f56e
12 changed files with 827 additions and 182 deletions
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Modules\Central\Controllers;
use App\Modules\Identity\Services\AuthService;
use App\Modules\Tenants\Services\TenantService;
class LandingController
{
public function __construct(
private readonly TenantService $tenantService,
private readonly AuthService $authService
) {
}
public function index(): array
{
return [
'view' => 'welcome',
'data' => [
'title' => 'Kaffeeliste SaaS',
'tenantOverview' => $this->tenantService->adminOverview(),
'centralLoginPreview' => $this->authService->centralLoginPreview(),
],
];
}
}
@@ -19,6 +19,7 @@ class LoginController
'data' => [
'title' => 'Anmeldung',
'providers' => $this->authService->availableProviders(),
'loginPreview' => $this->authService->centralLoginPreview(),
],
];
}
@@ -4,13 +4,19 @@ declare(strict_types=1);
namespace App\Modules\Identity\Services;
use App\Modules\Tenants\Services\TenantService;
class AuthService
{
public function __construct(private readonly TenantService $tenantService)
{
}
public function availableProviders(): array
{
return [
'password' => [
'label' => 'Lokaler Login',
'label' => 'Zentraler Passwort-Login',
'type' => 'password',
],
'oidc' => [
@@ -20,11 +26,64 @@ class AuthService
];
}
/**
* @return array<string, mixed>
*/
public function centralLoginPreview(): array
{
$singleEmail = 'mia@berlin.example';
$multipleEmail = 'leitung@kaffeeliste.example';
$unknownEmail = 'extern@example.org';
return [
'single' => [
'email' => $singleEmail,
'status' => 'redirect-tenant',
'matches' => $this->tenantService->lookupTenantsByEmail($singleEmail),
'message' => 'Bei genau einem Treffer wird direkt in den zugeordneten Tenant weitergeleitet.',
],
'multiple' => [
'email' => $multipleEmail,
'status' => 'select-tenant',
'matches' => $this->tenantService->lookupTenantsByEmail($multipleEmail),
'message' => 'Bei mehreren Trefferkonten erscheint zuerst eine Tenant-Auswahl fuer denselben Mitarbeiter.',
],
'unknown' => [
'email' => $unknownEmail,
'status' => 'request-tenant',
'matches' => [],
'message' => 'Unbekannte Mail-Adressen werden nicht ins Leere geleitet, sondern klar auf Tenant- oder Admin-Kontakt gefuehrt.',
],
];
}
public function attemptLogin(array $payload): array
{
$email = strtolower(trim((string) ($payload['email'] ?? '')));
$matches = $this->tenantService->lookupTenantsByEmail($email);
if (count($matches) === 1) {
$tenant = $matches[0];
return [
'status' => 'redirect-tenant',
'email' => $email,
'tenant' => $tenant,
'redirect_to' => 'https://' . $tenant['domain'] . '/login',
];
}
if (count($matches) > 1) {
return [
'status' => 'select-tenant',
'email' => $email,
'tenants' => $matches,
];
}
return [
'status' => 'not-implemented',
'email' => $payload['email'] ?? null,
'status' => 'request-tenant',
'email' => $email !== '' ? $email : null,
];
}
}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Modules\Tenants\Controllers;
use App\Modules\Tenants\Services\TenantService;
class TenantConsoleController
{
public function __construct(private readonly TenantService $tenantService)
{
}
public function index(): array
{
return [
'view' => 'tenants.index',
'data' => [
'title' => 'Tenant Console',
'overview' => $this->tenantService->adminOverview(),
],
];
}
}
@@ -34,8 +34,178 @@ class TenantService
*/
public function listTenants(): array
{
return array_map(
static fn (array $tenant): Tenant => new Tenant(
$tenant['id'],
$tenant['tenant_key'],
$tenant['name'],
$tenant['status']
),
$this->tenantPortfolio()
);
}
/**
* @return array<string, mixed>
*/
public function adminOverview(): array
{
$portfolio = $this->tenantPortfolio();
$activeCount = count(array_filter($portfolio, static fn (array $tenant): bool => $tenant['status'] === 'active'));
$memberCount = array_sum(array_map(static fn (array $tenant): int => $tenant['member_count'], $portfolio));
$providerCount = array_sum(array_map(static fn (array $tenant): int => $tenant['oidc_provider_count'], $portfolio));
return [
new Tenant('tenant-placeholder', 'demo', 'Demo Tenant', 'active'),
'metrics' => [
[
'label' => 'Aktive Tenants',
'value' => (string) $activeCount,
'detail' => 'Mandanten mit aktivem Produktbetrieb und freigeschalteter Plattform.',
],
[
'label' => 'Mitglieder gesamt',
'value' => (string) $memberCount,
'detail' => 'Alle aktiven Nutzerzuordnungen ueber die Mandantenlandschaft.',
],
[
'label' => 'SSO-Abdeckung',
'value' => (string) $providerCount,
'detail' => 'Konfigurierte OIDC-Provider fuer zentrale oder tenantbezogene Anmeldung.',
],
[
'label' => 'Betriebsstatus',
'value' => 'Stabil',
'detail' => 'Queues, Exporte und Benachrichtigungen sind fuer Cron-Betrieb vorbereitet.',
],
],
'tenants' => $portfolio,
'shared_access' => [
[
'email' => 'leitung@kaffeeliste.example',
'tenants' => ['Werk Berlin', 'Werk Koeln', 'Shared Services'],
'next_step' => 'Tenant-Auswahl vor Passwort oder SSO anzeigen',
'status' => 'mehrfach',
],
[
'email' => 'mia@berlin.example',
'tenants' => ['Werk Berlin'],
'next_step' => 'Direkt an den Tenant-Login weiterleiten',
'status' => 'einzeln',
],
[
'email' => 'extern@example.org',
'tenants' => [],
'next_step' => 'Kontakt zum Tenant-Admin oder zentrale Einladung anbieten',
'status' => 'unbekannt',
],
],
'operations' => [
[
'title' => 'Neuen Tenant live schalten',
'detail' => 'Domain, Initial-Admin, Branding und Importpfad in einem zentralen Rollout steuern.',
'state' => 'Bereit',
],
[
'title' => 'Mitglieder zentral pruefen',
'detail' => 'Mehrfachzuordnungen und offene Tenant-Einladungen vor Freischaltung abstimmen.',
'state' => 'Im Fokus',
],
[
'title' => 'SSO-Rollout konsolidieren',
'detail' => 'Provider, Redirect-URIs und Fallback-Login tenantweise vereinheitlichen.',
'state' => 'In Arbeit',
],
],
];
}
/**
* @return array<int, array<string, mixed>>
*/
public function lookupTenantsByEmail(string $email): array
{
$normalized = strtolower(trim($email));
$directory = [
'mia@berlin.example' => ['berlin'],
'leitung@kaffeeliste.example' => ['berlin', 'koeln', 'shared-services'],
'ops@kaffeeliste.example' => ['shared-services', 'finance-hub'],
];
$matches = $directory[$normalized] ?? [];
$portfolio = [];
foreach ($this->tenantPortfolio() as $tenant) {
if (in_array($tenant['tenant_key'], $matches, true)) {
$portfolio[] = $tenant;
}
}
return $portfolio;
}
/**
* @return array<int, array<string, mixed>>
*/
public function tenantPortfolio(): array
{
return [
[
'id' => 'tenant-berlin',
'tenant_key' => 'berlin',
'name' => 'Werk Berlin',
'status' => 'active',
'domain' => 'berlin.kaffeeliste.de',
'member_count' => 42,
'admin_count' => 4,
'oidc_provider_count' => 1,
'tenant_health' => 'stabil',
'billing_state' => 'aktiv',
'login_mode' => 'oidc-first',
'primary_contact' => 'Office Operations Berlin',
],
[
'id' => 'tenant-koeln',
'tenant_key' => 'koeln',
'name' => 'Werk Koeln',
'status' => 'active',
'domain' => 'koeln.kaffeeliste.de',
'member_count' => 31,
'admin_count' => 3,
'oidc_provider_count' => 1,
'tenant_health' => 'stabil',
'billing_state' => 'aktiv',
'login_mode' => 'oidc-first',
'primary_contact' => 'Backoffice Koeln',
],
[
'id' => 'tenant-shared-services',
'tenant_key' => 'shared-services',
'name' => 'Shared Services',
'status' => 'active',
'domain' => 'shared.kaffeeliste.de',
'member_count' => 18,
'admin_count' => 2,
'oidc_provider_count' => 2,
'tenant_health' => 'stabil',
'billing_state' => 'aktiv',
'login_mode' => 'central-routing',
'primary_contact' => 'Platform Operations',
],
[
'id' => 'tenant-finance-hub',
'tenant_key' => 'finance-hub',
'name' => 'Finance Hub',
'status' => 'sandbox',
'domain' => 'finance.kaffeeliste.de',
'member_count' => 9,
'admin_count' => 2,
'oidc_provider_count' => 0,
'tenant_health' => 'boarding',
'billing_state' => 'test',
'login_mode' => 'password-fallback',
'primary_contact' => 'Finance Enablement',
],
];
}
}
+137 -34
View File
@@ -1,58 +1,161 @@
@extends('layouts.app')
@section('page_title', 'Kaffeeliste SaaS - Login')
@section('page_title', 'Kaffeeliste SaaS - Zentrale Anmeldung')
@php
$preview = $loginPreview ?? [
'single' => [
'email' => 'mia@berlin.example',
'message' => 'Direkte Weiterleitung in den zugeordneten Tenant.',
'matches' => [['name' => 'Werk Berlin', 'domain' => 'berlin.kaffeeliste.de', 'login_mode' => 'oidc-first']],
],
'multiple' => [
'email' => 'leitung@kaffeeliste.example',
'message' => 'Tenant-Auswahl vor Passwort oder SSO.',
'matches' => [
['name' => 'Werk Berlin', 'domain' => 'berlin.kaffeeliste.de', 'login_mode' => 'oidc-first'],
['name' => 'Werk Koeln', 'domain' => 'koeln.kaffeeliste.de', 'login_mode' => 'oidc-first'],
['name' => 'Shared Services', 'domain' => 'shared.kaffeeliste.de', 'login_mode' => 'central-routing'],
],
],
'unknown' => [
'email' => 'extern@example.org',
'message' => 'Rueckfuehrung auf Tenant-Admin oder Einladungspfad.',
'matches' => [],
],
];
@endphp
@section('content')
<section class="hero">
<div>
<p class="hero__kicker">Identity</p>
<h2 class="hero__title">Mandantenbezogener Login mit lokalem Fallback und OIDC-Zielbild.</h2>
<p class="hero__lead">
Die Legacy-Anbindung ueber `AUTH_USER` und LDAP wird in eine flexiblere
Identity-Schicht ueberfuehrt. SSO bleibt bevorzugt, lokaler Login und
Reset-Prozess sichern den Betrieb ab.
</p>
</div>
<div class="toolbar">
<span class="badge">Tenant erkannt</span>
<span class="badge">OIDC first</span>
<span class="badge badge--solid">Fallback bereit</span>
<section class="hero hero--split">
<div class="hero__content">
<div>
<p class="hero__kicker">Mitglieder-Login</p>
<h2 class="hero__title">Eine zentrale Anmeldung, die Mitglieder automatisch in den richtigen Tenant bringt.</h2>
<p class="hero__lead">
Statt verschiedene Tenant-URLs zu kennen, starten Mitglieder zentral mit ihrer E-Mail-Adresse. Die Plattform
erkennt Einzel- oder Mehrfachzuordnungen und fuehrt danach passend weiter: direkt in den Tenant, in eine
Tenant-Auswahl oder in einen klaren Kontaktpfad.
</p>
</div>
<div class="hero__actions">
<a class="button" href="#mitglied-login">Mitglieder-Login starten</a>
<a class="button button--ghost" href="/tenants">Admin Console ansehen</a>
</div>
<div class="hero__meta">
<span class="badge">E-Mail first</span>
<span class="badge">Tenant-Erkennung</span>
<span class="badge badge--solid">Mehrfachzuordnung unterstuetzt</span>
</div>
</div>
<aside class="hero__aside">
<article class="card metric metric--compact">
<p class="metric__label">Ein Tenant erkannt</p>
<div class="metric__value">1 Klick</div>
<p class="muted">Bei genau einer Mitgliedschaft wird direkt in den passenden Tenant weitergeleitet.</p>
</article>
<article class="card metric metric--compact">
<p class="metric__label">Mehrfachzuordnung</p>
<div class="metric__value">{{ count($preview['multiple']['matches']) }} Tenants</div>
<p class="muted">Die Auswahl erscheint nur dann, wenn fuer dieselbe Mail-Adresse mehrere Kontexte hinterlegt sind.</p>
</article>
<div class="callout">
<strong>SSO und Passwort aus einem Einstieg</strong>
Die zentrale Anmeldung entscheidet zuerst ueber den Tenant-Kontext. Danach folgen Passwort-Login, OIDC oder ein
klarer Fallback fuer kleinere Tenants.
</div>
</aside>
</section>
<section class="split">
<section id="mitglied-login" class="split">
<article class="form-panel">
<p class="card__eyebrow">Lokaler Login</p>
<h3>Mit Passwort anmelden</h3>
<form class="form-grid" method="post" action="/login">
<p class="card__eyebrow">Zentraler Einstieg</p>
<h3>Mit E-Mail und Passwort anmelden</h3>
<p class="muted">
Die E-Mail-Adresse steuert zuerst die Tenant-Erkennung. Der Passwort- oder SSO-Schritt kommt danach im richtigen Kontext.
</p>
<form class="form-grid" method="post" action="/login" style="margin-top: 18px;">
<div class="field">
<label for="email">E-Mail</label>
<input id="email" name="email" type="email" autocomplete="username" placeholder="mitglied@example.com">
<label for="email">E-Mail-Adresse</label>
<input id="email" name="email" type="email" autocomplete="username" placeholder="mitglied@unternehmen.de">
</div>
<div class="field">
<label for="password">Passwort</label>
<input id="password" name="password" type="password" autocomplete="current-password" placeholder="........">
<input id="password" name="password" type="password" autocomplete="current-password" placeholder="••••••••">
</div>
<div class="toolbar">
<button type="submit">Mit Passwort anmelden</button>
<button type="submit">Zentral anmelden</button>
<a class="button button--ghost" href="/forgot-password">Passwort vergessen?</a>
</div>
</form>
<div class="note" style="margin-top: 18px;">
Zielbild: Eine zentrale Anmeldung fuer Mitglieder, ohne dass zuerst Tenant-Keys oder Subdomains bekannt sein muessen.
</div>
</article>
<article class="panel">
<h3>Single Sign-on</h3>
<p class="muted">
Tenant-Admins hinterlegen je Mandant einen oder mehrere OIDC-Provider.
So wird die bisherige AD-Naehe in ein flexibles SaaS-Modell ueberfuehrt.
</p>
<div class="stack" style="margin-top: 18px;">
<a class="button" href="/auth/oidc/entra-default">Mit OIDC / Entra anmelden</a>
<span class="badge">Provider: entra-default</span>
<span class="badge">Tenant: demo</span>
<p class="card__eyebrow">Was danach passiert</p>
<h3>Automatische Weiterleitung oder Tenant-Auswahl.</h3>
<div class="timeline timeline--tight" style="margin-top: 18px;">
<div class="timeline__item">
<p class="timeline__title">Genau ein Treffer</p>
<p class="timeline__meta">Die Plattform leitet direkt in den Tenant weiter und zeigt dort Passwort- oder SSO-Login an.</p>
</div>
<div class="timeline__item">
<p class="timeline__title">Mehrere Treffer</p>
<p class="timeline__meta">Vor dem eigentlichen Login erscheint eine Tenant-Auswahl mit Namen, Domain und Login-Modell.</p>
</div>
<div class="timeline__item">
<p class="timeline__title">Kein Treffer</p>
<p class="timeline__meta">Statt Sackgasse gibt es einen klaren Rueckweg zu Tenant-Admin, Einladung oder Support.</p>
</div>
</div>
<div class="note" style="margin-top: 18px;">
Lokaler Login bleibt wichtig fuer Initialsetup, Notfallbetrieb und kleinere Tenants ohne SSO.
</article>
</section>
<section class="grid grid--3" style="margin-top: 18px;">
<article class="card">
<p class="card__eyebrow">Preview: Einzelzuordnung</p>
<h3>{{ $preview['single']['email'] }}</h3>
<p class="muted">{{ $preview['single']['message'] }}</p>
<div class="tenant-grid" style="margin-top: 18px;">
@foreach ($preview['single']['matches'] as $tenant)
<div class="tenant-row">
<div class="tenant-row__meta">
<p class="tenant-row__title">{{ $tenant['name'] }}</p>
<p class="tenant-row__copy">{{ $tenant['domain'] }}</p>
</div>
<span class="status">Auto-Weiterleitung</span>
</div>
@endforeach
</div>
</article>
<article class="card">
<p class="card__eyebrow">Preview: Mehrfachzuordnung</p>
<h3>{{ $preview['multiple']['email'] }}</h3>
<p class="muted">{{ $preview['multiple']['message'] }}</p>
<div class="tenant-grid" style="margin-top: 18px;">
@foreach ($preview['multiple']['matches'] as $tenant)
<div class="tenant-row">
<div class="tenant-row__meta">
<p class="tenant-row__title">{{ $tenant['name'] }}</p>
<p class="tenant-row__copy">{{ $tenant['domain'] }} - {{ $tenant['login_mode'] }}</p>
</div>
<span class="status status--warning">Auswahl</span>
</div>
@endforeach
</div>
</article>
<article class="card">
<p class="card__eyebrow">Preview: Unbekannte Mail</p>
<h3>{{ $preview['unknown']['email'] }}</h3>
<p class="muted">{{ $preview['unknown']['message'] }}</p>
<div class="callout" style="margin-top: 18px;">
<strong>Keine Sackgasse fuer Mitglieder</strong>
Die Anwendung kann stattdessen Tenant-Kontakt, Einladung oder den zentralen Supportpfad anzeigen.
</div>
</article>
</section>
+112 -7
View File
@@ -32,10 +32,15 @@
radial-gradient(circle at top left, rgba(180, 83, 9, 0.12), transparent 32%),
radial-gradient(circle at top right, rgba(15, 118, 110, 0.14), transparent 28%),
linear-gradient(180deg, #faf7f1 0%, var(--bg) 100%);
font-family: "Segoe UI", "Aptos", "Trebuchet MS", sans-serif;
font-family: "Aptos", "Segoe UI", "Trebuchet MS", sans-serif;
line-height: 1.55;
}
h1, h2, h3, h4, .hero__title, .brand__title {
font-family: "Palatino Linotype", "Book Antiqua", Georgia, serif;
letter-spacing: -0.02em;
}
a { color: var(--brand-strong); text-decoration: none; }
a:hover { text-decoration: underline; }
img { max-width: 100%; }
@@ -114,6 +119,10 @@
font-size: 0.92rem;
font-weight: 600;
}
.app-nav a.is-primary {
background: linear-gradient(135deg, var(--brand) 0%, #115e59 100%);
color: #fff;
}
.app-nav a:hover {
text-decoration: none;
border-color: rgba(15, 118, 110, 0.2);
@@ -133,6 +142,15 @@
radial-gradient(circle at bottom left, rgba(15, 118, 110, 0.16), transparent 26%);
box-shadow: var(--shadow);
}
.hero--split {
grid-template-columns: minmax(0, 1.25fr) minmax(300px, 0.75fr);
align-items: stretch;
}
.hero__content,
.hero__aside {
display: grid;
gap: 18px;
}
.hero__kicker {
margin: 0 0 12px;
text-transform: uppercase;
@@ -154,6 +172,11 @@
font-size: 1.02rem;
}
.hero__actions { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 4px; }
.hero__meta {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.button,
button,
input[type="submit"] {
@@ -208,11 +231,91 @@
.list-reset { margin: 0; padding: 0; list-style: none; }
.list-reset li + li { margin-top: 12px; }
.stack { display: grid; gap: 14px; }
.feature-list {
display: grid;
gap: 12px;
}
.feature-list__item {
display: flex;
gap: 12px;
align-items: flex-start;
padding: 14px 16px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.78);
border: 1px solid rgba(31, 41, 51, 0.08);
}
.feature-list__badge {
flex: 0 0 auto;
width: 34px;
height: 34px;
border-radius: 12px;
display: grid;
place-items: center;
background: rgba(15, 118, 110, 0.1);
color: var(--brand-strong);
font-weight: 800;
}
.feature-list__title {
margin: 0 0 4px;
font-weight: 700;
}
.feature-list__copy {
margin: 0;
color: var(--muted);
font-size: 0.94rem;
}
.split {
display: grid;
gap: 18px;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 0.9fr);
}
.auth-summary__card {
padding: 16px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.75);
border: 1px solid rgba(31, 41, 51, 0.08);
}
.tenant-grid {
display: grid;
gap: 12px;
}
.tenant-row {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
padding: 14px 16px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(31, 41, 51, 0.08);
}
.tenant-row__meta {
display: grid;
gap: 4px;
}
.tenant-row__title {
margin: 0;
font-weight: 700;
}
.tenant-row__copy {
margin: 0;
color: var(--muted);
font-size: 0.92rem;
}
.callout {
padding: 16px 18px;
border-radius: 18px;
background: rgba(15, 118, 110, 0.08);
border: 1px solid rgba(15, 118, 110, 0.12);
color: var(--brand-strong);
}
.callout strong {
display: block;
margin-bottom: 4px;
}
.metric--compact {
padding: 18px 20px;
}
.toolbar {
display: flex;
flex-wrap: wrap;
@@ -289,6 +392,7 @@
color: var(--brand-strong);
}
.timeline { display: grid; gap: 14px; }
.timeline--tight { gap: 10px; }
.timeline__item {
display: grid;
gap: 6px;
@@ -314,7 +418,8 @@
.grid--2,
.grid--3,
.grid--4,
.split { grid-template-columns: 1fr; }
.split,
.hero--split { grid-template-columns: 1fr; }
.app-header__inner { flex-direction: column; align-items: flex-start; }
.header-meta { justify-content: flex-start; }
.hero,
@@ -337,12 +442,14 @@
</div>
</div>
<div class="header-meta">
<span class="badge">Webspace-ready</span>
<span class="badge badge--solid">Tenant aware</span>
<span class="badge">Webspace-tauglich</span>
<span class="badge badge--solid">Mandantenfaehig</span>
</div>
</div>
<nav class="app-nav" aria-label="Hauptnavigation">
<a href="/">Landing</a>
<a class="is-primary" href="/">Start</a>
<a href="/login">Anmeldung</a>
<a href="/tenants">Mandanten-Admin</a>
<a href="/dashboard">Dashboard</a>
<a href="/members">Mitglieder</a>
<a href="/ledger">Ledger</a>
@@ -352,8 +459,6 @@
<a href="/exports">Exports</a>
<a href="/notifications">Notifications</a>
<a href="/surveys">Surveys</a>
<a href="/tenants">Tenants</a>
<a href="/login">Login</a>
</nav>
</header>
+123 -75
View File
@@ -1,93 +1,141 @@
@extends('layouts.app')
@section('page_title', 'Kaffeeliste SaaS - Tenants')
@section('page_title', 'Kaffeeliste SaaS - Tenant Console')
@php
$data = $overview ?? [
'metrics' => [],
'tenants' => [],
'shared_access' => [],
'operations' => [],
];
@endphp
@section('content')
<section class="hero">
<div>
<p class="hero__kicker">Central admin</p>
<h2 class="hero__title">Mandanten, Domains und SSO sauber zentral verwalten.</h2>
<p class="hero__lead">
Die SaaS-Version fuehrt eine echte Mandantenebene ein. So werden mehrere
Teams, Standorte oder Fachbereiche in derselben Plattform betrieben, aber
fachlich und organisatorisch getrennt.
</p>
</div>
<div class="toolbar">
<span class="badge">Subdomain routing</span>
<span class="badge">OIDC first</span>
<span class="badge badge--solid">Tenant aware</span>
<section class="hero hero--split">
<div class="hero__content">
<div>
<p class="hero__kicker">Central admin console</p>
<h2 class="hero__title">Alle Tenants, Mitgliedschaften und Login-Pfade in einer zentralen Admin-Uebersicht.</h2>
<p class="hero__lead">
Diese Konsole ist die zentrale Schaltstelle fuer Tenant-Rollout, Domains, zentrale Anmeldung und den Umgang
mit Mitarbeitenden, die in mehreren Tenants hinterlegt sind. Statt verteilter Einzelansichten entsteht eine
gemeinsame Portfolio-Sicht fuer Betrieb und Weiterentwicklung.
</p>
</div>
<div class="hero__actions">
<a class="button" href="/login">Mitglieder-Login testen</a>
<a class="button button--ghost" href="/">Landingpage ansehen</a>
</div>
<div class="hero__meta">
<span class="badge badge--solid">Tenant Portfolio</span>
<span class="badge">Identity & Routing</span>
<span class="badge">Mehrfachzuordnungen</span>
</div>
</div>
<aside class="hero__aside">
@foreach ($data['metrics'] as $metric)
<article class="card metric metric--compact">
<p class="metric__label">{{ $metric['label'] }}</p>
<div class="metric__value">{{ $metric['value'] }}</div>
<p class="muted">{{ $metric['detail'] }}</p>
</article>
@endforeach
</aside>
</section>
<section class="grid grid--3">
<article class="card metric">
<p class="metric__label">Mandanten</p>
<div class="metric__value">3</div>
<p class="muted">Aktive Bereiche auf gemeinsamer Plattform.</p>
</article>
<article class="card metric">
<p class="metric__label">Domains</p>
<div class="metric__value">5</div>
<p class="muted">Zentrale und tenantbezogene Hosts fuer Login und Betrieb.</p>
</article>
<article class="card metric">
<p class="metric__label">Provider</p>
<div class="metric__value">2</div>
<p class="muted">OIDC-Provider plus lokaler Fallback.</p>
</article>
<section class="table-card">
<div class="table-card__header">
<div>
<p class="card__eyebrow">Portfolio</p>
<h3>Tenant-Portfolio mit Betriebs- und Login-Sicht</h3>
</div>
<span class="pill">Zentrale Steuerung</span>
</div>
<div class="table-card__body">
<table>
<thead>
<tr>
<th>Mandant</th>
<th>Domain</th>
<th>Mitglieder</th>
<th>Login-Modell</th>
<th>SSO</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@foreach ($data['tenants'] as $tenant)
<tr>
<td>
<strong>{{ $tenant['name'] }}</strong><br>
<span class="muted">{{ $tenant['tenant_key'] }} - {{ $tenant['primary_contact'] }}</span>
</td>
<td>{{ $tenant['domain'] }}</td>
<td>{{ $tenant['member_count'] }} Mitglieder / {{ $tenant['admin_count'] }} Admins</td>
<td>{{ $tenant['login_mode'] }}</td>
<td>{{ $tenant['oidc_provider_count'] }} Provider</td>
<td>
@if ($tenant['status'] === 'active')
<span class="status">Aktiv</span>
@else
<span class="status status--warning">Sandbox</span>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</section>
<section class="split" style="margin-top: 18px;">
<article class="table-card">
<div class="table-card__header">
<div>
<p class="card__eyebrow">Mandantenlandschaft</p>
<h3>Aktive Tenants</h3>
</div>
<span class="pill">Central view</span>
</div>
<div class="table-card__body">
<table>
<thead>
<tr>
<th>Mandant</th>
<th>Tenant Key</th>
<th>Domain</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr>
<td>Office Berlin</td>
<td>berlin</td>
<td>berlin.kaffeeliste.app</td>
<td><span class="status">Aktiv</span></td>
</tr>
<tr>
<td>Office Koeln</td>
<td>koeln</td>
<td>koeln.kaffeeliste.app</td>
<td><span class="status">Aktiv</span></td>
</tr>
<tr>
<td>Shared Demo</td>
<td>demo</td>
<td>demo.kaffeeliste.app</td>
<td><span class="status status--warning">Sandbox</span></td>
</tr>
</tbody>
</table>
<article class="panel">
<p class="card__eyebrow">Mehrfachzuordnungen</p>
<h3>Wie die Plattform E-Mail-Adressen ueber mehrere Tenants hinweg behandelt.</h3>
<div class="timeline" style="margin-top: 18px;">
@foreach ($data['shared_access'] as $entry)
<div class="timeline__item">
<p class="timeline__title">{{ $entry['email'] }}</p>
<p class="timeline__meta">
@if ($entry['tenants'] !== [])
{{ implode(', ', $entry['tenants']) }}
@else
Keine Tenant-Zuordnung vorhanden
@endif
</p>
<div class="toolbar" style="margin-top: 2px;">
@if ($entry['status'] === 'einzeln')
<span class="status">Direkte Weiterleitung</span>
@elseif ($entry['status'] === 'mehrfach')
<span class="status status--warning">Tenant-Auswahl</span>
@else
<span class="status status--danger">Einladung noetig</span>
@endif
</div>
<p class="muted">{{ $entry['next_step'] }}</p>
</div>
@endforeach
</div>
</article>
<article class="panel">
<h3>Zentrale Aufgaben</h3>
<ul class="list-reset">
<li><span class="status">Domain Setup</span> Host und Tenant Key fuer saubere Aufloesung.</li>
<li><span class="status">Identity</span> OIDC-Provider pro Mandant hinterlegen.</li>
<li><span class="status">Feature Flags</span> PayPal, Surveys oder Self-Service-Striche je Tenant steuern.</li>
<p class="card__eyebrow">Zentrale Aufgaben</p>
<h3>Prioritaeten fuer den Plattformbetrieb.</h3>
<ul class="list-reset" style="margin-top: 18px;">
@foreach ($data['operations'] as $operation)
<li>
<span class="status">{{ $operation['state'] }}</span>
<strong style="display: block; margin-top: 8px;">{{ $operation['title'] }}</strong>
<span class="muted">{{ $operation['detail'] }}</span>
</li>
@endforeach
</ul>
<div class="note" style="margin-top: 18px;">
Die Tenant Console ist bewusst nicht nur eine Tabelle: Sie verbindet Rollout, Login-Logik, Mehrfachzuordnungen
und zentrale Operations-Signale zu einer konsistenten Admin-Oberflaeche.
</div>
</article>
</section>
@endsection
+150 -41
View File
@@ -1,65 +1,174 @@
@extends('layouts.app')
@section('page_title', 'Kaffeeliste SaaS - Landing')
@section('page_title', 'Kaffeeliste SaaS - Zentrale Plattform')
@php
$overview = $tenantOverview ?? [
'metrics' => [
['label' => 'Aktive Tenants', 'value' => '4', 'detail' => 'Mandanten ueber die Plattform verteilt.'],
['label' => 'Mitglieder gesamt', 'value' => '100', 'detail' => 'Aktive Nutzerkonten im Verbund.'],
['label' => 'SSO-Abdeckung', 'value' => '4', 'detail' => 'Tenant-Logins mit zentraler Identitaetsstrategie.'],
['label' => 'Betriebsstatus', 'value' => 'Stabil', 'detail' => 'Queues und Exporte fuer den Webspace-Betrieb vorbereitet.'],
],
'tenants' => [],
];
$preview = $centralLoginPreview ?? [
'single' => ['email' => 'mia@berlin.example', 'matches' => [['name' => 'Werk Berlin', 'domain' => 'berlin.kaffeeliste.de']]],
'multiple' => ['email' => 'leitung@kaffeeliste.example', 'matches' => [['name' => 'Werk Berlin', 'domain' => 'berlin.kaffeeliste.de'], ['name' => 'Werk Koeln', 'domain' => 'koeln.kaffeeliste.de']]],
'unknown' => ['email' => 'extern@example.org', 'matches' => []],
];
@endphp
@section('content')
<section class="hero">
<div>
<p class="hero__kicker">Coffee operations cloud</p>
<h2 class="hero__title">Die neue SaaS-Zentrale fuer Kaffeeliste, Mitglieder und Buchungen.</h2>
<p class="hero__lead">
Diese Version stellt den Produktkern sichtbar in den Vordergrund: Mandanten, Login, Kontostaende,
Striche, Einzahlungen, Hinweise und Auswertungen in einer klaren, webspace-tauglichen Oberflaeche.
</p>
<section class="hero hero--split">
<div class="hero__content">
<div>
<p class="hero__kicker">Zentrale Plattform fuer Mitglieder und Verantwortliche</p>
<h2 class="hero__title">Kaffeeliste verbindet zentrale Anmeldung, Tenant-Steuerung und den operativen Alltag in einer klaren SaaS-Oberflaeche.</h2>
<p class="hero__lead">
Endanwender finden ihren Kontostand, Striche, Einzahlungen und Hinweise ohne Tenant-Chaos. Gleichzeitig
behalten Verantwortliche alle Mandanten, Domains, SSO-Pfade und Mehrfachzuordnungen in einer gemeinsamen
Admin-Konsole im Blick.
</p>
</div>
<div class="hero__actions">
<a class="button" href="/dashboard">Zum Dashboard</a>
<a class="button button--ghost" href="/login">Login pruefen</a>
<a class="button" href="/login">Zentrale Anmeldung starten</a>
<a class="button button--ghost" href="/tenants">Tenant Console ansehen</a>
</div>
<div class="hero__meta">
<span class="badge badge--solid">Ein Login fuer alle Mitgliedschaften</span>
<span class="badge">Automatische Tenant-Weiterleitung</span>
<span class="badge">Mehrfachzuordnung mit Auswahlfluss</span>
</div>
</div>
<div class="grid grid--3">
<article class="card">
<p class="card__eyebrow">MVP</p>
<h3>Kernfunktionen bleiben erhalten</h3>
<p class="muted">Mitglieder, Ledger, Einzahlungen, Striche und Hinweise sind fachlich sichtbar modelliert.</p>
<aside class="hero__aside">
<article class="card metric metric--compact">
<p class="metric__label">{{ $overview['metrics'][0]['label'] }}</p>
<div class="metric__value">{{ $overview['metrics'][0]['value'] }}</div>
<p class="muted">{{ $overview['metrics'][0]['detail'] }}</p>
</article>
<article class="card">
<p class="card__eyebrow">SaaS</p>
<h3>Mandantenfaehig gedacht</h3>
<p class="muted">Jede Organisation bekommt ihren eigenen Bereich, eigene Rollen und eigene Inhalte.</p>
<article class="card metric metric--compact">
<p class="metric__label">{{ $overview['metrics'][1]['label'] }}</p>
<div class="metric__value">{{ $overview['metrics'][1]['value'] }}</div>
<p class="muted">{{ $overview['metrics'][1]['detail'] }}</p>
</article>
<article class="card">
<p class="card__eyebrow">Hosting</p>
<h3>Webspace und Cron zuerst</h3>
<p class="muted">Die Zielarchitektur bleibt leichtgewichtig und vermeidet dauerhaft laufende Worker.</p>
</article>
</div>
<div class="callout">
<strong>Zentraler Login statt Inseln</strong>
Mitglieder geben zuerst nur ihre E-Mail-Adresse an. Danach entscheidet die Plattform, ob direkt in einen Tenant
weitergeleitet wird oder ob zuerst eine Tenant-Auswahl erscheinen muss.
</div>
</aside>
</section>
<section class="grid grid--2">
<section class="grid grid--3">
<article class="card">
<p class="card__eyebrow">Fuer Mitglieder</p>
<h3>Schneller Einstieg ohne Tenant-Raten</h3>
<p class="muted">
Eine zentrale Anmeldung nimmt die Mail-Adresse entgegen und fuehrt danach automatisch in den passenden Bereich,
statt Nutzer auf Subdomains oder technische Tenant-Keys zu verweisen.
</p>
</article>
<article class="card">
<p class="card__eyebrow">Fuer Verantwortliche</p>
<h3>Alle Tenants in einer Admin-Console</h3>
<p class="muted">
Domains, SSO-Abdeckung, Rollout-Status und Mehrfachzuordnungen lassen sich tenantuebergreifend steuern und
priorisieren.
</p>
</article>
<article class="card">
<p class="card__eyebrow">Fuer den Betrieb</p>
<h3>Webspace-tauglich und tenantbewusst</h3>
<p class="muted">
Die Produktflaechen bleiben leichtgewichtig, dokumentiert und anschlussfaehig fuer Cron, Importe, Exporte und
spaetere Identity-Ausbaustufen.
</p>
</article>
</section>
<section class="split" style="margin-top: 18px;">
<article class="panel">
<h3>Was die Plattform abdeckt</h3>
<ul class="list-reset">
<li><span class="status">Dashboard</span> Kontostand, Monatswerte und letzte Aktionen.</li>
<li><span class="status">Ledger</span> Einzahlungen, Verbrauch und Korrekturen in einer Sicht.</li>
<li><span class="status">Members</span> Aktivitaet, Rollen und Mitgliedsstatus pro Mandant.</li>
<li><span class="status">Operations</span> Importe, Exporte, Notifications und Surveys als Zusatzmodule.</li>
<p class="card__eyebrow">Login-Fluss</p>
<h3>So funktioniert die zentrale Anmeldung fuer Mitglieder.</h3>
<div class="feature-list" style="margin-top: 18px;">
<div class="feature-list__item">
<div class="feature-list__badge">1</div>
<div>
<p class="feature-list__title">E-Mail zuerst</p>
<p class="feature-list__copy">Die Plattform prueft zentral, in welchen Tenants die Mail-Adresse hinterlegt ist.</p>
</div>
</div>
<div class="feature-list__item">
<div class="feature-list__badge">2</div>
<div>
<p class="feature-list__title">Automatische Entscheidung</p>
<p class="feature-list__copy">Bei genau einer Mitgliedschaft erfolgt die Weiterleitung direkt in den korrekten Tenant.</p>
</div>
</div>
<div class="feature-list__item">
<div class="feature-list__badge">3</div>
<div>
<p class="feature-list__title">Auswahl bei Mehrfachzuordnung</p>
<p class="feature-list__copy">Bei mehreren Tenants erscheint zuerst eine Auswahl, damit Mitglieder bewusst den richtigen Kontext waehlen.</p>
</div>
</div>
</div>
</article>
<article class="panel">
<p class="card__eyebrow">Mehrfachzuordnung</p>
<h3>Beispiel fuer eine zentrale Tenant-Auswahl.</h3>
<p class="muted">
Fuer <strong>{{ $preview['multiple']['email'] }}</strong> werden mehrere Mitgliedschaften erkannt. Statt Fehlleitungen
oder separater Logins zeigt die Plattform alle erreichbaren Tenants in einer klaren Auswahl.
</p>
<div class="tenant-grid" style="margin-top: 18px;">
@foreach ($preview['multiple']['matches'] as $tenant)
<div class="tenant-row">
<div class="tenant-row__meta">
<p class="tenant-row__title">{{ $tenant['name'] }}</p>
<p class="tenant-row__copy">{{ $tenant['domain'] }}</p>
</div>
<span class="status">Auswaehlbar</span>
</div>
@endforeach
</div>
<div class="note" style="margin-top: 18px;">
Genau diese Logik wird spaeter im produktiven Login verwendet: kein technischer Tenant-Schritt fuer Mitglieder,
aber trotzdem ein sauberer Kontext fuer jede Organisation.
</div>
</article>
</section>
<section class="grid grid--2" style="margin-top: 18px;">
<article class="panel">
<p class="card__eyebrow">Endanwender-Nutzen</p>
<h3>Was Mitglieder auf der Plattform erwarten duerfen.</h3>
<ul class="list-reset" style="margin-top: 18px;">
<li><span class="status">Kontostand</span> Ein klares Dashboard mit Verbrauch, Zahlungen und letzten Buchungen.</li>
<li><span class="status">Hinweise</span> Tenantbezogene Inhalte, FAQ und operative Informationen an einem Ort.</li>
<li><span class="status">Mitgliedschaften</span> Ein zentraler Einstieg auch fuer Personen mit mehreren Teams oder Standorten.</li>
<li><span class="status">Self-Service</span> Direkte Wege zu Ledger, Zahlungen und spaeterem Passwort-/SSO-Fallback.</li>
</ul>
</article>
<article class="panel">
<h3>Projektfokus</h3>
<div class="timeline">
<p class="card__eyebrow">Admin-Perspektive</p>
<h3>Was die zentrale Tenant Console sichtbar macht.</h3>
<div class="timeline timeline--tight" style="margin-top: 18px;">
<div class="timeline__item">
<p class="timeline__title">1. Root modernisieren</p>
<p class="timeline__meta">Die alte PHP-Oberflaeche wird fachlich in die SaaS-Struktur ueberfuehrt.</p>
<p class="timeline__title">Tenant-Portfolio</p>
<p class="timeline__meta">Mitgliederzahlen, Betriebsstatus und Login-Modell aller Mandanten in einer Sicht.</p>
</div>
<div class="timeline__item">
<p class="timeline__title">2. Nicht benoetigte Seiten entlasten</p>
<p class="timeline__meta">Sonderlogik wandert in optionale Module oder faellt bewusst weg.</p>
<p class="timeline__title">Identity-Steuerung</p>
<p class="timeline__meta">SSO-Abdeckung, Fallback-Logins und tenantbezogene Einstiegspfade konsistent halten.</p>
</div>
<div class="timeline__item">
<p class="timeline__title">3. Doku und Betrieb</p>
<p class="timeline__meta">Installationsanleitung, Hosting-Hinweise und Migrationspfad bleiben nachvollziehbar.</p>
<p class="timeline__title">Rollout & Betrieb</p>
<p class="timeline__meta">Onboarding, Migrationen und operative To-dos nicht pro Tenant verstreut, sondern zentral priorisiert.</p>
</div>
</div>
</article>
+7 -9
View File
@@ -1,55 +1,53 @@
<?php
use App\Http\Middleware\ResolveTenant;
return [
[
'method' => 'GET',
'uri' => '/login',
'action' => 'App\\Modules\\Identity\\Controllers\\LoginController@show',
'name' => 'login',
'middleware' => [ResolveTenant::class],
'middleware' => [],
],
[
'method' => 'POST',
'uri' => '/login',
'action' => 'App\\Modules\\Identity\\Controllers\\LoginController@authenticate',
'name' => 'login.attempt',
'middleware' => [ResolveTenant::class],
'middleware' => [],
],
[
'method' => 'GET',
'uri' => '/forgot-password',
'action' => 'App\\Modules\\Identity\\Controllers\\ForgotPasswordController@show',
'name' => 'password.request',
'middleware' => [ResolveTenant::class],
'middleware' => [],
],
[
'method' => 'POST',
'uri' => '/forgot-password',
'action' => 'App\\Modules\\Identity\\Controllers\\ForgotPasswordController@sendResetLink',
'name' => 'password.email',
'middleware' => [ResolveTenant::class],
'middleware' => [],
],
[
'method' => 'GET',
'uri' => '/auth/oidc/providers',
'action' => 'App\\Modules\\Identity\\Controllers\\OidcController@providers',
'name' => 'auth.oidc.providers',
'middleware' => [ResolveTenant::class],
'middleware' => [],
],
[
'method' => 'GET',
'uri' => '/auth/oidc/{provider}',
'action' => 'App\\Modules\\Identity\\Controllers\\OidcController@start',
'name' => 'auth.oidc.start',
'middleware' => [ResolveTenant::class],
'middleware' => [],
],
[
'method' => 'GET',
'uri' => '/auth/oidc/{provider}/callback',
'action' => 'App\\Modules\\Identity\\Controllers\\OidcController@callback',
'name' => 'auth.oidc.callback',
'middleware' => [ResolveTenant::class],
'middleware' => [],
],
];
+9 -4
View File
@@ -1,13 +1,18 @@
<?php
use App\Http\Middleware\ResolveTenant;
return [
[
'method' => 'GET',
'uri' => '/tenants',
'action' => 'App\\Modules\\Tenants\\Services\\TenantService@listTenants',
'action' => 'App\\Modules\\Tenants\\Controllers\\TenantConsoleController@index',
'name' => 'tenants.index',
'middleware' => [ResolveTenant::class],
'middleware' => [],
],
[
'method' => 'GET',
'uri' => '/admin/tenants',
'action' => 'App\\Modules\\Tenants\\Controllers\\TenantConsoleController@index',
'name' => 'admin.tenants.index',
'middleware' => [],
],
];
+1 -8
View File
@@ -6,17 +6,10 @@ return [
[
'method' => 'GET',
'uri' => '/',
'action' => 'LandingController@index',
'action' => 'App\\Modules\\Central\\Controllers\\LandingController@index',
'name' => 'landing',
'middleware' => [],
],
[
'method' => 'GET',
'uri' => '/login',
'action' => 'Auth\\LoginController@show',
'name' => 'login',
'middleware' => [ResolveTenant::class],
],
[
'method' => 'GET',
'uri' => '/dashboard',