asdsad
All checks were successful
Deploy / deploy-staging (push) Successful in 52s
Deploy / deploy-production (push) Has been skipped

This commit is contained in:
2026-06-10 22:44:28 +02:00
parent a8f4a4b36a
commit cd5525ff2e
20 changed files with 1951 additions and 351 deletions

View File

@@ -26,27 +26,39 @@ Wenn du in Entwicklung arbeitest:
Wenn du in Produktion Theme-Caches aktiv hast: Wenn du in Produktion Theme-Caches aktiv hast:
1. Keycloak stoppen oder neu starten 1. Keycloak stoppen oder neu starten
2. falls noetig Theme-Cache loeschen: 2. falls nötig Theme-Cache löschen:
- `data/tmp/kc-gzip-cache` - `data/tmp/kc-gzip-cache`
## 3. Theme nur fuer den Desktop-Client setzen ## 3. Theme nur für den Desktop-Client setzen
In Keycloak: In Keycloak:
1. Realm `KuscheBerlin` oeffnen 1. Realm `KuscheBerlin` öffnen
2. Client `desktop-kusche-berlin` oeffnen 2. Client `desktop-kusche-berlin` öffnen
3. Die Login-Theme-Override fuer den Client auf `kusche-desktop` setzen 3. Die Login-Theme-Override für den Client auf `kusche-desktop` setzen
Wichtig: Wichtig:
- Das Theme soll **nicht** pauschal als Realm-Login-Theme fuer alle Clients gesetzt werden, wenn andere Clients das Standard-Keycloak-Login behalten sollen. - Das Theme soll **nicht** pauschal als Realm-Login-Theme für alle Clients gesetzt werden, wenn andere Clients das Standard-Keycloak-Login behalten sollen.
- Wenn die Texte automatisch nach Browser-Sprache wechseln sollen:
- im Realm unter `Realm settings` die Internationalisierung aktivieren
- `de` und `en` als erlaubte Sprachen hinterlegen
- optional `de` als Standard-Sprache setzen
- Wenn `Passwort vergessen?` sichtbar sein soll:
- im Realm unter `Login` die Option `Forgot password` aktivieren
- Wenn `Angemeldet bleiben` sichtbar sein soll:
- im Realm unter `Login` die Option `Remember Me` aktivieren
- Wenn ein Passwort-Reset auch wirklich ins angebundene LDAP zurückschreiben soll:
- in der LDAP-User-Federation muss Passwort-Änderung erlaubt sein
- das hängt von der Keycloak-LDAP-Konfiguration ab, nicht vom Theme selbst
- falls LDAP nur read-only angebunden ist, kann Keycloak zwar den Reset-Flow anzeigen, aber das Passwort nicht ins LDAP zurückschreiben
Falls deine Keycloak-Version im Client-UI keine direkte Login-Theme-Auswahl anzeigt: Falls deine Keycloak-Version im Client-UI keine direkte Login-Theme-Auswahl anzeigt:
- pruefen, ob die Client-spezifische Login-Theme-Option in den Client-Einstellungen / Advanced Settings vorhanden ist - prüfen, ob die Client-spezifische Login-Theme-Option in den Client-Einstellungen / Advanced Settings vorhanden ist
- falls nicht vorhanden, bleibt als saubere Alternative nur: - falls nicht vorhanden, bleibt als saubere Alternative nur:
- eigener Realm fuer Desktop - eigener Realm für Desktop
- oder spaeter ein Theme Selector SPI - oder später ein Theme Selector SPI
## 4. Testen ## 4. Testen
@@ -57,12 +69,12 @@ Danach testen:
Erwartung: Erwartung:
- fuer den Client `desktop-kusche-berlin` erscheint das neue Desktop-Login-Theme - für den Client `desktop-kusche-berlin` erscheint das neue Desktop-Login-Theme
- nach erfolgreichem Login geht es zur Desktop-Shell zurueck - nach erfolgreichem Login geht es zur Desktop-Shell zurück
## 5. Falls das Theme nicht erscheint ## 5. Falls das Theme nicht erscheint
Dann diese Punkte pruefen: Dann diese Punkte prüfen:
- Theme-Ordner liegt wirklich im Keycloak-`themes/`-Verzeichnis - Theme-Ordner liegt wirklich im Keycloak-`themes/`-Verzeichnis
- Verzeichnisname ist exakt `kusche-desktop` - Verzeichnisname ist exakt `kusche-desktop`
@@ -71,17 +83,17 @@ Dann diese Punkte pruefen:
- Cache wurde geleert - Cache wurde geleert
- wirklich der Client `desktop-kusche-berlin` wird verwendet - wirklich der Client `desktop-kusche-berlin` wird verwendet
## 6. Welche Datei du spaeter bei Aenderungen ersetzt ## 6. Welche Datei du später bei Änderungen ersetzt
Wenn du nur Styling aendern willst, reicht meist: Wenn du nur Styling ändern willst, reicht meist:
- `theme/kusche-desktop/login/resources/css/desktop-kusche.css` - `theme/kusche-desktop/login/resources/css/desktop-kusche.css`
Wenn du die Struktur aendern willst: Wenn du die Struktur ändern willst:
- `theme/kusche-desktop/login/login.ftl` - `theme/kusche-desktop/login/login.ftl`
## 7. Was ich bewusst nicht fuer dich serverseitig tue ## 7. Was ich bewusst nicht für dich serverseitig tue
Diese Schritte musst du selbst auf dem Keycloak-System machen: Diese Schritte musst du selbst auf dem Keycloak-System machen:

View File

