Add neo-brutalist Keycloak login theme 'ecosplay'

- Custom theme under themes/ecosplay/login (extends keycloak parent)
  with template.ftl and login.ftl matching the e-cosplay.fr style:
  thick black borders, hard offset shadows, italic uppercase, indigo
  accent, hover translate effect, marquee header, watermark.
- Tailwind via Play CDN for utility classes (no build step).
- Mount the theme dir read-only into the Keycloak container.
- Init container now also sets loginTheme=ecosplay on master realm
  alongside the SMTP config; service renamed keycloak-init.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-04-10 09:17:49 +02:00
parent 59f60b4c5c
commit f1b98fe8d7
4 changed files with 227 additions and 5 deletions

View File

@@ -35,15 +35,17 @@ services:
KEYCLOAK_ADMIN_PASSWORD: admin KEYCLOAK_ADMIN_PASSWORD: admin
ports: ports:
- "9450:8080" - "9450:8080"
volumes:
- ./themes/ecosplay:/opt/keycloak/themes/ecosplay:ro
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
networks: networks:
- keycloak-net - keycloak-net
keycloak-smtp-init: keycloak-init:
image: quay.io/keycloak/keycloak:26.0 image: quay.io/keycloak/keycloak:26.0
container_name: ecosplay-auth-smtp-init container_name: ecosplay-auth-init
depends_on: depends_on:
keycloak: keycloak:
condition: service_started condition: service_started
@@ -57,6 +59,7 @@ services:
SMTP_FROM_DISPLAY_NAME: E-Cosplay SMTP_FROM_DISPLAY_NAME: E-Cosplay
SMTP_USER: AKIAWTT2T22CWBRBBDYN SMTP_USER: AKIAWTT2T22CWBRBBDYN
SMTP_PASSWORD: BBdgb6KxRQ8mNcpWFJsZCJxbSGNdgLhKFiITMErfBlQP SMTP_PASSWORD: BBdgb6KxRQ8mNcpWFJsZCJxbSGNdgLhKFiITMErfBlQP
LOGIN_THEME: ecosplay
entrypoint: ["/bin/bash", "-c"] entrypoint: ["/bin/bash", "-c"]
command: command:
- | - |
@@ -69,7 +72,7 @@ services:
echo "Waiting for Keycloak to be ready..." echo "Waiting for Keycloak to be ready..."
sleep 5 sleep 5
done done
echo "Keycloak ready, configuring SMTP on master realm..." echo "Keycloak ready, configuring master realm (SMTP + theme)..."
/opt/keycloak/bin/kcadm.sh update realms/master \ /opt/keycloak/bin/kcadm.sh update realms/master \
-s "smtpServer.host=$$SMTP_HOST" \ -s "smtpServer.host=$$SMTP_HOST" \
-s "smtpServer.port=$$SMTP_PORT" \ -s "smtpServer.port=$$SMTP_PORT" \
@@ -79,8 +82,9 @@ services:
-s "smtpServer.starttls=true" \ -s "smtpServer.starttls=true" \
-s "smtpServer.ssl=false" \ -s "smtpServer.ssl=false" \
-s "smtpServer.user=$$SMTP_USER" \ -s "smtpServer.user=$$SMTP_USER" \
-s "smtpServer.password=$$SMTP_PASSWORD" -s "smtpServer.password=$$SMTP_PASSWORD" \
echo "SMTP configuration applied to master realm." -s "loginTheme=$$LOGIN_THEME"
echo "Master realm configured."
networks: networks:
- keycloak-net - keycloak-net
restart: "no" restart: "no"

View File

