asdsad
This commit is contained in:
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -1,146 +1,121 @@
|
|||||||
<#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-kicker">Kusche.Berlin</p>
|
<p class="kb-login-date" id="kb-login-date">${msg("kbClockDateFallback", "Montag, 18. November")}</p>
|
||||||
<h1 class="kb-login-title">${msg("kbDesktopLoginHeadline")}</h1>
|
<p class="kb-login-time" id="kb-login-time">${msg("kbClockTimeFallback", "4:20")}</p>
|
||||||
<p class="kb-login-subtitle">${msg("kbDesktopLoginSubheadline")}</p>
|
</header>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="kc-form" class="kb-login-form-shell">
|
<div class="kb-login-card">
|
||||||
<div id="kc-form-wrapper">
|
<div class="kb-login-brand">
|
||||||
<#if realm.password>
|
<p class="kb-login-kicker">Kusche.Berlin</p>
|
||||||
<form id="kc-form-login" onsubmit="login.disabled = true; return true;" action="${url.loginAction}" method="post">
|
</div>
|
||||||
<#if !usernameHidden??>
|
|
||||||
<div class="${properties.kcFormGroupClass!}">
|
<div id="kc-form" class="kb-login-form-shell">
|
||||||
<label for="username" class="${properties.kcLabelClass!}">
|
<div id="kc-form-wrapper">
|
||||||
<#if !realm.loginWithEmailAllowed>
|
<#if realm.password>
|
||||||
${msg("username")}
|
<form id="kc-form-login" onsubmit="login.disabled = true; return true;" action="${url.loginAction}" method="post">
|
||||||
<#elseif !realm.registrationEmailAsUsername>
|
<#if !usernameHidden??>
|
||||||
${msg("usernameOrEmail")}
|
<div class="kb-form-group">
|
||||||
<#else>
|
<input tabindex="2" id="username" class="kb-text-input" name="username" value="${(login.username!'')}" type="text"
|
||||||
${msg("email")}
|
autofocus autocomplete="${(enableWebAuthnConditionalUI?has_content)?then('username webauthn', 'username')}"
|
||||||
|
aria-invalid="<#if messagesPerField.existsError('username','password')>true</#if>"
|
||||||
|
dir="ltr"
|
||||||
|
placeholder="Benutzername oder E-Mail eingeben" />
|
||||||
|
|
||||||
|
<#if messagesPerField.existsError('username','password')>
|
||||||
|
<span id="input-error" class="kb-input-error" aria-live="polite">
|
||||||
|
${kcSanitize(messagesPerField.getFirstError('username','password'))?no_esc}
|
||||||
|
</span>
|
||||||
</#if>
|
</#if>
|
||||||
</label>
|
</div>
|
||||||
|
</#if>
|
||||||
|
|
||||||
<input tabindex="2" id="username" class="${properties.kcInputClass!}" name="username" value="${(login.username!'')}" type="text"
|
<div class="kb-form-group">
|
||||||
autofocus autocomplete="${(enableWebAuthnConditionalUI?has_content)?then('username webauthn', 'username')}"
|
<div class="kb-password-row" dir="ltr">
|
||||||
aria-invalid="<#if messagesPerField.existsError('username','password')>true</#if>"
|
<input tabindex="3" id="password" class="kb-text-input kb-text-input-password" name="password" type="password" autocomplete="current-password"
|
||||||
dir="ltr" />
|
aria-invalid="<#if messagesPerField.existsError('username','password')>true</#if>"
|
||||||
|
placeholder="Passwort eingeben" />
|
||||||
|
|
||||||
<#if messagesPerField.existsError('username','password')>
|
<button class="kb-password-toggle" type="button" aria-label="${msg("showPassword")}"
|
||||||
<span id="input-error" class="${properties.kcInputErrorMessageClass!}" aria-live="polite">
|
aria-controls="password" data-password-toggle tabindex="4"
|
||||||
|
data-icon-show="${properties.kcFormPasswordVisibilityIconShow!}" data-icon-hide="${properties.kcFormPasswordVisibilityIconHide!}"
|
||||||
|
data-label-show="${msg('showPassword')}" data-label-hide="${msg('hidePassword')}">
|
||||||
|
<span aria-hidden="true">◉</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<#if usernameHidden?? && messagesPerField.existsError('username','password')>
|
||||||
|
<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>
|
|
||||||
|
|
||||||
<div class="${properties.kcFormGroupClass!}">
|
<div class="kb-form-meta">
|
||||||
<label for="password" class="${properties.kcLabelClass!}">${msg("password")}</label>
|
<div class="kb-form-options" id="kc-form-options">
|
||||||
<div class="${properties.kcInputGroup!}" dir="ltr">
|
<#if !usernameHidden??>
|
||||||
<input tabindex="3" id="password" class="${properties.kcInputClass!}" name="password" type="password" autocomplete="current-password"
|
<label class="kb-remember">
|
||||||
aria-invalid="<#if messagesPerField.existsError('username','password')>true</#if>" />
|
|
||||||
|
|
||||||
<button class="${properties.kcFormPasswordVisibilityButtonClass!}" type="button" aria-label="${msg("showPassword")}"
|
|
||||||
aria-controls="password" data-password-toggle tabindex="4"
|
|
||||||
data-icon-show="${properties.kcFormPasswordVisibilityIconShow!}" data-icon-hide="${properties.kcFormPasswordVisibilityIconHide!}"
|
|
||||||
data-label-show="${msg('showPassword')}" data-label-hide="${msg('hidePassword')}">
|
|
||||||
<i class="${properties.kcFormPasswordVisibilityIconShow!}" aria-hidden="true"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<#if usernameHidden?? && messagesPerField.existsError('username','password')>
|
|
||||||
<span id="input-error" class="${properties.kcInputErrorMessageClass!}" aria-live="polite">
|
|
||||||
${kcSanitize(messagesPerField.getFirstError('username','password'))?no_esc}
|
|
||||||
</span>
|
|
||||||
</#if>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="${properties.kcFormGroupClass!} ${properties.kcFormSettingClass!}">
|
|
||||||
<div id="kc-form-options">
|
|
||||||
<#if realm.rememberMe && !usernameHidden??>
|
|
||||||
<div class="checkbox">
|
|
||||||
<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 class="kb-form-links">
|
||||||
|
<#if (url.loginResetCredentialsUrl!'')?has_content>
|
||||||
|
<a tabindex="6" href="${url.loginResetCredentialsUrl}">Passwort vergessen?</a>
|
||||||
|
</#if>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="${properties.kcFormOptionsWrapperClass!}">
|
<div id="kc-form-buttons" class="kb-form-submit">
|
||||||
<#if realm.resetPasswordAllowed>
|
<input type="hidden" id="id-hidden-input" name="credentialId" <#if auth.selectedCredential?has_content>value="${auth.selectedCredential}"</#if>/>
|
||||||
<span><a tabindex="6" href="${url.loginResetCredentialsUrl}">${msg("doForgotPassword")}</a></span>
|
<input tabindex="7" class="kb-submit-button" name="login" id="kc-login" type="submit" value="Anmelden"/>
|
||||||
</#if>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="kc-form-buttons" class="${properties.kcFormGroupClass!}">
|
<div id="kc-registration-container" class="kb-registration">
|
||||||
<input type="hidden" id="id-hidden-input" name="credentialId" <#if auth.selectedCredential?has_content>value="${auth.selectedCredential}"</#if>/>
|
<div id="kc-registration">
|
||||||
<input tabindex="7" class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}" name="login" id="kc-login" type="submit" value="${msg("doLogIn")}"/>
|
<a class="kb-register-button" id="kb-register-button" tabindex="8" href="#" hidden>Registrieren</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
</#if>
|
</form>
|
||||||
|
</#if>
|
||||||
|
</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>
|
</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>
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}());
|
||||||
@@ -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
25
config/registration.php
Normal 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,
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -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">
|
||||||
|
|||||||
222
public/admin/registrations/index.php
Normal file
222
public/admin/registrations/index.php
Normal 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>
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
153
public/auth/register/index.php
Normal file
153
public/auth/register/index.php
Normal 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>
|
||||||
@@ -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}
|
||||||
|
|||||||
37
src/App/RegistrationAccess.php
Normal file
37
src/App/RegistrationAccess.php
Normal 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) !== [];
|
||||||
|
}
|
||||||
|
}
|
||||||
161
src/App/RegistrationLdifBuilder.php
Normal file
161
src/App/RegistrationLdifBuilder.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
274
src/App/RegistrationService.php
Normal file
274
src/App/RegistrationService.php
Normal 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, '/');
|
||||||
|
}
|
||||||
|
}
|
||||||
150
src/App/RegistrationStore.php
Normal file
150
src/App/RegistrationStore.php
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
213
temp/MODERIERTE_REGISTRIERUNG_KONZEPT.md
Normal file
213
temp/MODERIERTE_REGISTRIERUNG_KONZEPT.md
Normal 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.
|
||||||
Reference in New Issue
Block a user