@@ -1,26 +1,24 @@
<#import "template.ftl" as layout> <#import "template.ftl" as layout>
<#import "passkeys.ftl" as passkeys> <#import "passkeys.ftl" as passkeys>
<@layout.registrationLayout displayMessage=!messagesPerField.existsError('username','password') displayInfo=realm.password && realm.registrationAllowed && !registrationDisabled??; section> <@layout.registrationLayout displayMessage=!messagesPerField.existsError('username','password') displayInfo=false; section>
<#if section = "header"> <#if section = "header">
${msg("loginAccountTitle")} Desktop-Anmeldung
<#elseif section = "form"> <#elseif section = "form">
<section class="kb-login-shell"> <section class="kb-login-shell">
<div class="kb-login-curtains" aria-hidden="true"> <div class="kb-login-backdrop" aria-hidden="true"></div>
<span class="kb-login-curtain kb-login-curtain-top kb-login-curtain-top-a"></span>
<span class="kb-login-curtain kb-login-curtain-top kb-login-curtain-top-b"></span>
<span class="kb-login-curtain kb-login-curtain-bottom kb-login-curtain-bottom-a"></span>
<span class="kb-login-curtain kb-login-curtain-bottom kb-login-curtain-bottom-b"></span>
</div>
<div class="kb-login-ambient" aria-hidden="true"></div>
<div class="kb-login-panel"> <div class="kb-login-stage">
<div class="kb-login-copy"> <header class="kb-login-clock" aria-live="polite">
<p class="kb-login-date" id="kb-login-date">${msg("kbClockDateFallback", "Montag, 18. November")}</p>
<p class="kb-login-time" id="kb-login-time">${msg("kbClockTimeFallback", "4:20")}</p>
</header>
<div class="kb-login-card">
<div class="kb-login-brand">
<p class="kb-login-kicker">Kusche.Berlin</p> <p class="kb-login-kicker">Kusche.Berlin</p>
<h1 class="kb-login-title">${msg("kbDesktopLoginHeadline")}</h1>
<p class="kb-login-subtitle">${msg("kbDesktopLoginSubheadline")}</p>
</div> </div>
<div id="kc-form" class="kb-login-form-shell"> <div id="kc-form" class="kb-login-form-shell">
@@ -28,119 +26,96 @@
<#if realm.password> <#if realm.password>
<form id="kc-form-login" onsubmit="login.disabled = true; return true;" action="${url.loginAction}" method="post"> <form id="kc-form-login" onsubmit="login.disabled = true; return true;" action="${url.loginAction}" method="post">
<#if !usernameHidden??> <#if !usernameHidden??>
<div class="${properties.kcFormGroupClass!}"> <div class="kb-form-group">
<label for="username" class="${properties.kcLabelClass!}"> <input tabindex="2" id="username" class="kb-text-input" name="username" value="${(login.username!'')}" type="text"
<#if !realm.loginWithEmailAllowed>
${msg("username")}
<#elseif !realm.registrationEmailAsUsername>
${msg("usernameOrEmail")}
<#else>
${msg("email")}
</#if>
</label>
<input tabindex="2" id="username" class="${properties.kcInputClass!}" name="username" value="${(login.username!'')}" type="text"
autofocus autocomplete="${(enableWebAuthnConditionalUI?has_content)?then('username webauthn', 'username')}" autofocus autocomplete="${(enableWebAuthnConditionalUI?has_content)?then('username webauthn', 'username')}"
aria-invalid="<#if messagesPerField.existsError('username','password')>true</#if>" aria-invalid="<#if messagesPerField.existsError('username','password')>true</#if>"
dir="ltr" /> dir="ltr"
placeholder="Benutzername oder E-Mail eingeben" />
<#if messagesPerField.existsError('username','password')> <#if messagesPerField.existsError('username','password')>
<span id="input-error" class="${properties.kcInputErrorMessageClass!}" aria-live="polite"> <span id="input-error" class="kb-input-error" aria-live="polite">
${kcSanitize(messagesPerField.getFirstError('username','password'))?no_esc} ${kcSanitize(messagesPerField.getFirstError('username','password'))?no_esc}
</span> </span>
</#if> </#if>
</div> </div>
</#if> </#if>
<div class="${properties.kcFormGroupClass!}"> <div class="kb-form-group">
<label for="password" class="${properties.kcLabelClass!}">${msg("password")}</label> <div class="kb-password-row" dir="ltr">
<div class="${properties.kcInputGroup!}" dir="ltr"> <input tabindex="3" id="password" class="kb-text-input kb-text-input-password" name="password" type="password" autocomplete="current-password"
<input tabindex="3" id="password" class="${properties.kcInputClass!}" name="password" type="password" autocomplete="current-password" aria-invalid="<#if messagesPerField.existsError('username','password')>true</#if>"
aria-invalid="<#if messagesPerField.existsError('username','password')>true</#if>" /> placeholder="Passwort eingeben" />
<button class="${properties.kcFormPasswordVisibilityButtonClass!}" type="button" aria-label="${msg("showPassword")}" <button class="kb-password-toggle" type="button" aria-label="${msg("showPassword")}"
aria-controls="password" data-password-toggle tabindex="4" aria-controls="password" data-password-toggle tabindex="4"
data-icon-show="${properties.kcFormPasswordVisibilityIconShow!}" data-icon-hide="${properties.kcFormPasswordVisibilityIconHide!}" data-icon-show="${properties.kcFormPasswordVisibilityIconShow!}" data-icon-hide="${properties.kcFormPasswordVisibilityIconHide!}"
data-label-show="${msg('showPassword')}" data-label-hide="${msg('hidePassword')}"> data-label-show="${msg('showPassword')}" data-label-hide="${msg('hidePassword')}">
<i class="${properties.kcFormPasswordVisibilityIconShow!}" aria-hidden="true"></i> <span aria-hidden="true"></span>
</button> </button>
</div> </div>
<#if usernameHidden?? && messagesPerField.existsError('username','password')> <#if usernameHidden?? && messagesPerField.existsError('username','password')>
<span id="input-error" class="${properties.kcInputErrorMessageClass!}" aria-live="polite"> <span id="input-error" class="kb-input-error" aria-live="polite">
${kcSanitize(messagesPerField.getFirstError('username','password'))?no_esc} ${kcSanitize(messagesPerField.getFirstError('username','password'))?no_esc}
</span> </span>
</#if> </#if>
</div> </div>
<div class="${properties.kcFormGroupClass!} ${properties.kcFormSettingClass!}"> <div class="kb-form-meta">
<div id="kc-form-options"> <div class="kb-form-options" id="kc-form-options">
<#if realm.rememberMe && !usernameHidden??> <#if !usernameHidden??>
<div class="checkbox"> <label class="kb-remember">
<label>
<#if login.rememberMe??> <#if login.rememberMe??>
<input tabindex="5" id="rememberMe" name="rememberMe" type="checkbox" checked> ${msg("rememberMe")} <input tabindex="5" id="rememberMe" name="rememberMe" type="checkbox" checked>
<#else> <#else>
<input tabindex="5" id="rememberMe" name="rememberMe" type="checkbox"> ${msg("rememberMe")} <input tabindex="5" id="rememberMe" name="rememberMe" type="checkbox">
</#if> </#if>
<span>Angemeldet bleiben</span>
</label> </label>
</div>
</#if> </#if>
</div> </div>
<div class="${properties.kcFormOptionsWrapperClass!}"> <div class="kb-form-links">
<#if realm.resetPasswordAllowed> <#if (url.loginResetCredentialsUrl!'')?has_content>
<span><a tabindex="6" href="${url.loginResetCredentialsUrl}">${msg("doForgotPassword")}</a></span> <a tabindex="6" href="${url.loginResetCredentialsUrl}">Passwort vergessen?</a>
</#if> </#if>
</div> </div>
</div> </div>
<div id="kc-form-buttons" class="${properties.kcFormGroupClass!}"> <div id="kc-form-buttons" class="kb-form-submit">
<input type="hidden" id="id-hidden-input" name="credentialId" <#if auth.selectedCredential?has_content>value="${auth.selectedCredential}"</#if>/> <input type="hidden" id="id-hidden-input" name="credentialId" <#if auth.selectedCredential?has_content>value="${auth.selectedCredential}"</#if>/>
<input tabindex="7" class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}" name="login" id="kc-login" type="submit" value="${msg("doLogIn")}"/> <input tabindex="7" class="kb-submit-button" name="login" id="kc-login" type="submit" value="Anmelden"/>
</div>
<div id="kc-registration-container" class="kb-registration">
<div id="kc-registration">
<a class="kb-register-button" id="kb-register-button" tabindex="8" href="#" hidden>Registrieren</a>
</div>
</div> </div>
</form> </form>
</#if> </#if>
</div> </div>
</div> </div>
<aside class="kb-login-info">
<strong>Willkommen im Familienbereich</strong>
<p>Dieser geschützte Dashboardbereich ist für die Familie Kusche und enge Freunde gedacht. Hier entstehen persönliche Übersichten, gemeinsame Tools, interne Informationen und weitere digitale Bereiche für Alltag, Organisation und Zusammenarbeit.</p>
</aside>
</div>
</div> </div>
</section> </section>
<@passkeys.conditionalUIData /> <@passkeys.conditionalUIData />
<script type="module" src="${url.resourcesPath}/js/passwordVisibility.js"></script> <script type="module" src="${url.resourcesPath}/js/passwordVisibility.js"></script>
<script src="${url.resourcesPath}/js/login-clock.js"></script>
<script src="${url.resourcesPath}/js/desktop-register-link.js"></script>
<#elseif section = "info" > <#elseif section = "info" >
<#if realm.password && realm.registrationAllowed && !registrationDisabled??> <#-- Intentionally unused. Registration is rendered inside the form section. -->
<div id="kc-registration-container">
<div id="kc-registration">
<span>${msg("noAccount")} <a tabindex="8" href="${url.registrationUrl}">${msg("doRegister")}</a></span>
</div>
</div>
</#if>
<#elseif section = "socialProviders" > <#elseif section = "socialProviders" >
<#if realm.password && social?? && social.providers?has_content> <#-- Social logins are intentionally hidden for this theme. -->
<div id="kc-social-providers" class="${properties.kcFormSocialAccountSectionClass!}">
<hr/>
<h2>${msg("identity-provider-login-label")}</h2>
<ul class="${properties.kcFormSocialAccountListClass!} <#if social.providers?size gt 3>${properties.kcFormSocialAccountListGridClass!}</#if>">
<#list social.providers as p>
<li>
<a data-once-link data-disabled-class="${properties.kcFormSocialAccountListButtonDisabledClass!}" id="social-${p.alias}"
class="${properties.kcFormSocialAccountListButtonClass!} <#if social.providers?size gt 3>${properties.kcFormSocialAccountGridItem!}</#if>"
type="button" href="${p.loginUrl}">
<#if p.iconClasses?has_content>
<i class="${properties.kcCommonLogoIdP!} ${p.iconClasses!}" aria-hidden="true"></i>
<span class="${properties.kcFormSocialAccountNameClass!} kc-social-icon-text">${p.displayName!}</span>
<#else>
<span class="${properties.kcFormSocialAccountNameClass!}">${p.displayName!}</span>
</#if>
</a>
</li>
</#list>
</ul>
</div>
</#if>
</#if> </#if>
</@layout.registrationLayout> </@layout.registrationLayout>

View File

@@ -0,0 +1,16 @@
kbDesktopLoginHeadline=Desktop Login
kbDesktopLoginSubheadline=Melde dich mit deinem Kusche.Berlin Konto an, um auf Desktop, Apps und geschützte Inhalte zuzugreifen.
kbUsernameLabel=Benutzername
kbUsernameOrEmailLabel=Benutzername oder E-Mail
kbEmailLabel=E-Mail
kbPasswordLabel=Passwort
kbUsernamePlaceholder=Benutzername oder E-Mail eingeben
kbPasswordPlaceholder=Passwort eingeben
kbRememberMeLabel=Angemeldet bleiben
kbForgotPasswordLabel=Passwort vergessen?
kbLoginButtonLabel=Anmelden
kbRegisterButtonLabel=Registrieren
kbClockDateFallback=Montag, 18. November
kbClockTimeFallback=4:20
kbInfoTitle=Willkommen im Familienbereich
kbInfoBody=Dieser geschützte Dashboardbereich ist für die Familie Kusche und enge Freunde gedacht. Hier entstehen persönliche Übersichten, gemeinsame Tools, interne Informationen und künftig weitere digitale Bereiche für Alltag, Organisation und Zusammenarbeit.

View File

@@ -1,2 +1,16 @@
kbDesktopLoginHeadline=Desktop Login kbDesktopLoginHeadline=Desktop Login
kbDesktopLoginSubheadline=Geschuetzter Zugang zur Desktop-Shell und kuenftigen Modulen. kbDesktopLoginSubheadline=Melde dich mit deinem Kusche.Berlin Konto an, um auf Desktop, Apps und geschützte Inhalte zuzugreifen.
kbUsernameLabel=Benutzername
kbUsernameOrEmailLabel=Benutzername oder E-Mail
kbEmailLabel=E-Mail
kbPasswordLabel=Passwort
kbUsernamePlaceholder=Benutzername oder E-Mail eingeben
kbPasswordPlaceholder=Passwort eingeben
kbRememberMeLabel=Angemeldet bleiben
kbForgotPasswordLabel=Passwort vergessen?
kbLoginButtonLabel=Anmelden
kbRegisterButtonLabel=Registrieren
kbClockDateFallback=Montag, 18. November
kbClockTimeFallback=4:20
kbInfoTitle=Willkommen im Familienbereich
kbInfoBody=Dieser geschützte Dashboardbereich ist für die Familie Kusche und enge Freunde gedacht. Hier entstehen persönliche Übersichten, gemeinsame Tools, interne Informationen und künftig weitere digitale Bereiche für Alltag, Organisation und Zusammenarbeit.

View File

@@ -1,2 +1,16 @@
kbDesktopLoginHeadline=Desktop Login kbDesktopLoginHeadline=Desktop Login
kbDesktopLoginSubheadline=Protected access to the desktop shell and upcoming modules. kbDesktopLoginSubheadline=Sign in with your Kusche.Berlin account to access the desktop, apps and protected content.
kbUsernameLabel=Username
kbUsernameOrEmailLabel=Username or email
kbEmailLabel=Email
kbPasswordLabel=Password
kbUsernamePlaceholder=Enter your username or email
kbPasswordPlaceholder=Enter your password
kbRememberMeLabel=Keep me signed in
kbForgotPasswordLabel=Forgot password?
kbLoginButtonLabel=Sign in
kbRegisterButtonLabel=Register
kbClockDateFallback=Monday, November 18
kbClockTimeFallback=4:20
kbInfoTitle=Welcome to the family area
kbInfoBody=This protected dashboard area is intended for the Kusche family and close friends. It will host personal overviews, shared tools, internal information and additional digital spaces for daily life, organization and collaboration.

View File