@@ -0,0 +1,95 @@
<#import "template.ftl" as layout>
<@layout.registrationLayout displayMessage=!messagesPerField.existsError('username','password') displayInfo=realm.password && realm.registrationAllowed && !registrationDisabled??; section>
<#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="space-y-6">
<#if !usernameHidden??>
<div>
<label for="username" class="block font-black uppercase tracking-widest text-xs mb-2 text-gray-900">
<#if !realm.loginWithEmailAllowed>${msg("username")}<#elseif !realm.registrationEmailAsUsername>${msg("usernameOrEmail")}<#else>${msg("email")}</#if>
</label>
<input tabindex="1" id="username" name="username" type="text" autofocus autocomplete="username"
value="${(login.username!'')}"
aria-invalid="<#if messagesPerField.existsError('username','password')>true</#if>"
class="w-full px-4 py-4 bg-white text-gray-900 font-bold not-italic border-4 border-gray-900 placeholder-gray-400 focus:outline-none focus:bg-yellow-100 focus:shadow-[6px_6px_0px_rgba(79,70,229,1)] transition-all" />
<#if messagesPerField.existsError('username','password')>
<span class="block mt-2 text-red-600 font-black uppercase text-xs tracking-widest" aria-live="polite">
${kcSanitize(messagesPerField.getFirstError('username','password'))?no_esc}
</span>
</#if>
</div>
</#if>
<div>
<label for="password" class="block font-black uppercase tracking-widest text-xs mb-2 text-gray-900">
${msg("password")}
</label>
<div class="relative">
<input tabindex="2" id="password" name="password" type="password" autocomplete="current-password"
aria-invalid="<#if messagesPerField.existsError('username','password')>true</#if>"
class="w-full px-4 py-4 bg-white text-gray-900 font-bold not-italic border-4 border-gray-900 placeholder-gray-400 focus:outline-none focus:bg-yellow-100 focus:shadow-[6px_6px_0px_rgba(79,70,229,1)] transition-all" />
</div>
<#if usernameHidden?? && messagesPerField.existsError('username','password')>
<span class="block mt-2 text-red-600 font-black uppercase text-xs tracking-widest" aria-live="polite">
${kcSanitize(messagesPerField.getFirstError('username','password'))?no_esc}
</span>
</#if>
</div>
<div class="flex items-center justify-between gap-4 flex-wrap pt-2">
<#if realm.rememberMe && !usernameHidden??>
<label for="rememberMe" class="flex items-center gap-3 font-black uppercase text-xs tracking-widest cursor-pointer">
<input tabindex="3" id="rememberMe" name="rememberMe" type="checkbox"
<#if login.rememberMe??>checked</#if>
class="w-5 h-5 border-4 border-gray-900 accent-indigo-600">
${msg("rememberMe")}
</label>
</#if>
<#if realm.resetPasswordAllowed>
<a tabindex="5" href="${url.loginResetCredentialsUrl}"
class="font-black uppercase text-xs tracking-widest text-indigo-600 hover:text-gray-900 underline decoration-4 underline-offset-4">
${msg("doForgotPassword")}
</a>
</#if>
</div>
<input type="hidden" id="id-hidden-input" name="credentialId" <#if auth.selectedCredential?has_content>value="${auth.selectedCredential}"</#if>/>
<button tabindex="4" name="login" id="kc-login" type="submit"
class="block w-full px-8 py-5 bg-indigo-600 text-white font-black uppercase italic tracking-widest text-lg border-4 border-gray-900 shadow-[8px_8px_0px_rgba(0,0,0,1)] hover:shadow-none hover:translate-x-2 hover:translate-y-2 transition-all">
${msg("doLogIn")}
</button>
</form>
</#if>
<#elseif section = "info">
<#if realm.password && realm.registrationAllowed && !registrationDisabled??>
<p class="text-sm">
<span class="font-bold uppercase tracking-tight text-gray-700">${msg("noAccount")}</span>
<a tabindex="6" href="${url.registrationUrl}"
class="ml-2 font-black uppercase text-indigo-600 hover:text-gray-900 underline decoration-4 underline-offset-4">
${msg("doRegister")}
</a>
</p>
</#if>
<#elseif section = "socialProviders">
<#if realm.password && social?? && social.providers?? && social.providers?has_content>
<div class="mt-8 pt-6 border-t-4 border-gray-900">
<p class="text-indigo-600 font-black uppercase tracking-[0.3em] mb-4 text-xs">// ${msg("identity-provider-login-label")}</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<#list social.providers as p>
<a id="social-${p.alias}" href="${p.loginUrl}"
class="flex items-center justify-center gap-2 px-4 py-3 bg-white text-gray-900 font-black uppercase tracking-widest text-xs border-4 border-gray-900 shadow-[6px_6px_0px_rgba(0,0,0,1)] hover:shadow-none hover:translate-x-1 hover:translate-y-1 transition-all">
<#if p.iconClasses?has_content><i class="${p.iconClasses!}" aria-hidden="true"></i></#if>
<span>${p.displayName!}</span>
</a>
</#list>
</div>
</div>
</#if>
</#if>
</@layout.registrationLayout>

View File

