Files
breadcrumb-the-shire/pages/auth/login(login).phtml
fs e775ed2800 refactor(login): Stripe-style split layout + multi-step polish
Reworks the auth flow from a single centered card into a two-pane
layout — form on the left, tenant brand on the right — and tightens the
multi-step login UX along the way. Major changes:

LAYOUT
- templates/login.phtml splits into a flex-column body so the footer
  spans both panes at the bottom instead of getting clipped under main.
- New .login-form-pane and .login-brand-pane on a 1fr/1fr grid above
  768px; mobile stacks brand on top as a slim band, form below.
- Brand pane carries a soft halo + dot grid + tinted base, all derived
  from --app-primary so it tracks the tenant accent automatically.
- Login card gets a Stripe-style hairline border + soft shadow, no
  Pico article > header sectioning band, h1 in semibold + tracking-tight.
- The "body > main" global padding is overridden for login so the brand
  panel reaches the very top + bottom edges of its column.

OS THEME FALLBACK
- New appExplicitTheme() returns the user/tenant theme or empty string,
  used by login.phtml + error.phtml to OMIT data-theme entirely when no
  preference exists. CSS prefers-color-scheme media query then drives
  the theme — DB stays the source of truth, no browser-side persistence.

MULTI-STEP UX
- Heading is stage-aware: "Login" / "Select tenant" / "Login to {tenant}".
  Drops the redundant "Login credentials" subtitle.