@@ -1,307 +1,339 @@
html, html,
body { body {
min-height: 100%; min-height: 100%;
width: 100%;
}
html {
margin: 0;
padding: 0;
background: transparent !important;
} }
body { body {
margin: 0;
overflow: hidden; overflow: hidden;
background: background:
radial-gradient(circle at top, rgba(255, 221, 177, 0.30), transparent 26%), radial-gradient(circle at 18% 84%, rgba(142, 214, 119, 0.22), transparent 18%),
radial-gradient(circle at 80% 18%, rgba(132, 216, 166, 0.18), transparent 20%), linear-gradient(0deg, rgba(14, 27, 19, 0.28), rgba(14, 27, 19, 0.28)),
linear-gradient(145deg, #101827 0%, #18253d 52%, #0f172a 100%) !important; linear-gradient(135deg, #26442b 0%, #355939 26%, #5f4a2f 58%, #22351f 100%) !important;
color: #f8fafc; color: #f8fafc;
font-family: "Roboto", "Helvetica Neue", Arial, sans-serif;
}
body > div,
body > main {
margin: 0;
} }
#kc-header-wrapper, #kc-header-wrapper,
#kc-header, #kc-header,
.login-pf-header, .login-pf-header,
#kc-page-title { #kc-page-title,
#kc-locale,
#kc-social-providers {
display: none !important; display: none !important;
} }
#kc-content,
#kc-content-wrapper, #kc-content-wrapper,
#kc-locale { #kc-content,
#kc-form,
#kc-form-wrapper,
.login-pf-page,
.card-pf,
.pf-v5-c-login,
.pf-v5-c-login__container,
.pf-v5-c-login__main,
.pf-v5-c-login__main-body,
.pf-v5-c-login__main-header,
.pf-v5-c-login__main-footer {
margin: 0 !important;
padding: 0 !important;
max-width: none !important;
width: 100% !important;
border: 0 !important;
background: transparent !important;
box-shadow: none !important;
}
#kc-content-wrapper,
#kc-content,
.login-pf-page,
.pf-v5-c-login,
.pf-v5-c-login__container,
.pf-v5-c-login__main,
.pf-v5-c-login__main-body {
min-height: 100vh !important;
}
#kc-content,
#kc-content-wrapper {
position: relative; position: relative;
z-index: 2; z-index: 2;
} }
#kc-content-wrapper { .pf-v5-c-alert,
padding-top: 48px; .alert,
} #kc-info-message,
#kc-error-message {
#kc-form-login .pf-v5-c-form__label-text, margin: 0 0 14px !important;
#kc-form-login label, border: 0 !important;
#kc-form-options, border-radius: 12px !important;
#kc-form-options a, background: rgba(127, 29, 29, 0.24) !important;
#kc-registration, color: #fee2e2 !important;
#kc-registration a, box-shadow: none !important;
#kc-social-providers h2,
#kc-social-providers a {
color: #e2e8f0 !important;
} }
.kb-login-shell { .kb-login-shell {
position: relative; position: relative;
isolation: isolate; min-height: 100vh;
display: grid; padding: 24px 20px 30px;
place-items: center;
min-height: calc(100vh - 80px);
padding: 24px;
} }
.kb-login-curtains { .kb-login-backdrop {
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
}
.kb-login-curtain {
position: absolute;
left: 50%;
width: 180vmax;
height: 180vmax;
opacity: 0.72;
border-radius: 999px;
transform-origin: 0 50%;
animation-duration: 1.1s;
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
animation-fill-mode: both;
}
.kb-login-curtain-top,
.kb-login-curtain-bottom {
top: 50%;
margin-top: -90vmax;
}
.kb-login-curtain-top-a {
background: linear-gradient(135deg, rgba(236, 101, 105, 0.84), rgba(238, 175, 129, 0.76));
animation-name: kbLoginCurtainTopA;
}
.kb-login-curtain-top-b {
background: linear-gradient(135deg, rgba(255, 196, 104, 0.66), rgba(255, 138, 76, 0.6));
animation-name: kbLoginCurtainTopB;
animation-delay: 0.08s;
}
.kb-login-curtain-bottom-a {
background: linear-gradient(135deg, rgba(96, 184, 212, 0.7), rgba(55, 69, 181, 0.82));
animation-name: kbLoginCurtainBottomA;
animation-delay: 0.12s;
}
.kb-login-curtain-bottom-b {
background: linear-gradient(135deg, rgba(72, 125, 255, 0.5), rgba(36, 74, 179, 0.76));
animation-name: kbLoginCurtainBottomB;
animation-delay: 0.18s;
}
.kb-login-ambient {
position: absolute; position: absolute;
inset: 0; inset: 0;
background: background:
radial-gradient(circle at 50% 10%, rgba(255, 255, 255, 0.12), transparent 30%), linear-gradient(180deg, rgba(255, 255, 255, 0.10), transparent 16%),
url("../img/desktop-glow.svg") center/cover no-repeat; radial-gradient(circle at center, transparent 24%, rgba(7, 13, 8, 0.18) 100%);
opacity: 0.9;
pointer-events: none; pointer-events: none;
} }
.kb-login-panel { .kb-login-stage {
position: relative; position: relative;
z-index: 1; z-index: 1;
display: grid; display: grid;
gap: 22px; justify-items: center;
width: min(540px, 100%); align-content: start;
padding: 28px; min-height: 100vh;
border-radius: 28px;
background: rgba(15, 23, 42, 0.62);
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: 0 24px 80px rgba(2, 6, 23, 0.42);
backdrop-filter: blur(24px);
opacity: 0;
transform: translateY(28px) scale(0.98);
animation: kbLoginPanelReveal 0.72s cubic-bezier(0.22, 1, 0.36, 1) 0.35s forwards;
transition: transform 180ms ease, box-shadow 180ms ease, border-color 180ms ease;
} }
.kb-login-panel:hover, .kb-login-clock {
.kb-login-panel:focus-within { margin-top: 22px;
transform: translateY(-2px); text-align: center;
box-shadow: 0 30px 90px rgba(2, 6, 23, 0.5); color: #f8fafc;
border-color: rgba(255, 255, 255, 0.18); text-shadow: 0 10px 28px rgba(0, 0, 0, 0.22);
} }
.kb-login-copy { .kb-login-date {
margin: 0 0 6px;
font-size: 1.15rem;
font-weight: 400;
letter-spacing: 0.01em;
opacity: 0.9;
}
.kb-login-time {
margin: 0;
font-size: clamp(3.8rem, 8vw, 5.6rem);
font-weight: 300;
line-height: 1;
}
.kb-login-card {
width: min(420px, 100%);
margin-top: 148px;
display: grid; display: grid;
gap: 12px; gap: 14px;
}
.kb-login-brand {
text-align: center;
} }
.kb-login-kicker { .kb-login-kicker {
margin: 0; margin: 0;
font-size: 12px; font-size: 0.76rem;
letter-spacing: 0.18em; font-weight: 700;
letter-spacing: 0.22em;
text-transform: uppercase; text-transform: uppercase;
color: #cbd5e1; color: rgba(248, 250, 252, 0.84);
} }
.kb-login-title { .kb-login-form-shell,
#kc-form-wrapper,
#kc-form {
margin: 0; margin: 0;
font-size: clamp(30px, 4vw, 40px);
line-height: 1.04;
color: #f8fafc;
}
.kb-login-subtitle {
margin: 0;
color: #dbe5f1;
line-height: 1.55;
}
.kb-login-form-shell {
margin: 0;
}
#kc-form-wrapper {
padding: 0 !important; padding: 0 !important;
} }
#kc-form-login input[type="text"], #kc-form-login {
#kc-form-login input[type="password"] { display: grid;
gap: 12px;
}
.kb-form-group {
display: grid;
gap: 6px;
}
.kb-text-input {
width: 100%;
min-height: 48px; min-height: 48px;
border-radius: 14px !important; padding: 0 18px;
border: 1px solid rgba(255, 255, 255, 0.12) !important; border: 1px solid rgba(255, 255, 255, 0.10);
background: rgba(255, 255, 255, 0.08) !important; border-radius: 999px;
background: rgba(213, 223, 193, 0.54) !important;
color: #f8fafc !important; color: #f8fafc !important;
box-shadow: none !important; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08) !important;
outline: none;
font-size: 1rem;
backdrop-filter: blur(9px);
transition: background-color 160ms ease, border-color 160ms ease;
} }
#kc-form-login input::placeholder { .kb-text-input:focus {
color: rgba(226, 232, 240, 0.72); border-color: rgba(255, 255, 255, 0.26);
background: rgba(213, 223, 193, 0.62) !important;
} }
#kc-form-login .pf-v5-c-button, .kb-text-input::placeholder {
#kc-login { color: rgba(248, 250, 252, 0.82);
min-height: 50px; }
border: 0 !important;
border-radius: 14px !important; .kb-password-row {
background: linear-gradient(180deg, #a7f3d0, #6ee7b7) !important; position: relative;
color: #082032 !important; }
.kb-text-input-password {
padding-right: 56px;
}
.kb-password-toggle {
position: absolute;
top: 50%;
right: 10px;
transform: translateY(-50%);
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: 0;
border-radius: 999px;
background: transparent;
color: rgba(248, 250, 252, 0.88);
cursor: pointer;
}
.kb-input-error {
padding: 0 10px;
font-size: 0.82rem;
color: #fee2e2;
}
.kb-form-meta {
display: grid;
gap: 8px;
margin-top: 2px;
}
.kb-remember {
display: inline-flex;
align-items: center;
gap: 8px;
color: rgba(248, 250, 252, 0.94);
font-size: 0.88rem;
cursor: pointer;
}
.kb-remember input {
width: 16px;
height: 16px;
accent-color: #93c5fd;
}
.kb-form-links a {
color: rgba(248, 250, 252, 0.94) !important;
text-decoration: none;
font-size: 0.88rem;
}
.kb-form-links a:hover,
.kb-registration a:hover {
text-decoration: underline;
}
.kb-form-submit {
margin-top: 4px;
}
.kb-submit-button,
.kb-register-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 48px;
border: 0;
border-radius: 999px;
font-size: 0.98rem;
font-weight: 700; font-weight: 700;
box-shadow: none !important; letter-spacing: 0.01em;
transition: transform 160ms ease, filter 160ms ease, background-color 160ms ease, border-color 160ms ease; text-decoration: none;
cursor: pointer;
transition: transform 160ms ease, filter 160ms ease;
} }
#kc-form-login .pf-v5-c-button:hover, .kb-submit-button {
#kc-login:hover { background: linear-gradient(180deg, #7dd3fc, #0ea5e9) !important;
color: #082f49 !important;
box-shadow: 0 14px 30px rgba(14, 165, 233, 0.22);
}
.kb-register-button {
background: rgba(248, 250, 252, 0.18);
color: #f8fafc !important;
border: 1px solid rgba(248, 250, 252, 0.16);
backdrop-filter: blur(8px);
}
.kb-submit-button:hover,
.kb-register-button:hover {
transform: translateY(-1px); transform: translateY(-1px);
filter: brightness(1.03); filter: brightness(1.03);
} }
#kc-form-options { .kb-login-info {
padding-top: 4px; padding: 16px 18px;
border-radius: 18px;
background: rgba(9, 18, 12, 0.34);
border: 1px solid rgba(255, 255, 255, 0.08);
color: rgba(248, 250, 252, 0.94);
backdrop-filter: blur(10px);
} }
#kc-form-options .checkbox label { .kb-login-info strong {
display: inline-flex; display: block;
align-items: center; margin-bottom: 6px;
gap: 8px; font-size: 0.95rem;
} }
#kc-social-providers { .kb-login-info p {
margin-top: 10px; margin: 0;
font-size: 0.9rem;
line-height: 1.5;
} }
#kc-social-providers hr { .kb-registration {
border-color: rgba(255, 255, 255, 0.12); width: min(420px, 100%);
} margin: 14px auto 0;
#kc-registration {
margin-top: 18px;
}
.pf-v5-c-alert {
border-radius: 14px !important;
}
@keyframes kbLoginPanelReveal {
from {
opacity: 0;
transform: translateY(28px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes kbLoginCurtainTopA {
from {
transform: rotate(46deg) translateX(0);
}
to {
transform: rotate(46deg) translateX(22vmax);
}
}
@keyframes kbLoginCurtainTopB {
from {
transform: rotate(136deg) translateX(0);
}
to {
transform: rotate(136deg) translateX(18vmax);
}
}
@keyframes kbLoginCurtainBottomA {
from {
transform: rotate(-44deg) translateX(0);
}
to {
transform: rotate(-44deg) translateX(22vmax);
}
}
@keyframes kbLoginCurtainBottomB {
from {
transform: rotate(-134deg) translateX(0);
}
to {
transform: rotate(-134deg) translateX(18vmax);
}
}
@media (prefers-reduced-motion: reduce) {
.kb-login-curtain,
.kb-login-panel {
animation: none;
}
.kb-login-panel {
opacity: 1;
transform: none;
}
} }
@media (max-width: 640px) { @media (max-width: 640px) {
#kc-content-wrapper {
padding-top: 20px;
}
.kb-login-shell { .kb-login-shell {
min-height: auto; padding: 18px 14px 24px;
padding: 16px;
} }
.kb-login-panel { .kb-login-clock {
padding: 22px 18px; margin-top: 10px;
border-radius: 22px; }
.kb-login-time {
font-size: 3.7rem;
}
.kb-login-card {
margin-top: 88px;
} }
} }

View File

@@ -0,0 +1,22 @@
(function () {
const registerLink = document.getElementById('kb-register-button');
if (!registerLink) {
return;
}
const params = new URLSearchParams(window.location.search);
const redirectUri = params.get('redirect_uri');
if (!redirectUri) {
return;
}
try {
const target = new URL(redirectUri);
registerLink.href = target.origin + '/auth/register';
registerLink.hidden = false;
} catch (error) {
console.warn('Registrierungslink konnte nicht vorbereitet werden.', error);
}
}());

View File

@@ -0,0 +1,26 @@
(function () {
const dateNode = document.getElementById('kb-login-date');
const timeNode = document.getElementById('kb-login-time');
if (!dateNode || !timeNode) {
return;
}
const renderClock = () => {
const now = new Date();
dateNode.textContent = new Intl.DateTimeFormat(document.documentElement.lang || 'de', {
weekday: 'long',
month: 'long',
day: 'numeric',
}).format(now);
timeNode.textContent = new Intl.DateTimeFormat(document.documentElement.lang || 'de', {
hour: 'numeric',
minute: '2-digit',
}).format(now);
};
renderClock();
window.setInterval(renderClock, 1000);
}());

25
config/registration.php Normal file
View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
return [
'enabled' => true,
'storage_path' => 'data/registration-requests.json',
'ldif_export_dir' => 'data/registration-ldif',
'approver_groups' => ['administrators', 'appadmin'],
'approver_usernames' => [],
'ldap' => [
'users_dn' => 'cn=users,dc=nas,dc=kusche,dc=berlin',
'default_groups' => [
'cn=generaluser,cn=groups,dc=nas,dc=kusche,dc=berlin',
],
'default_login_shell' => '/bin/sh',
'home_directory_prefix' => '/home',
'shadow_expire' => 1,
'shadow_flag' => 0,
'shadow_inactive' => 0,
'shadow_max' => 99999,
'shadow_min' => 0,
'shadow_warning' => 7,
],
];

View File

@@ -6,6 +6,7 @@ $productName = (string) ($loginScreen['branding']['product_name'] ?? 'Kusche.Ber
$headline = (string) ($loginScreen['branding']['headline'] ?? 'Desktop Login'); $headline = (string) ($loginScreen['branding']['headline'] ?? 'Desktop Login');
$subheadline = (string) ($loginScreen['branding']['subheadline'] ?? ''); $subheadline = (string) ($loginScreen['branding']['subheadline'] ?? '');
$loginUrl = $loginScreen['login_url'] ?? null; $loginUrl = $loginScreen['login_url'] ?? null;
$registerUrl = $loginScreen['register_url'] ?? null;
$previewUrl = $loginScreen['preview_url'] ?? null; $previewUrl = $loginScreen['preview_url'] ?? null;
$keycloakReady = (bool) ($loginScreen['keycloak_ready'] ?? false); $keycloakReady = (bool) ($loginScreen['keycloak_ready'] ?? false);
$callbackUrl = (string) ($loginScreen['callback_url'] ?? ''); $callbackUrl = (string) ($loginScreen['callback_url'] ?? '');
@@ -36,6 +37,10 @@ $callbackUrl = (string) ($loginScreen['callback_url'] ?? '');
<?php if ($previewUrl !== null): ?> <?php if ($previewUrl !== null): ?>
<a class="login-button login-button-secondary" href="<?= htmlspecialchars($previewUrl, ENT_QUOTES) ?>">Desktop-Vorschau</a> <a class="login-button login-button-secondary" href="<?= htmlspecialchars($previewUrl, ENT_QUOTES) ?>">Desktop-Vorschau</a>
<?php endif; ?> <?php endif; ?>
<?php if ($registerUrl !== null): ?>
<a class="login-button login-button-secondary" href="<?= htmlspecialchars($registerUrl, ENT_QUOTES) ?>">Registrierung anfragen</a>
<?php endif; ?>
</div> </div>
<dl class="login-meta"> <dl class="login-meta">

View File

@@ -0,0 +1,222 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__, 3) . '/src/App/bootstrap.php';
use App\ConfigLoader;
use App\RegistrationAccess;
use App\RegistrationService;
session_start();
$projectRoot = dirname(__DIR__, 3);
$config = ConfigLoader::load($projectRoot, 'registration');
$registration = new RegistrationService($projectRoot, $config);
$access = new RegistrationAccess($config);
if (!$access->canManage()) {
http_response_code(403);
echo 'Kein Zugriff auf Registrierungsfreigaben.';
exit;
}
$csrfToken = (string) ($_SESSION['registration_admin_token'] ?? '');
if ($csrfToken === '') {
$csrfToken = bin2hex(random_bytes(16));
$_SESSION['registration_admin_token'] = $csrfToken;
}
$currentUser = is_array($_SESSION['desktop_auth']['user'] ?? null) ? $_SESSION['desktop_auth']['user'] : [];
$reviewer = (string) ($currentUser['username'] ?? 'unbekannt');
$selectedId = trim((string) ($_GET['id'] ?? $_POST['id'] ?? ''));
$errors = [];
$notice = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$postedToken = (string) ($_POST['csrf_token'] ?? '');
if ($postedToken === '' || !hash_equals($csrfToken, $postedToken)) {
$errors[] = 'Die Formularprüfung ist fehlgeschlagen.';
} else {
$action = (string) ($_POST['action'] ?? '');
if ($action === 'approve') {
$result = $registration->approve($selectedId, array_map('strval', $_POST), $reviewer);
if (($result['success'] ?? false) !== true) {
$errors = array_values(array_map('strval', (array) ($result['errors'] ?? [])));
} else {
$notice = 'Registrierung freigegeben. Ein LDIF-Entwurf für den inaktiven Benutzer wurde erzeugt.';
}
} elseif ($action === 'reject') {
$result = $registration->reject($selectedId, (string) ($_POST['reject_reason'] ?? ''), $reviewer);
if (($result['success'] ?? false) !== true) {
$errors = array_values(array_map('strval', (array) ($result['errors'] ?? [])));
} else {
$notice = 'Registrierung abgelehnt.';
}
}
}
}
$requests = $registration->all();
$selected = $selectedId !== '' ? $registration->find($selectedId) : ($requests[0] ?? null);
$provisioning = is_array($selected['provisioning'] ?? null) ? $selected['provisioning'] : [];
?><!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Registrierungsfreigaben</title>
<link rel="stylesheet" href="/assets/auth/login.css">
</head>
<body>
<main class="login-shell">
<section class="login-panel login-panel-admin">
<div class="login-panel-top">
<p class="login-kicker">Kusche.Berlin</p>
<h1>Registrierungsfreigaben</h1>
<p class="login-copy">Anfragen werden erst nach Prüfung für LDAP und NAS vorbereitet. Die Freigabe erzeugt aktuell einen LDIF-Entwurf für einen inaktiven Benutzer.</p>
</div>
<?php if ($notice !== null): ?>
<div class="login-notice login-notice-success">
<strong>Aktion abgeschlossen</strong>
<p><?= htmlspecialchars($notice, ENT_QUOTES) ?></p>
</div>
<?php endif; ?>
<?php if ($errors !== []): ?>
<div class="login-notice login-notice-error">
<strong>Bitte prüfen</strong>
<ul class="login-list">
<?php foreach ($errors as $error): ?>
<li><?= htmlspecialchars($error, ENT_QUOTES) ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<div class="admin-layout">
<aside class="admin-sidebar">
<h2>Offene Vorgänge</h2>
<div class="admin-request-list">
<?php foreach ($requests as $request): ?>
<a class="admin-request-card<?= (($selected['id'] ?? '') === ($request['id'] ?? '')) ? ' is-active' : '' ?>" href="/admin/registrations/?id=<?= urlencode((string) ($request['id'] ?? '')) ?>">
<strong><?= htmlspecialchars((string) ($request['username'] ?? ''), ENT_QUOTES) ?></strong>
<span><?= htmlspecialchars((string) ($request['email'] ?? ''), ENT_QUOTES) ?></span>
<span class="admin-status admin-status-<?= htmlspecialchars((string) ($request['status'] ?? 'pending'), ENT_QUOTES) ?>">
<?= htmlspecialchars((string) ($request['status'] ?? 'pending'), ENT_QUOTES) ?>
</span>
</a>
<?php endforeach; ?>
</div>
</aside>
<section class="admin-detail">
<?php if (!is_array($selected)): ?>
<div class="login-notice login-notice-info">
<strong>Keine Anfragen vorhanden</strong>
<p>Es liegt aktuell keine Registrierungsanfrage vor.</p>
</div>
<?php else: ?>
<div class="admin-summary">
<h2><?= htmlspecialchars((string) ($selected['display_name'] ?? $selected['username'] ?? ''), ENT_QUOTES) ?></h2>
<p>Status: <strong><?= htmlspecialchars((string) ($selected['status'] ?? 'pending'), ENT_QUOTES) ?></strong></p>
<p>Benutzername: <?= htmlspecialchars((string) ($selected['username'] ?? ''), ENT_QUOTES) ?></p>
<p>E-Mail: <?= htmlspecialchars((string) ($selected['email'] ?? ''), ENT_QUOTES) ?></p>
<p>Angelegt: <?= htmlspecialchars((string) ($selected['created_at'] ?? ''), ENT_QUOTES) ?></p>
<?php if (((string) ($selected['note'] ?? '')) !== ''): ?>
<p>Zusatzinfo: <?= nl2br(htmlspecialchars((string) ($selected['note'] ?? ''), ENT_QUOTES)) ?></p>
<?php endif; ?>
</div>
<form class="login-form" method="post" action="/admin/registrations">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken, ENT_QUOTES) ?>">
<input type="hidden" name="id" value="<?= htmlspecialchars((string) ($selected['id'] ?? ''), ENT_QUOTES) ?>">
<div class="login-form-grid">
<label class="login-field">
<span>uidNumber</span>
<input type="text" name="uid_number" value="<?= htmlspecialchars((string) ($provisioning['uid_number'] ?? ''), ENT_QUOTES) ?>" required>
</label>
<label class="login-field">
<span>gidNumber</span>
<input type="text" name="gid_number" value="<?= htmlspecialchars((string) ($provisioning['gid_number'] ?? ''), ENT_QUOTES) ?>" required>
</label>
<label class="login-field">
<span>homeDirectory</span>
<input type="text" name="home_directory" value="<?= htmlspecialchars((string) ($provisioning['home_directory'] ?? ('/home/' . ($selected['username'] ?? ''))), ENT_QUOTES) ?>" required>
</label>
<label class="login-field">
<span>loginShell</span>
<input type="text" name="login_shell" value="<?= htmlspecialchars((string) ($provisioning['login_shell'] ?? '/bin/sh'), ENT_QUOTES) ?>">
</label>
<label class="login-field">
<span>Display Name</span>
<input type="text" name="display_name" value="<?= htmlspecialchars((string) ($provisioning['display_name'] ?? $selected['display_name'] ?? ''), ENT_QUOTES) ?>">
</label>
<label class="login-field">
<span>CN</span>
<input type="text" name="cn" value="<?= htmlspecialchars((string) ($provisioning['cn'] ?? $selected['display_name'] ?? $selected['username'] ?? ''), ENT_QUOTES) ?>">
</label>
<label class="login-field">
<span>GECOS</span>
<input type="text" name="gecos" value="<?= htmlspecialchars((string) ($provisioning['gecos'] ?? $selected['display_name'] ?? ''), ENT_QUOTES) ?>">
</label>
<label class="login-field">
<span>apple-generateduid</span>
<input type="text" name="apple_generateduid" value="<?= htmlspecialchars((string) ($provisioning['apple_generateduid'] ?? ''), ENT_QUOTES) ?>">
</label>
<label class="login-field">
<span>sambaSID</span>
<input type="text" name="samba_sid" value="<?= htmlspecialchars((string) ($provisioning['samba_sid'] ?? ''), ENT_QUOTES) ?>">
</label>
<label class="login-field login-field-span-2">
<span>memberOf als CSV</span>
<input type="text" name="member_of_csv" value="<?= htmlspecialchars((string) ($provisioning['member_of_csv'] ?? 'cn=generaluser,cn=groups,dc=nas,dc=kusche,dc=berlin'), ENT_QUOTES) ?>">
</label>
</div>
<label class="login-field">
<span>Admin-Notiz</span>
<textarea name="admin_note" rows="4"><?= htmlspecialchars((string) ($provisioning['admin_note'] ?? ''), ENT_QUOTES) ?></textarea>
</label>
<div class="login-actions">
<button class="login-button login-button-primary" type="submit" name="action" value="approve">Freigeben und LDIF erzeugen</button>
<button class="login-button login-button-danger" type="submit" name="action" value="reject">Ablehnen</button>
</div>
<label class="login-field">
<span>Ablehnungsgrund</span>
<textarea name="reject_reason" rows="3" placeholder="Nur erforderlich, wenn du auf Ablehnen klickst."></textarea>
</label>
</form>
<?php if (((string) ($provisioning['ldif_path'] ?? '')) !== '' && is_file((string) $provisioning['ldif_path'])): ?>
<div class="login-notice login-notice-info">
<strong>LDIF-Entwurf</strong>
<p><?= htmlspecialchars((string) ($provisioning['ldif_path'] ?? ''), ENT_QUOTES) ?></p>
<pre class="login-pre"><?= htmlspecialchars((string) file_get_contents((string) $provisioning['ldif_path']), ENT_QUOTES) ?></pre>
</div>
<?php endif; ?>
<?php endif; ?>
</section>
</div>
</section>
</main>
</body>
</html>

