Improve keycloak theming

feat/theme-v2
Benjamin Bädorf 2022-11-27 21:38:41 +01:00
parent 3c8ef7c3d2
commit cac941a300
No known key found for this signature in database
GPG Key ID: 4406E80E13CD656C
14 changed files with 324 additions and 178 deletions

0
.dev-import/.gitkeep Normal file
View File

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
tags
.direnv
common/resources/css/*.css
.dev-import/*.json

View File

@ -1,14 +1,25 @@
# pub.solar Keycloak theme
To start a dev keycloak instance that can show the theme.
```
docker run \
--name keycloak-theme-dev \
-p 8080:8080 \
-e KEYCLOAK_ADMIN=admin \
-e KEYCLOAK_ADMIN_PASSWORD=admin \
-v $(pwd):/opt/keycloak/themes/pub.solar \
quay.io/keycloak/keycloak:20.0.1 \
start-dev
```
## Development setup
To start a dev keycloak instance that can show the theme, you need to do the following:
1. Go into Keycloak, and export the `pub.solar` realm config. In Keycloak, open the pub.solar realm, click on the menu item "Realm settings", open the dropdown "Action", and click "Partial export". Move the generated JSON file into `./.dev-imports` in this repo.
2. Run the following command:
```
docker run \
--name keycloak-theme-dev \
-p 8080:8080 \
-e KEYCLOAK_ADMIN=admin \
-e KEYCLOAK_ADMIN_PASSWORD=admin \
-v $(pwd):/opt/keycloak/themes/pub.solar \
-v $(pwd)/.dev-import:/opt/keycloak/data/import \
quay.io/keycloak/keycloak:20.0.1 \
start-dev --import-realm
```
3. After this, you can start and stop the container using `docker start keycloak-theme-dev` and `docker-stop keycloak-theme-dev`.
4. Connect to the local keycloak instance at `http://localhost:8080` and open the administration console. In the `pub.solar` realm, click on the menu item "Clients", then the client "account", "Advanced" tab, "Authentication flow overrides" section, select "Webauthn Browser" for the "Browser Flow". Press save.

View File

@ -57,22 +57,22 @@
<main class="ps-main--page ps-page">
<header class="ps-page--header ps-header">
<a href="https://pub.solar/" class="ps-homelink">pub.solar/</a>
<#if realm.internationalizationEnabled && locale.supported?size gt 1>
<ul class="ps-i18n-links">
<#list locale.supported as l>
<li class="ps-i18n-links--item">
<a
class="ps-i18n-links--link <#if locale.current == l.label >ps-i18n-links--link_active</#if>"
href="${l.url}"
>${l.label}</a>
</li>
</#list>
</ul>
</#if>
<nav class="ps-header--nav" role="navigation">
<ul class="ps-header--nav-list">
<#if realm.internationalizationEnabled>
<li class="ps-header--nav-link">
<div class="kc-dropdown" id="kc-locale-dropdown">
<a href="#" id="kc-current-locale-link">${locale.current}</a>
<ul>
<#list locale.supported as l>
<li class="kc-dropdown-item">
<a class="ps-link" href="${l.url}">${l.label}</a>
</li>
</#list>
</ul>
</div>
<li>
</#if>
<#if referrer?has_content && referrer.url?has_content>
<li class="ps-header--nav-item">
<a

View File

@ -121,6 +121,10 @@ html {
font-weight: bold; }
.ps-form-group .ps-button {
align-self: flex-start; }
.ps-form-group--error {
margin-top: 0.25rem;
color: var(--accent);
font-weight: bold; }
.ps-homelink {
position: fixed;
@ -147,6 +151,41 @@ html {
.ps-homelink {
font-size: 32px;
padding: 12px; } }
.ps-i18n-links {
position: fixed;
top: 0;
right: 0;
z-index: 100;
display: flex;
margin: 0; }
.ps-i18n-links--item {
display: flex; }
.ps-i18n-links--link {
pointer-events: all;
color: var(--foreground);
background: white;
text-decoration: none;
text-align: center;
font-weight: 900;
font-size: 24px;
padding: 8px;
line-height: 1em;
text-shadow: 0.15vw 0px 0px var(--background);
transition: text-shadow 0.1s ease;
border: 12px solid var(--foreground);
border-top: 0;
border-right: 0; }
.ps-i18n-links--link:hover {
text-shadow: 0.3vw 0px 0px var(--accent); }
@media screen and (min-width: 1200px) {
.ps-i18n-links--link {
font-size: 32px;
padding: 12px; } }
.ps-i18n-links--link_active {
background-color: var(--foreground);
color: var(--background);
text-shadow: 0.15vw 0px 0px var(--foreground); }
.ps-main {
display: flex;
flex-direction: column;
@ -170,9 +209,6 @@ html {
z-index: 1; }
.ps-main_full {
padding: 0; }
.ps-main_full .ps-main--page {
overflow: hidden;
height: 100vh; }
.ps-header {
display: flex;
@ -233,13 +269,17 @@ html {
right: 0;
height: auto;
position: fixed; }
.ps-page--title {
font-size: 2rem;
border-bottom: 0.5rem solid var(--foreground);
padding-bottom: 0.5rem;
margin: 2rem; }
.ps-page--section {
border: 12px solid black;
margin-top: 2rem;
margin-bottom: 2rem;
max-width: 700px;
flex-basis: 100%;
font-size: 16px;
flex-shrink: 1;
pointer-events: all;
color: var(--foreground);
@ -255,9 +295,7 @@ html {
.ps-page--section_full {
max-width: unset;
width: calc(100% - 8rem);
margin: 4rem;
height: calc(100vh - 8rem);
overflow: auto; }
margin: 4rem; }
.ps-page--section a {
color: var(--accent);
border-bottom: 1px solid transparent;
@ -305,8 +343,8 @@ html {
margin-top: 1rem; }
.ps-page--section-contents > .ps-table + * {
margin-top: 1rem; }
.ps-page--section-contents > h1, .ps-page--section-contents h2 {
margin-top: 2rem;
.ps-page--section-contents > h2, .ps-page--section-contents h3, .ps-page--section-contents h4 {
margin-top: 1.5rem;
line-height: 1.5; }
.ps-section-nav {

View File

@ -11,4 +11,11 @@
.ps-button {
align-self: flex-start;
}
&--error {
margin-top: 0.25rem;
color: var(--accent);
font-weight: bold;
// font-family: monospace;
}
}

View File

@ -0,0 +1,44 @@
.ps-i18n-links {
position: fixed;
top: 0;
right: 0;
z-index: 100;
display: flex;
margin: 0;
&--item {
display: flex;
}
&--link {
pointer-events: all;
color: var(--foreground);
background: white;
text-decoration: none;
text-align: center;
font-weight: 900;
font-size: 24px;
padding: 8px;
line-height: 1em;
text-shadow: 0.15vw 0px 0px var(--background);
transition: text-shadow 0.1s ease;
border: 12px solid var(--foreground);
border-top: 0;
border-right: 0;
&:hover {
text-shadow: 0.3vw 0px 0px var(--accent);
}
@media screen and (min-width: 1200px) {
font-size: 32px;
padding: 12px;
}
&_active {
background-color: var(--foreground);
color: var(--background);
text-shadow: 0.15vw 0px 0px var(--foreground);
}
}
}

View File

@ -39,6 +39,7 @@ html {
@import './form-group';
@import './homelink';
@import './i18n-links';
@import './main';
@import './header';
@import './page';

View File

@ -27,9 +27,4 @@
&_full {
padding: 0;
}
&_full &--page {
overflow: hidden;
height: 100vh;
}
}

View File

@ -27,13 +27,19 @@
position: fixed;
}
&--title {
font-size: 2rem;
border-bottom: 0.5rem solid var(--foreground);
padding-bottom: 0.5rem;
margin: 2rem;
}
&--section {
border: 12px solid black;
margin-top: 2rem;
margin-bottom: 2rem;
max-width: 700px;
flex-basis: 100%;
font-size: 16px;
flex-shrink: 1;
pointer-events: all;
color: var(--foreground);
@ -54,8 +60,6 @@
max-width: unset;
width: calc(100% - 8rem);
margin: 4rem;
height: calc(100vh - 8rem);
overflow: auto;
}
a {
@ -130,8 +134,8 @@
}
}
> h1, h2 {
margin-top: 2rem;
> h2, h3, h4 {
margin-top: 1.5rem;
line-height: 1.5;
}
}

View File

@ -1,43 +1,55 @@
<#import "template.ftl" as layout>
<@layout.registrationLayout displayMessage=!messagesPerField.existsError('password'); section>
<#if section = "header">
${msg("doLogIn")}
<#elseif section = "form">
<div id="kc-form">
<div id="kc-form-wrapper">
<form id="kc-form-login" onsubmit="login.disabled = true; return true;" action="${url.loginAction}"
method="post">
<div class="${properties.kcFormGroupClass!} no-bottom-margin">
<hr/>
<label for="password" class="${properties.kcLabelClass!}">${msg("password")}</label>
<input tabindex="2" id="password" class="${properties.kcInputClass!}" name="password"
type="password" autocomplete="on" autofocus
aria-invalid="<#if messagesPerField.existsError('password')>true</#if>"
/>
<#if messagesPerField.existsError('password')>
<span id="input-error-password" class="${properties.kcInputErrorMessageClass!}" aria-live="polite">
${kcSanitize(messagesPerField.get('password'))?no_esc}
</span>
</#if>
</div>
<#if section = "header">
${msg("doLogIn")}
<#elseif section = "form">
<form
id="kc-form-login"
class="ps-container"
onsubmit="login.disabled = true; return true;"
action="${url.loginAction}"
method="post"
>
<div class="${properties.kcFormGroupClass!}">
<label for="password" class="${properties.kcLabelClass!}">${msg("password")}</label>
<input
tabindex="2"
id="password"
class="${properties.kcInputClass!}"
name="password"
type="password"
autocomplete="on"
autofocus
aria-invalid="<#if messagesPerField.existsError('password')>true</#if>"
/>
<#if messagesPerField.existsError('password')>
<span id="input-error-password" class="${properties.kcInputErrorMessageClass!}" aria-live="polite">
${kcSanitize(messagesPerField.get('password'))?no_esc}
</span>
</#if>
</div>
<div class="${properties.kcFormGroupClass!} ${properties.kcFormSettingClass!}">
<div id="kc-form-options">
</div>
<div class="${properties.kcFormOptionsWrapperClass!}">
<#if realm.resetPasswordAllowed>
<span><a tabindex="5"
href="${url.loginResetCredentialsUrl}">${msg("doForgotPassword")}</a></span>
</#if>
</div>
</div>
<div class="${properties.kcFormGroupClass!} ${properties.kcFormSettingClass!}">
<#if realm.resetPasswordAllowed>
<span><a tabindex="5"
href="${url.loginResetCredentialsUrl}">${msg("doForgotPassword")}</a></span>
</#if>
</div>
<div id="kc-form-buttons" class="${properties.kcFormGroupClass!}">
<input tabindex="4" class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}" name="login" id="kc-login" type="submit" value="${msg("doLogIn")}"/>
</div>
</form>
</div>
</div>
</#if>
<div id="kc-form-buttons" class="${properties.kcFormGroupClass!}">
<button
tabindex="4"
class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}"
name="login"
id="kc-login"
type="submit"
>
${msg("doLogIn")}
</button>
</div>
</form>
</div>
</div>
</#if>
</@layout.registrationLayout>

View File

@ -1,87 +1,111 @@
<#import "template.ftl" as layout>
<@layout.registrationLayout displayMessage=!messagesPerField.existsError('username') displayInfo=(realm.password && realm.registrationAllowed && !registrationDisabled??); section>
<#if section = "header">
${msg("loginAccountTitle")}
<#elseif section = "form">
<div id="kc-form">
<div id="kc-form-wrapper">
<#if realm.password>
<form id="kc-form-login" onsubmit="login.disabled = true; return true;" action="${url.loginAction}"
method="post">
<#if !usernameHidden??>
<div class="${properties.kcFormGroupClass!}">
<label for="username"
class="${properties.kcLabelClass!}"><#if !realm.loginWithEmailAllowed>${msg("username")}<#elseif !realm.registrationEmailAsUsername>${msg("usernameOrEmail")}<#else>${msg("email")}</#if></label>
<#if section = "header">
${msg("loginAccountTitle")}
<#elseif section = "form">
<#if realm.password>
<form
id="kc-form-login"
onsubmit="login.disabled = true; return true;"
action="${url.loginAction}"
method="post"
class="ps-container"
>
<#if !usernameHidden??>
<div class="${properties.kcFormGroupClass!}">
<label
for="username"
class="${properties.kcLabelClass!}"
>
<#if !realm.loginWithEmailAllowed>${msg("username")}<#elseif !realm.registrationEmailAsUsername>${msg("usernameOrEmail")}<#else>${msg("email")}</#if>
</label>
<input tabindex="1" id="username"
aria-invalid="<#if messagesPerField.existsError('username')>true</#if>"
class="${properties.kcInputClass!}" name="username"
value="${(login.username!'')}"
type="text" autofocus autocomplete="off"/>
<input
tabindex="1"
id="username"
aria-invalid="<#if messagesPerField.existsError('username')>true</#if>"
class="${properties.kcInputClass!}"
name="username"
value="${(login.username!'')}"
type="text"
autofocus
autocomplete="off"
/>
<#if messagesPerField.existsError('username')>
<span id="input-error-username" class="${properties.kcInputErrorMessageClass!}" aria-live="polite">
${kcSanitize(messagesPerField.get('username'))?no_esc}
</span>
</#if>
</div>
</#if>
<#if messagesPerField.existsError('username')>
<span id="input-error-username" class="${properties.kcInputErrorMessageClass!}" aria-live="polite">
${kcSanitize(messagesPerField.get('username'))?no_esc}
</span>
</#if>
</div>
</#if>
<div class="${properties.kcFormGroupClass!} ${properties.kcFormSettingClass!}">
<div id="kc-form-options">
<#if realm.rememberMe && !usernameHidden??>
<div class="checkbox">
<label>
<#if login.rememberMe??>
<input tabindex="3" id="rememberMe" name="rememberMe" type="checkbox"
checked> ${msg("rememberMe")}
<#else>
<input tabindex="3" id="rememberMe" name="rememberMe"
type="checkbox"> ${msg("rememberMe")}
</#if>
</label>
</div>
</#if>
</div>
</div>
<div id="kc-form-buttons" class="${properties.kcFormGroupClass!}">
<input tabindex="4"
class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}"
name="login" id="kc-login" type="submit" value="${msg("doLogIn")}"/>
</div>
</form>
</#if>
<#if realm.rememberMe && !usernameHidden??>
<div class="${properties.kcFormGroupClass!} ${properties.kcFormSettingClass!}">
<div id="kc-form-options">
<div class="checkbox">
<label>
<#if login.rememberMe??>
<input
tabindex="3"
id="rememberMe"
name="rememberMe"
type="checkbox"
checked
/> ${msg("rememberMe")}
<#else>
<input
tabindex="3"
id="rememberMe"
name="rememberMe"
type="checkbox"
/> ${msg("rememberMe")}
</#if>
</label>
</div>
</div>
</div>
</#if>
<div id="kc-form-buttons" class="${properties.kcFormGroupClass!}">
<button
tabindex="4"
class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}"
name="login"
id="kc-login"
type="submit"
>${msg("doLogIn")}</button>
</div>
<#elseif section = "info" >
<#if realm.password && realm.registrationAllowed && !registrationDisabled??>
<div id="kc-registration">
<span>${msg("noAccount")} <a tabindex="6" href="${url.registrationUrl}">${msg("doRegister")}</a></span>
</div>
</#if>
<#elseif section = "socialProviders" >
<#if realm.password && social.providers??>
<div id="kc-social-providers" class="${properties.kcFormSocialAccountSectionClass!}">
<hr/>
<h4>${msg("identity-provider-login-label")}</h4>
<ul class="${properties.kcFormSocialAccountListClass!} <#if social.providers?size gt 3>${properties.kcFormSocialAccountListGridClass!}</#if>">
<#list social.providers as p>
<a 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>
</#list>
</ul>
</div>
</#if>
</form>
</#if>
<#elseif section = "info" >
<#if realm.password && realm.registrationAllowed && !registrationDisabled??>
<div id="kc-registration">
<span>${msg("noAccount")} <a tabindex="6" href="${url.registrationUrl}">${msg("doRegister")}</a></span>
</div>
</#if>
<#elseif section = "socialProviders" >
<#if realm.password && social.providers??>
<div id="kc-social-providers" class="${properties.kcFormSocialAccountSectionClass!}">
<hr/>
<h4>${msg("identity-provider-login-label")}</h4>
<ul class="${properties.kcFormSocialAccountListClass!} <#if social.providers?size gt 3>${properties.kcFormSocialAccountListGridClass!}</#if>">
<#list social.providers as p>
<a 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>
</#list>
</ul>
</div>
</#if>
</#if>
</@layout.registrationLayout>

View File

@ -59,9 +59,30 @@
</div>
</div>
<main class="ps-main--page ps-page">
<header class="ps-page--header ps-header">
<header class="ps-page--header ps-header">
<a href="https://pub.solar/" class="ps-homelink">pub.solar/</a>
<#if realm.internationalizationEnabled && locale.supported?size gt 1>
<ul class="ps-i18n-links">
<#list locale.supported as l>
<li class="ps-i18n-links--item">
<a
class="ps-i18n-links--link <#if locale.current == l.label >ps-i18n-links--link_active</#if>"
href="${l.url}"
>${l.label}</a>
</li>
</#list>
</ul>
</#if>
</header>
<section class="ps-page--section ps-page--section_full">
<#if !(auth?has_content && auth.showUsername() && !auth.showResetCredentials())>
<h1 class="ps-header--title"><#nested "header"></h1>
<h1 class="ps-page--title"><#nested "header"></h1>
<#if displayRequiredFields>
<div class="${properties.kcLabelWrapperClass!} subtitle">
<span class="subtitle"><span class="required">*</span> ${msg("requiredFields")}</span>
@ -99,25 +120,11 @@
</div>
</#if>
</#if>
<#if realm.internationalizationEnabled && locale.supported?size gt 1>
<div class="ps-dropdown">
<a href="#" class="ps-link ps-dropdown--trigger">${locale.current}</a>
<div class="ps-dropdown--body">
<ul class="ps-list">
<#list locale.supported as l>
<li class="ps-list--item">
<a class="ps-list--item" href="${l.url}">${l.label}</a>
</li>
</#list>
</ul>
</div>
</div>
</#if>
</header>
<section class="ps-page--section ps-page--section_full">
<div class="ps-page--section-contents ps-container">
<#-- App-initiated actions should not see warning messages about the need to complete the action -->
<#-- during login. -->
<#-- App-initiated actions should not see warning messages about the need to complete the action -->
<#-- during login. -->
<#if displayMessage && message?has_content && (message.type != 'warning' || !isAppInitiatedAction??)>
<div class="alert-${message.type} ${properties.kcAlertClass!} pf-m-<#if message.type = 'error'>danger<#else>${message.type}</#if>">
<div class="pf-c-alert__icon">

View File

@ -11,4 +11,6 @@ kcButtonLargeClass=ps-button_large
kcFormGroupClass=ps-form-group
kcLabelClass=ps-form-group--label
kcInputErrorMessageClass=ps-form-group--error
kcInputClass=ps-input