- Stage 3 gets an identity pill (icon + email + compact "use different
  email" button) replacing the old tenant-context block, so the user
  always sees which account they're signing in with regardless of
  multi-tenant status.
- Stage 2 tenant selection drops avatars + initials — just radio + name
  with text-overflow ellipsis for long tenant names.
- Tightens primary CTA: full-width on every stage incl. <p>-wrapped
  buttons. autofocus moves to the right input per stage (ldap_username
  / password).

NOTICE / HELP LINKS
- The placeholder "Problems logging in" link is gone (it used to point
  at the imprint route — misleading). show_support wired through 6
  auth pages and the partial removed; architecture tests adjusted.
- Help-links centered with bullet separators between items, hairline
  border-top so they read as secondary navigation under the main CTA.
- The "Encrypted / HTTPS/TLS 1.2+" trust badge at the card bottom is
  removed — modern users assume HTTPS, and the badge added noise.

DEAD CODE
- $authLogoHref, $selectedTenantAvatarUrl, $selectedTenantInitial,
  $hasSelectedTenantAvatar, $canSwitchTenant — unused after the
  identity-pill / brand-pane move, removed from all 6 auth pages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:16:26 +02:00

227 lines
11 KiB
PHTML

<?php
/**
* @var string $stage
* @var string $email
* @var string $tenantHint
* @var array $tenantCandidates
* @var int $selectedTenantId
* @var array|null $selectedTenant
* @var array $selectedTenantMethods
* @var array<int, string> $errors
*/
use MintyPHP\Buffer;
use MintyPHP\Session;
Buffer::set('title', t('Login'));
$stage = (string) ($stage ?? 'resolve_email');
$email = trim((string) ($email ?? ''));
$tenantHint = trim((string) ($tenantHint ?? ''));
$tenantCandidates = is_array($tenantCandidates ?? null) ? $tenantCandidates : [];
$selectedTenantId = (int) ($selectedTenantId ?? 0);
$selectedTenant = is_array($selectedTenant ?? null) ? $selectedTenant : null;
$selectedTenantMethods = is_array($selectedTenantMethods ?? null) ? $selectedTenantMethods : ['local' => false, 'microsoft' => false, 'ldap' => false];
$selectedTenantSlug = (string) ($selectedTenant['slug'] ?? '');
$selectedTenantLabel = trim((string) ($selectedTenant['description'] ?? ''));
if ($selectedTenantLabel === '') {
$selectedTenantLabel = t('Select tenant');
}
$errors = is_array($errors ?? null) ? $errors : [];
$errorNoticeId = $errors ? 'auth-login-error-notice' : '';
$authHelpContext = [];
if ($stage === 'resolve_email') {
$loginHeading = t('Login');
$authHelpContext = [
'show_register' => true,
];
} elseif ($stage === 'choose_tenant') {
$loginHeading = t('Select tenant');
} else {
$loginHeading = sprintf(t('Login to %s'), $selectedTenantLabel);
$authHelpContext = [
'show_forgot' => !empty($selectedTenantMethods['local']),
];
}
$returnToSanitized = (string) ($returnToSanitized ?? '');
?>
<article class="login-card">
<header>
<h1><?php e($loginHeading); ?></h1>
</header>
<?php if ($errors): ?>
<div id="<?php e($errorNoticeId); ?>" class="notice" data-variant="error" role="alert" aria-live="assertive" tabindex="-1">
<ul>
<?php foreach ($errors as $error): ?>
<li><?php e($error); ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<?php if ($stage === 'resolve_email'): ?>
<form method="post" data-login-submit-lock="1">
<input type="hidden" name="stage" value="resolve_email">
<input type="hidden" name="tenant_hint" value="<?php e($tenantHint); ?>">
<?php if ($returnToSanitized !== ''): ?><input type="hidden" name="return_to" value="<?php e($returnToSanitized); ?>"><?php endif; ?>
<label for="email">
<span><?php e(t('Email')); ?></span>
<input required type="email" name="email" id="email" value="<?php e($email); ?>" autocomplete="email" autofocus <?php if ($errors): ?>aria-invalid="true" aria-describedby="<?php e($errorNoticeId); ?>"<?php endif; ?> />
</label>
<p>
<button type="submit"><?php e(t('Continue')); ?></button>
</p>
<?php Session::getCsrfInput(); ?>
</form>
<?php elseif ($stage === 'choose_tenant'): ?>
<form method="post" class="login-tenant-select-form" data-login-submit-lock="1">
<input type="hidden" name="stage" value="choose_tenant">
<input type="hidden" name="email" value="<?php e($email); ?>">
<input type="hidden" name="tenant_hint" value="<?php e($tenantHint); ?>">
<?php if ($returnToSanitized !== ''): ?><input type="hidden" name="return_to" value="<?php e($returnToSanitized); ?>"><?php endif; ?>
<fieldset class="login-tenant-fieldset">
<legend class="login-tenant-select-label"><?php e(t('Select tenant')); ?></legend>
<div class="login-tenant-choice-grid">
<?php foreach ($tenantCandidates as $tenantCandidate): ?>
<?php
$tenantId = (int) ($tenantCandidate['id'] ?? 0);
if ($tenantId <= 0) {
continue;
}
$tenantLabel = trim((string) ($tenantCandidate['description'] ?? ''));
if ($tenantLabel === '') {
$tenantLabel = t('Select tenant');
}
?>
<label class="app-field login-tenant-choice">
<input type="radio" name="tenant_id" value="<?php e((string) $tenantId); ?>" <?php e($tenantId === $selectedTenantId ? 'checked autofocus' : ''); ?>>
<span class="login-tenant-choice-text"><?php e($tenantLabel); ?></span>
</label>
<?php endforeach; ?>
</div>
</fieldset>
<p>
<button type="submit"><?php e(t('Continue')); ?></button>
</p>
<?php Session::getCsrfInput(); ?>
</form>
<form method="post">
<input type="hidden" name="stage" value="reset">
<?php if ($returnToSanitized !== ''): ?><input type="hidden" name="return_to" value="<?php e($returnToSanitized); ?>"><?php endif; ?>
<p>
<button type="submit" class="secondary outline"><?php e(t('Use different email')); ?></button>
</p>
<?php Session::getCsrfInput(); ?>
</form>
<?php else: ?>
<aside class="login-identity-pill" role="status" aria-live="polite">
<span class="login-identity-pill-icon" aria-hidden="true">
<i class="bi bi-person-circle"></i>
</span>
<span class="login-identity-pill-email"><?php e($email); ?></span>
<form method="post" class="login-identity-pill-reset-form">
<input type="hidden" name="stage" value="reset">
<?php if ($returnToSanitized !== ''): ?><input type="hidden" name="return_to" value="<?php e($returnToSanitized); ?>"><?php endif; ?>
<button type="submit" class="secondary outline small"
aria-label="<?php e(t('Use different email')); ?>"
data-tooltip="<?php e(t('Use different email')); ?>"
data-tooltip-pos="top">
<i class="bi bi-arrow-left-right" aria-hidden="true"></i>
</button>
<?php Session::getCsrfInput(); ?>
</form>
</aside>
<div class="login-methods-stack">
<?php if (!empty($selectedTenantMethods['microsoft']) && $selectedTenantSlug !== ''): ?>
<a class="primary login-microsoft-link"
href="<?php e(lurl('auth/microsoft/start?tenant=' . rawurlencode($selectedTenantSlug))); ?>">
<i class="bi bi-microsoft" aria-hidden="true"></i> <?php e(t('Sign in with Microsoft')); ?>
</a>
<?php endif; ?>
<?php if (!empty($selectedTenantMethods['ldap'])): ?>
<?php
$hasMethodAbove = !empty($selectedTenantMethods['microsoft']);
?>
<?php if ($hasMethodAbove): ?>
<div class="login-alt-separator" aria-hidden="true">
<span><?php e(t('Or')); ?></span>
</div>
<?php endif; ?>
<form method="post" class="login-ldap-form" data-login-submit-lock="1">
<input type="hidden" name="stage" value="login_ldap">
<input type="hidden" name="email" value="<?php e($email); ?>">
<input type="hidden" name="tenant_hint" value="<?php e($tenantHint); ?>">
<input type="hidden" name="tenant_id" value="<?php e((string) $selectedTenantId); ?>">
<?php if ($returnToSanitized !== ''): ?><input type="hidden" name="return_to" value="<?php e($returnToSanitized); ?>"><?php endif; ?>
<label for="ldap_username">
<span><?php e(t('LDAP Username')); ?></span>
<input required type="text" name="ldap_username" id="ldap_username" autocomplete="username" autofocus />
</label>
<label for="ldap_password">
<span><?php e(t('Password')); ?></span>
<input required type="password" name="password" id="ldap_password" autocomplete="current-password" <?php if ($errors): ?>aria-invalid="true" aria-describedby="<?php e($errorNoticeId); ?>"<?php endif; ?> />
</label>
<p>
<label class="app-field inline">
<input type="checkbox" name="remember" value="1">
<span><?php e(t('Remember me')); ?></span>
</label>
</p>
<p>
<button type="submit"><i class="bi bi-shield-lock" aria-hidden="true"></i> <?php e(t('Sign in with LDAP')); ?></button>
</p>
<?php Session::getCsrfInput(); ?>
</form>
<?php endif; ?>
<?php
$hasMethodAboveLocal = !empty($selectedTenantMethods['microsoft']) || !empty($selectedTenantMethods['ldap']);
?>
<?php if (!empty($selectedTenantMethods['local']) && $hasMethodAboveLocal): ?>
<div class="login-alt-separator" aria-hidden="true">
<span><?php e(t('Or')); ?></span>
</div>
<?php endif; ?>
<?php if (!empty($selectedTenantMethods['local'])): ?>
<form method="post" class="login-password-form" data-login-submit-lock="1">
<input type="hidden" name="stage" value="login_password">
<input type="hidden" name="email" value="<?php e($email); ?>">
<input type="hidden" name="tenant_hint" value="<?php e($tenantHint); ?>">
<input type="hidden" name="tenant_id" value="<?php e((string) $selectedTenantId); ?>">
<?php if ($returnToSanitized !== ''): ?><input type="hidden" name="return_to" value="<?php e($returnToSanitized); ?>"><?php endif; ?>
<label for="password">
<span><?php e(t('Password')); ?></span>
<input required type="password" name="password" id="password" autocomplete="current-password" autofocus <?php if ($errors): ?>aria-invalid="true" aria-describedby="<?php e($errorNoticeId); ?>"<?php endif; ?> />
</label>
<p>
<label class="app-field inline">
<input type="checkbox" name="remember" value="1">
<span><?php e(t('Remember me')); ?></span>
</label>
</p>
<p>
<button type="submit"><?php e(t('Login')); ?></button>
</p>
<?php Session::getCsrfInput(); ?>
</form>
<?php endif; ?>
</div>
<?php if (empty($selectedTenantMethods['local']) && empty($selectedTenantMethods['microsoft']) && empty($selectedTenantMethods['ldap'])): ?>
<div class="notice" data-variant="warning" role="status" aria-live="polite">
<?php e(t('No login method is available for this tenant')); ?>
</div>
<?php endif; ?>
<?php endif; ?>
<?php require templatePath('partials/auth-help-links.phtml'); ?>
</article>