View File

@@ -33,6 +33,14 @@ body {
backdrop-filter: blur(24px); backdrop-filter: blur(24px);
} }
.login-panel-wide {
width: min(760px, 100%);
}
.login-panel-admin {
width: min(1180px, 100%);
}
.login-panel-top { .login-panel-top {
margin-bottom: 24px; margin-bottom: 24px;
} }
@@ -72,6 +80,9 @@ body {
border-radius: 14px; border-radius: 14px;
text-decoration: none; text-decoration: none;
font-weight: 700; font-weight: 700;
border: 0;
cursor: pointer;
font: inherit;
} }
.login-button-primary { .login-button-primary {
@@ -91,12 +102,189 @@ body {
border: 1px dashed rgba(255, 255, 255, 0.18); border: 1px dashed rgba(255, 255, 255, 0.18);
} }
.login-button-danger {
color: #fff1f2;
background: linear-gradient(180deg, #fb7185, #e11d48);
}
.login-form {
display: grid;
gap: 18px;
}
.login-form-grid {
display: grid;
gap: 14px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.login-field {
display: grid;
gap: 8px;
}
.login-field span {
font-size: 13px;
color: #cbd5e1;
}
.login-field input,
.login-field textarea {
width: 100%;
min-height: 48px;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(15, 23, 42, 0.52);
color: #f8fafc;
font: inherit;
}
.login-field textarea {
min-height: 120px;
resize: vertical;
}
.login-field-span-2 {
grid-column: span 2;
}
.login-notice {
margin-bottom: 18px;
padding: 16px 18px;
border-radius: 18px;
border: 1px solid rgba(255, 255, 255, 0.12);
}
.login-notice strong {
display: block;
margin-bottom: 6px;
}
.login-notice p {
margin: 0;
line-height: 1.5;
}
.login-notice-success {
background: rgba(20, 83, 45, 0.45);
}
.login-notice-error {
background: rgba(127, 29, 29, 0.4);
}
.login-notice-info {
background: rgba(30, 41, 59, 0.5);
}
.login-list {
margin: 0;
padding-left: 18px;
}
.login-list li + li {
margin-top: 6px;
}
.login-meta-inline {
margin-top: 8px !important;
font-size: 0.92rem;
color: #cbd5e1;
}
.login-meta { .login-meta {
display: grid; display: grid;
gap: 14px; gap: 14px;
margin: 0; margin: 0;
} }
.admin-layout {
display: grid;
gap: 24px;
grid-template-columns: 320px minmax(0, 1fr);
}
.admin-sidebar h2,
.admin-summary h2 {
margin-top: 0;
}
.admin-request-list {
display: grid;
gap: 10px;
max-height: 70vh;
overflow: auto;
}
.admin-request-card {
display: grid;
gap: 6px;
padding: 14px;
border-radius: 16px;
text-decoration: none;
color: inherit;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.admin-request-card.is-active {
border-color: rgba(167, 243, 208, 0.56);
background: rgba(16, 185, 129, 0.12);
}
.admin-request-card span {
color: #cbd5e1;
font-size: 0.92rem;
}
.admin-status {
display: inline-flex;
width: fit-content;
padding: 4px 10px;
border-radius: 999px;
color: #e2e8f0 !important;
background: rgba(255, 255, 255, 0.12);
}
.admin-detail {
min-width: 0;
}
.admin-summary {
margin-bottom: 20px;
}
.admin-summary p {
margin: 0 0 10px;
line-height: 1.5;
}
.login-pre {
margin: 12px 0 0;
padding: 14px;
border-radius: 14px;
overflow: auto;
background: rgba(2, 6, 23, 0.65);
color: #dbeafe;
}
@media (max-width: 900px) {
.admin-layout {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.login-form-grid {
grid-template-columns: 1fr;
}
.login-field-span-2 {
grid-column: auto;
}
}
.login-meta div { .login-meta div {
display: grid; display: grid;
gap: 4px; gap: 4px;

View File

@@ -18,6 +18,7 @@ $previewUrl = $auth->allowDesktopPreview()
$loginScreen = [ $loginScreen = [
'branding' => $auth->branding(), 'branding' => $auth->branding(),
'login_url' => $auth->isConfigured() ? '/auth/keycloak' : null, 'login_url' => $auth->isConfigured() ? '/auth/keycloak' : null,
'register_url' => '/auth/register',
'preview_url' => $previewUrl, 'preview_url' => $previewUrl,
'keycloak_ready' => $auth->isConfigured(), 'keycloak_ready' => $auth->isConfigured(),
'callback_url' => $auth->redirectUri(), 'callback_url' => $auth->redirectUri(),

View File

@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__, 3) . '/src/App/bootstrap.php';
use App\ConfigLoader;
use App\RegistrationService;
session_start();
$projectRoot = dirname(__DIR__, 3);
$registration = new RegistrationService($projectRoot, ConfigLoader::load($projectRoot, 'registration'));
if (!$registration->isEnabled()) {
http_response_code(404);
echo 'Registrierung ist deaktiviert.';
exit;
}
$csrfToken = (string) ($_SESSION['registration_form_token'] ?? '');
if ($csrfToken === '') {
$csrfToken = bin2hex(random_bytes(16));
$_SESSION['registration_form_token'] = $csrfToken;
}
$errors = [];
$submitted = false;
$createdRequest = null;
$form = [
'username' => '',
'given_name' => '',
'family_name' => '',
'email' => '',
'note' => '',
];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$form = [
'username' => trim((string) ($_POST['username'] ?? '')),
'given_name' => trim((string) ($_POST['given_name'] ?? '')),
'family_name' => trim((string) ($_POST['family_name'] ?? '')),
'email' => trim((string) ($_POST['email'] ?? '')),
'note' => trim((string) ($_POST['note'] ?? '')),
];
$postedToken = (string) ($_POST['csrf_token'] ?? '');
if ($postedToken === '' || !hash_equals($csrfToken, $postedToken)) {
$errors[] = 'Die Formularprüfung ist fehlgeschlagen. Bitte versuche es erneut.';
} else {
$result = $registration->submit($form);
if (($result['success'] ?? false) !== true) {
$errors = array_values(array_map('strval', (array) ($result['errors'] ?? [])));
} else {
$submitted = true;
$createdRequest = $result['request'] ?? null;
$csrfToken = bin2hex(random_bytes(16));
$_SESSION['registration_form_token'] = $csrfToken;
$form = [
'username' => '',
'given_name' => '',
'family_name' => '',
'email' => '',
'note' => '',
];
}
}
}
?><!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Registrierung anfragen</title>
<link rel="stylesheet" href="/assets/auth/login.css">
</head>
<body>
<main class="login-shell">
<section class="login-panel login-panel-wide">
<div class="login-panel-top">
<p class="login-kicker">Kusche.Berlin</p>
<h1>Registrierung anfragen</h1>
<p class="login-copy">Neue Konten werden nicht sofort freigeschaltet. Deine Anfrage wird geprüft und erst danach für LDAP, NAS und Desktop vorbereitet.</p>
</div>
<?php if ($submitted): ?>
<div class="login-notice login-notice-success">
<strong>Anfrage gespeichert.</strong>
<p>Dein Zugang wurde noch nicht freigeschaltet. Eine berechtigte Person prüft nun die Registrierung.</p>
<?php if (is_array($createdRequest)): ?>
<p class="login-meta-inline">Vorgangs-ID: <?= htmlspecialchars((string) ($createdRequest['id'] ?? ''), ENT_QUOTES) ?></p>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if ($errors !== []): ?>
<div class="login-notice login-notice-error">
<strong>Bitte prüfen</strong>
<ul class="login-list">
<?php foreach ($errors as $error): ?>
<li><?= htmlspecialchars($error, ENT_QUOTES) ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<form class="login-form" method="post" action="/auth/register">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken, ENT_QUOTES) ?>">
<div class="login-form-grid">
<label class="login-field">
<span>Benutzername</span>
<input type="text" name="username" value="<?= htmlspecialchars($form['username'], ENT_QUOTES) ?>" required>
</label>
<label class="login-field">
<span>E-Mail</span>
<input type="email" name="email" value="<?= htmlspecialchars($form['email'], ENT_QUOTES) ?>" required>
</label>
<label class="login-field">
<span>Vorname</span>
<input type="text" name="given_name" value="<?= htmlspecialchars($form['given_name'], ENT_QUOTES) ?>" required>
</label>
<label class="login-field">
<span>Nachname</span>
<input type="text" name="family_name" value="<?= htmlspecialchars($form['family_name'], ENT_QUOTES) ?>" required>
</label>
</div>
<label class="login-field">
<span>Zusätzliche Info</span>
<textarea name="note" rows="5" placeholder="Optional: Bezug zur Familie, Einladung, gewünschte Nutzung, Rückfragekontakt"><?= htmlspecialchars($form['note'], ENT_QUOTES) ?></textarea>
</label>
<div class="login-notice login-notice-info">
<strong>Wichtig</strong>
<p>Das Passwort wird erst nach Freigabe gesetzt. Neue Registrierungen erhalten zunächst keinen aktiven Desktop-Zugang.</p>
</div>
<div class="login-actions">
<button class="login-button login-button-primary" type="submit">Registrierung absenden</button>
<a class="login-button login-button-secondary" href="/auth/login">Zurück zum Login</a>
</div>
</form>
</section>
</main>
</body>
</html>

View File

@@ -181,6 +181,7 @@ final class KeycloakAuth
'username' => (string) ($userInfo['preferred_username'] ?? ''), 'username' => (string) ($userInfo['preferred_username'] ?? ''),
'name' => (string) ($userInfo['name'] ?? ''), 'name' => (string) ($userInfo['name'] ?? ''),
'email' => (string) ($userInfo['email'] ?? ''), 'email' => (string) ($userInfo['email'] ?? ''),
'groups' => $this->extractGroups($tokenPayload, $userInfo),
], ],
]; ];
} }
@@ -312,6 +313,65 @@ final class KeycloakAuth
return strtolower($first); return strtolower($first);
} }
/**
* @param array<string, mixed> $tokenPayload
* @param array<string, mixed> $userInfo
* @return array<int, string>
*/
private function extractGroups(array $tokenPayload, array $userInfo): array
{
$groups = [];
foreach ([$userInfo, $this->decodeJwtClaims((string) ($tokenPayload['access_token'] ?? '')), $this->decodeJwtClaims((string) ($tokenPayload['id_token'] ?? ''))] as $source) {
$candidateGroups = $source['groups'] ?? [];
if (!is_array($candidateGroups)) {
continue;
}
foreach ($candidateGroups as $group) {
$group = trim((string) $group);
if ($group === '') {
continue;
}
$group = trim($group, '/');
$groups[$group] = $group;
}
}
return array_values($groups);
}
/**
* @return array<string, mixed>
*/
private function decodeJwtClaims(string $jwt): array
{
if ($jwt === '') {
return [];
}
$parts = explode('.', $jwt);
if (count($parts) < 2) {
return [];
}
$payload = $parts[1];
$payload .= str_repeat('=', (4 - strlen($payload) % 4) % 4);
$decoded = base64_decode(strtr($payload, '-_', '+/'), true);
if ($decoded === false) {
return [];
}
$claims = json_decode($decoded, true);
return is_array($claims) ? $claims : [];
}
/** /**
* @param array<string, string> $fields * @param array<string, string> $fields
* @return array{success: bool, data?: array<string, mixed>, error?: string} * @return array{success: bool, data?: array<string, mixed>, error?: string}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App;
final class RegistrationAccess
{
/**
* @param array<string, mixed> $config
*/
public function __construct(
private readonly array $config,
) {
}
public function canManage(): bool
{
$auth = $_SESSION['desktop_auth'] ?? null;
if (!is_array($auth)) {
return false;
}
$user = is_array($auth['user'] ?? null) ? $auth['user'] : [];
$username = strtolower((string) ($user['username'] ?? ''));
$groups = array_map('strtolower', array_values(array_map('strval', (array) ($user['groups'] ?? []))));
$approverGroups = array_map('strtolower', array_values(array_map('strval', (array) ($this->config['approver_groups'] ?? []))));
$approverUsernames = array_map('strtolower', array_values(array_map('strval', (array) ($this->config['approver_usernames'] ?? []))));
if ($username !== '' && in_array($username, $approverUsernames, true)) {
return true;
}
return array_intersect($groups, $approverGroups) !== [];
}
}