@@ -0,0 +1,120 @@
<#macro registrationLayout bodyClass="" displayInfo=false displayMessage=true displayRequiredFields=false showAnotherWayIfPresent=true>
<!DOCTYPE html>
<html lang="${locale.currentLanguageTag}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<meta name="robots" content="noindex, nofollow">
<title>${msg("loginTitle",(realm.displayName!''))}</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
@keyframes marquee {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
}
.animate-marquee {
display: flex;
width: 200%;
animation: marquee 40s linear infinite;
}
body { font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus {
-webkit-text-fill-color: #111827;
-webkit-box-shadow: 0 0 0px 1000px #ffffff inset;
transition: background-color 5000s ease-in-out 0s;
}
</style>
</head>
<body class="bg-[#fbfbfb] italic min-h-screen flex flex-col overflow-x-hidden ${properties.kcBodyClass!}">
<!-- Background watermark -->
<div aria-hidden="true" class="fixed inset-0 opacity-[0.03] pointer-events-none select-none overflow-hidden">
<span class="text-[30rem] font-black uppercase leading-none block -rotate-12 translate-y-20">ECOSPLAY</span>
</div>
<!-- Top marquee -->
<div class="bg-gray-900 py-4 border-b-4 border-indigo-600 overflow-hidden relative z-10">
<div class="flex whitespace-nowrap animate-marquee italic">
<#list 1..6 as i>
<span class="text-white font-black uppercase mx-8 text-lg opacity-80">
Authentification // E-Cosplay // Communauté Inclusive // Hauts-de-France
</span>
</#list>
</div>
</div>
<main class="flex-grow flex items-center justify-center px-4 py-16 relative z-10">
<div class="w-full max-w-xl">
<!-- Card -->
<div class="bg-white border-4 border-gray-900 shadow-[12px_12px_0px_rgba(0,0,0,1)] p-8 md:p-12">
<!-- Header -->
<div class="mb-8 pb-6 border-b-4 border-gray-900">
<p class="text-indigo-600 font-black uppercase tracking-[0.3em] mb-3 text-xs">// ${realm.displayName!"E-Cosplay"}</p>
<h1 class="text-4xl md:text-5xl font-black uppercase tracking-tighter leading-[0.9]">
<#nested "header">
</h1>
</div>
<!-- Messages -->
<#if displayMessage && message?has_content && (message.type != 'warning' || !isAppInitiatedAction??)>
<div class="mb-6 border-4 border-gray-900 p-4 font-black uppercase tracking-tight text-sm shadow-[6px_6px_0px_rgba(0,0,0,1)]
<#if message.type = 'success'>bg-green-200</#if>
<#if message.type = 'warning'>bg-yellow-300</#if>
<#if message.type = 'error'>bg-red-300</#if>
<#if message.type = 'info'>bg-indigo-200</#if>
">
${kcSanitize(message.summary)?no_esc}
</div>
</#if>
<!-- Required fields hint -->
<#if displayRequiredFields>
<p class="mb-6 text-xs font-black uppercase tracking-widest text-gray-500">
<span class="text-red-600">*</span> ${msg("requiredFields")}
</p>
</#if>
<!-- Main form / content -->
<div class="space-y-6">
<#nested "form">
</div>
<!-- Social providers -->
<#nested "socialProviders">
<!-- Info section -->
<#if displayInfo>
<div class="mt-8 pt-6 border-t-4 border-gray-900 text-sm font-bold">
<#nested "info">
</div>
</#if>
</div>
<!-- Locale selector -->
<#if realm.internationalizationEnabled && locale.supported?size gt 1>
<div class="mt-6 bg-white border-4 border-gray-900 p-3 shadow-[6px_6px_0px_rgba(0,0,0,1)] flex items-center justify-center gap-4 flex-wrap">
<span class="font-black uppercase text-xs tracking-widest text-gray-500">// Langue</span>
<#list locale.supported as l>
<a href="${l.url}" class="font-black uppercase text-xs tracking-widest <#if l.languageTag == locale.currentLanguageTag>text-indigo-600 underline decoration-4 underline-offset-4<#else>text-gray-900 hover:text-indigo-600</#if>">
${l.label}
</a>
</#list>
</div>
</#if>
</div>
</main>
<!-- Footer band -->
<footer class="bg-gray-900 border-t-4 border-indigo-600 py-6 px-4 relative z-10">
<p class="text-center text-white font-black uppercase tracking-widest text-xs italic">
&copy; E-Cosplay // Créer la réalité
</p>
</footer>
</body>
</html>
</#macro>

View File

@@ -0,0 +1,3 @@
parent=keycloak
import=common/keycloak
locales=en,fr