View File

@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace App;
final class RegistrationLdifBuilder
{
/**
* @param array<string, mixed> $config
*/
public function __construct(
private readonly array $config,
) {
}
/**
* @param array<string, mixed> $request
* @param array<string, mixed> $provisioning
*/
public function build(array $request, array $provisioning): string
{
$username = (string) ($request['username'] ?? '');
$usersDn = (string) ($this->config['ldap']['users_dn'] ?? '');
$uidNumber = trim((string) ($provisioning['uid_number'] ?? ''));
$gidNumber = trim((string) ($provisioning['gid_number'] ?? ''));
$homeDirectory = trim((string) ($provisioning['home_directory'] ?? ''));
$loginShell = trim((string) ($provisioning['login_shell'] ?? ($this->config['ldap']['default_login_shell'] ?? '/bin/sh')));
$memberOf = array_values(array_filter(array_map('trim', explode(',', (string) ($provisioning['member_of_csv'] ?? '')))));
$displayName = trim((string) ($provisioning['display_name'] ?? $request['display_name'] ?? ''));
$givenName = trim((string) ($request['given_name'] ?? ''));
$familyName = trim((string) ($request['family_name'] ?? ''));
$cn = trim((string) ($provisioning['cn'] ?? ($displayName !== '' ? $displayName : $username)));
$gecos = trim((string) ($provisioning['gecos'] ?? ($displayName !== '' ? $displayName : trim($givenName . ' ' . $familyName))));
$mail = trim((string) ($request['email'] ?? ''));
$appleGeneratedUid = trim((string) ($provisioning['apple_generateduid'] ?? $this->generateUuidV4()));
$sambaSid = trim((string) ($provisioning['samba_sid'] ?? ''));
$shadowExpire = (string) ($this->config['ldap']['shadow_expire'] ?? 1);
$shadowFlag = (string) ($this->config['ldap']['shadow_flag'] ?? 0);
$shadowInactive = (string) ($this->config['ldap']['shadow_inactive'] ?? 0);
$shadowMax = (string) ($this->config['ldap']['shadow_max'] ?? 99999);
$shadowMin = (string) ($this->config['ldap']['shadow_min'] ?? 0);
$shadowWarning = (string) ($this->config['ldap']['shadow_warning'] ?? 7);
$placeholderPassword = $this->generatePlaceholderPassword();
$passwordHash = $this->cryptSha512($placeholderPassword);
$warnings = [];
if ($uidNumber === '') {
$warnings[] = '# WARNUNG: uidNumber fehlt und muss vor dem LDAP-Import gesetzt werden.';
}
if ($gidNumber === '') {
$warnings[] = '# WARNUNG: gidNumber fehlt und muss vor dem LDAP-Import gesetzt werden.';
}
if ($sambaSid === '') {
$warnings[] = '# WARNUNG: sambaSID fehlt. Viele NAS-Setups erwarten diesen Wert.';
}
if ($memberOf === []) {
$memberOf = array_values(array_map('strval', (array) ($this->config['ldap']['default_groups'] ?? [])));
}
$lines = [
'version: 1',
'',
];
foreach ($warnings as $warning) {
$lines[] = $warning;
}
if ($warnings !== []) {
$lines[] = '# Das Passwort unten ist ein zufälliger Platzhalter für den inaktiven Zustand.';
$lines[] = '# Platzhalter-Passwort: ' . $placeholderPassword;
$lines[] = '';
}
$lines[] = 'dn: uid=' . $username . ',' . $usersDn;
$lines[] = 'objectClass: apple-user';
$lines[] = 'objectClass: extensibleObject';
$lines[] = 'objectClass: inetOrgPerson';
$lines[] = 'objectClass: organizationalPerson';
$lines[] = 'objectClass: person';
$lines[] = 'objectClass: posixAccount';
$lines[] = 'objectClass: sambaIdmapEntry';
$lines[] = 'objectClass: sambaSamAccount';
$lines[] = 'objectClass: shadowAccount';
$lines[] = 'objectClass: top';
$lines[] = 'cn: ' . $cn;
if ($gidNumber !== '') {
$lines[] = 'gidNumber: ' . $gidNumber;
}
$lines[] = 'homeDirectory: ' . $homeDirectory;
if ($sambaSid !== '') {
$lines[] = 'sambaSID: ' . $sambaSid;
}
$lines[] = 'sn: ' . ($familyName !== '' ? $familyName : $username);
$lines[] = 'uid: ' . $username;
if ($uidNumber !== '') {
$lines[] = 'uidNumber: ' . $uidNumber;
}
$lines[] = 'apple-generateduid: ' . strtoupper($appleGeneratedUid);
$lines[] = 'authAuthority: ;basic;';
$lines[] = 'displayName: ' . ($displayName !== '' ? $displayName : $username);
if ($gecos !== '') {
$lines[] = 'gecos: ' . $gecos;
}
if ($givenName !== '') {
$lines[] = 'givenName: ' . $givenName;
}
$lines[] = 'loginShell: ' . $loginShell;
$lines[] = 'mail: ' . $mail;
foreach ($memberOf as $groupDn) {
$lines[] = 'memberOf: ' . $groupDn;
}
$lines[] = 'shadowExpire: ' . $shadowExpire;
$lines[] = 'shadowFlag: ' . $shadowFlag;
$lines[] = 'shadowInactive: ' . $shadowInactive;
$lines[] = 'shadowLastChange: 1';
$lines[] = 'shadowMax: ' . $shadowMax;
$lines[] = 'shadowMin: ' . $shadowMin;
$lines[] = 'shadowWarning: ' . $shadowWarning;
$lines[] = 'userPassword: ' . $passwordHash;
return implode(PHP_EOL, $lines) . PHP_EOL;
}
private function generateUuidV4(): string
{
$bytes = random_bytes(16);
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4));
}
private function generatePlaceholderPassword(): string
{
return bin2hex(random_bytes(12));
}
private function cryptSha512(string $password): string
{
$salt = '$6$' . bin2hex(random_bytes(8)) . '$';
$hash = crypt($password, $salt);
return str_starts_with($hash, '$6$') ? '{CRYPT}' . $hash : $hash;
}
}

View File

@@ -0,0 +1,274 @@
<?php
declare(strict_types=1);
namespace App;
final class RegistrationService
{
private RegistrationStore $store;
private RegistrationLdifBuilder $ldifBuilder;
/**
* @param array<string, mixed> $config
*/
public function __construct(
private readonly string $projectRoot,
private readonly array $config,
) {
$storagePath = $this->resolveProjectPath((string) ($this->config['storage_path'] ?? 'data/registration-requests.json'));
$this->store = new RegistrationStore($storagePath);
$this->ldifBuilder = new RegistrationLdifBuilder($this->config);
}
public function isEnabled(): bool
{
return (bool) ($this->config['enabled'] ?? false);
}
/**
* @return array<int, array<string, mixed>>
*/
public function all(): array
{
return $this->store->all();
}
/**
* @return array<string, mixed>|null
*/
public function find(string $id): ?array
{
return $this->store->find($id);
}
/**
* @param array<string, string> $input
* @return array{success: bool, errors?: array<int, string>, request?: array<string, mixed>}
*/
public function submit(array $input): array
{
$username = strtolower(trim((string) ($input['username'] ?? '')));
$givenName = trim((string) ($input['given_name'] ?? ''));
$familyName = trim((string) ($input['family_name'] ?? ''));
$email = strtolower(trim((string) ($input['email'] ?? '')));
$note = trim((string) ($input['note'] ?? ''));
$displayName = trim($givenName . ' ' . $familyName);
$errors = [];
if (!preg_match('/^[a-z0-9._-]{3,32}$/', $username)) {
$errors[] = 'Der Benutzername muss 3 bis 32 Zeichen lang sein und darf nur Kleinbuchstaben, Zahlen, Punkt, Minus oder Unterstrich enthalten.';
}
if ($givenName === '') {
$errors[] = 'Der Vorname ist erforderlich.';
}
if ($familyName === '') {
$errors[] = 'Der Nachname ist erforderlich.';
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$errors[] = 'Bitte gib eine gültige E-Mail-Adresse an.';
}
if ($note !== '' && mb_strlen($note) > 2000) {
$errors[] = 'Die Zusatzinfo ist zu lang.';
}
$conflict = $this->store->findConflict($username, $email);
if ($conflict !== null) {
$errors[] = 'Für diesen Benutzernamen oder diese E-Mail existiert bereits eine offene oder freigegebene Registrierung.';
}
if ($errors !== []) {
return [
'success' => false,
'errors' => $errors,
];
}
$request = [
'id' => bin2hex(random_bytes(8)),
'status' => 'pending',
'created_at' => gmdate('c'),
'updated_at' => gmdate('c'),
'username' => $username,
'given_name' => $givenName,
'family_name' => $familyName,
'display_name' => $displayName,
'email' => $email,
'note' => $note,
'origin' => [
'ip' => (string) ($_SERVER['REMOTE_ADDR'] ?? ''),
'user_agent' => (string) ($_SERVER['HTTP_USER_AGENT'] ?? ''),
'host' => (string) ($_SERVER['HTTP_HOST'] ?? ''),
],
'review' => null,
'provisioning' => null,
];
return [
'success' => true,
'request' => $this->store->create($request),
];
}
/**
* @param array<string, string> $input
* @return array{success: bool, errors?: array<int, string>, request?: array<string, mixed>}
*/
public function approve(string $id, array $input, string $reviewer): array
{
$request = $this->store->find($id);
if ($request === null) {
return ['success' => false, 'errors' => ['Die Registrierung wurde nicht gefunden.']];
}
$uidNumber = trim((string) ($input['uid_number'] ?? ''));
$gidNumber = trim((string) ($input['gid_number'] ?? ''));
$homeDirectory = trim((string) ($input['home_directory'] ?? ''));
$sambaSid = trim((string) ($input['samba_sid'] ?? ''));
$memberOfCsv = trim((string) ($input['member_of_csv'] ?? ''));
$adminNote = trim((string) ($input['admin_note'] ?? ''));
$errors = [];
if ($uidNumber === '' || !ctype_digit($uidNumber)) {
$errors[] = 'uidNumber ist erforderlich und muss numerisch sein.';
}
if ($gidNumber === '' || !ctype_digit($gidNumber)) {
$errors[] = 'gidNumber ist erforderlich und muss numerisch sein.';
}
if ($homeDirectory === '') {
$errors[] = 'homeDirectory ist erforderlich.';
}
if ($errors !== []) {
return ['success' => false, 'errors' => $errors];
}
$provisioning = [
'uid_number' => $uidNumber,
'gid_number' => $gidNumber,
'home_directory' => $homeDirectory,
'login_shell' => trim((string) ($input['login_shell'] ?? ($this->config['ldap']['default_login_shell'] ?? '/bin/sh'))),
'member_of_csv' => $memberOfCsv,
'samba_sid' => $sambaSid,
'apple_generateduid' => trim((string) ($input['apple_generateduid'] ?? '')),
'display_name' => trim((string) ($input['display_name'] ?? (string) ($request['display_name'] ?? ''))),
'cn' => trim((string) ($input['cn'] ?? (string) ($request['display_name'] ?? $request['username'] ?? ''))),
'gecos' => trim((string) ($input['gecos'] ?? (string) ($request['display_name'] ?? ''))),
'admin_note' => $adminNote,
'ldif_generated_at' => gmdate('c'),
];
$request = $this->store->update(
$id,
static function (array $item) use ($provisioning, $reviewer, $adminNote): array {
$item['status'] = 'approved_pending_provisioning';
$item['updated_at'] = gmdate('c');
$item['review'] = [
'reviewed_at' => gmdate('c'),
'reviewed_by' => $reviewer,
'decision' => 'approved',
'note' => $adminNote,
];
$item['provisioning'] = $provisioning;
return $item;
}
);
if ($request === null) {
return ['success' => false, 'errors' => ['Die Registrierung konnte nicht aktualisiert werden.']];
}
$ldif = $this->ldifBuilder->build($request, $provisioning);
$ldifPath = $this->writeLdifDraft((string) ($request['id'] ?? ''), (string) ($request['username'] ?? ''), $ldif);
$request = $this->store->update(
$id,
static function (array $item) use ($ldifPath): array {
$item['updated_at'] = gmdate('c');
$item['provisioning']['ldif_path'] = $ldifPath;
$item['provisioning']['status'] = 'ldif_generated';
return $item;
}
);
return [
'success' => true,
'request' => $request,
];
}
/**
* @return array{success: bool, errors?: array<int, string>, request?: array<string, mixed>}
*/
public function reject(string $id, string $reason, string $reviewer): array
{
$reason = trim($reason);
if ($reason === '') {
return ['success' => false, 'errors' => ['Bitte gib einen Ablehnungsgrund an.']];
}
$request = $this->store->update(
$id,
static function (array $item) use ($reason, $reviewer): array {
$item['status'] = 'rejected';
$item['updated_at'] = gmdate('c');
$item['review'] = [
'reviewed_at' => gmdate('c'),
'reviewed_by' => $reviewer,
'decision' => 'rejected',
'note' => $reason,
];
return $item;
}
);
if ($request === null) {
return ['success' => false, 'errors' => ['Die Registrierung wurde nicht gefunden.']];
}
return [
'success' => true,
'request' => $request,
];
}
private function writeLdifDraft(string $id, string $username, string $content): string
{
$directory = $this->resolveProjectPath((string) ($this->config['ldif_export_dir'] ?? 'data/registration-ldif'));
if (!is_dir($directory)) {
mkdir($directory, 0775, true);
}
$filename = $id . '-' . preg_replace('/[^a-z0-9._-]+/i', '-', $username) . '.ldif';
$path = rtrim($directory, '/') . '/' . $filename;
file_put_contents($path, $content, LOCK_EX);
return $path;
}
private function resolveProjectPath(string $path): string
{
if ($path === '') {
return $this->projectRoot . '/data/registration-requests.json';
}
if (str_starts_with($path, '/')) {
return $path;
}
return $this->projectRoot . '/' . ltrim($path, '/');
}
}

View File

@@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
namespace App;
final class RegistrationStore
{
public function __construct(
private readonly string $storagePath,
) {
}
/**
* @return array<int, array<string, mixed>>
*/
public function all(): array
{
$items = $this->read();
usort(
$items,
static fn (array $left, array $right): int => strcmp(
(string) ($right['created_at'] ?? ''),
(string) ($left['created_at'] ?? '')
)
);
return $items;
}
/**
* @return array<string, mixed>|null
*/
public function find(string $id): ?array
{
foreach ($this->read() as $item) {
if ((string) ($item['id'] ?? '') === $id) {
return $item;
}
}
return null;
}
/**
* @return array<string, mixed>|null
*/
public function findConflict(string $username, string $email): ?array
{
$username = strtolower(trim($username));
$email = strtolower(trim($email));
foreach ($this->read() as $item) {
$status = (string) ($item['status'] ?? 'pending');
if ($status === 'rejected') {
continue;
}
if (strtolower((string) ($item['username'] ?? '')) === $username) {
return $item;
}
if (strtolower((string) ($item['email'] ?? '')) === $email) {
return $item;
}
}
return null;
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function create(array $payload): array
{
$items = $this->read();
$items[] = $payload;
$this->write($items);
return $payload;
}
/**
* @param callable(array<string, mixed>): array<string, mixed> $mutator
* @return array<string, mixed>|null
*/
public function update(string $id, callable $mutator): ?array
{
$items = $this->read();
$updated = null;
foreach ($items as $index => $item) {
if ((string) ($item['id'] ?? '') !== $id) {
continue;
}
$items[$index] = $mutator($item);
$updated = $items[$index];
break;
}
if ($updated === null) {
return null;
}
$this->write($items);
return $updated;
}
/**
* @return array<int, array<string, mixed>>
*/
private function read(): array
{
if (!is_file($this->storagePath)) {
return [];
}
$raw = file_get_contents($this->storagePath);
if ($raw === false || trim($raw) === '') {
return [];
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? array_values(array_filter($decoded, 'is_array')) : [];
}
/**
* @param array<int, array<string, mixed>> $items
*/
private function write(array $items): void
{
$directory = dirname($this->storagePath);
if (!is_dir($directory)) {
mkdir($directory, 0775, true);
}
file_put_contents(
$this->storagePath,
json_encode($items, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . PHP_EOL,
LOCK_EX
);
}
}

View File

@@ -0,0 +1,213 @@
# Moderierte Registrierung für desktop.kusche.berlin
## Ziel
Neue Nutzer sollen sich über die Login-Seite registrieren können, aber **nicht sofort** Zugriff auf den Desktop erhalten.
Stattdessen soll der Ablauf so sein:
1. Nutzer füllt Registrierungsformular aus.
2. Anfrage wird als `ausstehend` gespeichert.
3. Eine berechtigte Person prüft und genehmigt oder lehnt ab.
4. Erst bei Genehmigung wird der LDAP-/NAS-kompatible Benutzer vollständig angelegt.
5. Erst danach darf sich der Nutzer einloggen.
## Warum der normale Keycloak-Registrierungsflow hier nicht reicht
Der Standard-Registrierungsflow von Keycloak ist für euer Setup ungeeignet, weil:
- Keycloak aktuell nur einen sehr schlanken LDAP-Eintrag erzeugt
- die NAS aber deutlich mehr Attribute erwartet
- Benutzer bei Standard-Registrierung sofort im Keycloak-Kontext existieren
- eine nachträgliche manuelle Freigabe damit unnötig kompliziert wird
Für eure Umgebung ist es sauberer, die Registrierung **vor** Keycloak bzw. **neben** Keycloak zu behandeln.
## Empfohlene Architektur
### 1. Eigenes Registrierungsformular im Projekt
Nicht Keycloak `Registrieren` verwenden, sondern:
- eigene Seite, z. B. `/auth/register`
- eigenes Formular für:
- Benutzername
- Vorname
- Nachname
- E-Mail
- Passwort
- optional Begründung / Einladungscode / Referenz
Diese Daten landen **nicht direkt im LDAP**, sondern zuerst in einer Warteschlange.
### 2. Pending-Store für Registrierungsanfragen
Die Registrierung wird lokal gespeichert, z. B.:
- JSON-Datei in `data/registration-requests.json`
- oder besser SQLite / kleine Datenbank
Status je Eintrag:
- `pending`
- `approved`
- `rejected`
- `provisioned`
- `failed`
Zusätzlich speichern:
- Zeitstempel
- IP
- Browser
- gewünschter Benutzername
- Mail
- Vorname / Nachname
- gehashter Passwort-Entwurf oder temporärer verschlüsselter Wert
## Wichtig zum Passwort
Passwörter dürfen **nicht im Klartext** in so einer Warteschlange liegen.
Saubere Varianten:
1. Passwort erst nach Freigabe setzen lassen
2. oder verschlüsselt zwischenlagern
3. oder nach Freigabe Einladungslink / Set-Password-Link senden
Empfehlung:
- bei Registrierung noch **kein finales Passwort** speichern
- nach Freigabe bekommt der Nutzer einen Aktivierungslink
- Passwort wird erst dann final gesetzt
Das ist deutlich sauberer und sicherer.
### 3. Admin-/Freigabe-Tool
Es braucht ein internes Tool, z. B.:
- Desktop-App `Benutzerfreigaben`
- oder vorerst einfache Admin-Seite `/admin/registrations`
Funktionen:
- offene Registrierungen anzeigen
- Details prüfen
- genehmigen
- ablehnen
- Notiz hinterlegen
Nur bestimmte Gruppen dürfen das:
- `administrators`
- optional `appadmin`
### 4. Provisioning-Tool für LDAP/NAS
Bei Genehmigung startet **euer eigenes Provisioning-Tool**.
Dieses Tool erzeugt dann den Benutzer korrekt für die NAS, also mit allen nötigen Feldern:
- passende `objectClass`
- `uid`
- `cn`
- `sn`
- `mail`
- `uidNumber`
- `gidNumber`
- `homeDirectory`
- `loginShell`
- ggf. `displayName`
- ggf. `gecos`
- ggf. Apple-/Samba-/Shadow-Felder
- passende Gruppenmitgliedschaften
Genau dieser Teil sollte **nicht** Keycloak überlassen werden.
### 5. Aktivierung für Keycloak-Login
Nach erfolgreicher LDAP-Anlage:
- Nutzerstatus im Pending-Store auf `provisioned`
- Login ab dann erlaubt
Optional zusätzlich:
- Nutzer in bestimmte Startgruppen setzen
- Mail zur Freischaltung versenden
- Passwort-Setzen-Link generieren
## Technische Varianten
### Variante A: Registrierung komplett außerhalb von Keycloak
Flow:
1. Login-Seite hat Button `Registrieren`
2. Button führt auf eure eigene Registrierungsseite
3. Anfrage landet lokal im Pending-Store
4. Admin genehmigt
5. euer Tool legt LDAP-User korrekt an
6. danach Login über Keycloak gegen LDAP
Das ist die empfohlene Variante.
### Variante B: Keycloak-Registrierung bleibt aktiv
Flow:
1. Nutzer registriert sich in Keycloak
2. Benutzer wird dort oder im LDAP teilweise angelegt
3. zusätzliche Freigabelogik blockiert Login
Nachteile:
- unsauberer Zustand
- doppelte Logik
- schwerer kontrollierbar
- LDAP-/NAS-Kompatibilität bleibt problematisch
Diese Variante würde ich nicht empfehlen.
## Empfohlene Umsetzung für dieses Projekt
### Phase 1
- Keycloak-Registrierung im Login-Theme wieder entfernen oder deaktivieren
- eigene Route `/auth/register`
- einfache Ablage als JSON oder SQLite
- Admin-Ansicht für Freigaben
### Phase 2
- eigenes LDAP-Provisioning-Skript / Service
- Vergabe der fehlenden NAS-relevanten Attribute
- Gruppen-Standardzuweisung
### Phase 3
- Aktivierungs-Mail oder Passwort-Setz-Link
- Audit-Log
- Benachrichtigungen für Administratoren
## Konkrete Entscheidungsempfehlung
Ja, wir können dafür ein Tool bauen.
Die saubere Lösung ist:
- **nicht** die Keycloak-Registrierung zu verbiegen
- sondern eine **eigene moderierte Registrierung** im Projekt zu bauen
- und erst nach Freigabe den LDAP-/NAS-Benutzer korrekt zu provisionieren
## Nächster sinnvoller Schritt
Als nächstes sollten wir bauen:
1. `Registrieren` im Login nicht mehr auf Keycloak zeigen lassen
2. neue Projektseite `/auth/register`
3. lokaler `pending`-Store
4. interne Admin-Seite für Freigaben
Danach kommt das LDAP-Provisioning.