Compare commits

...

10 Commits

Author SHA1 Message Date
Serreau Jovann
4484b70c19 Disable PKCE on ecosplay_code client (Gitea compat)
Gitea 1.25.5 and earlier do not send PKCE code_challenge_method
on OIDC sources, so enforcing PKCE in Keycloak causes:

  Missing parameter: code_challenge_method

at the /auth endpoint. Drop the pkce.code.challenge.method
attribute from the ecosplay_code client block in the realm
import JSON, and add a set_client_pkce helper to sync.sh that
clears the attribute on existing installs. All other clients
(ecosplay_web, eticket) keep S256.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:53:17 +02:00
Serreau Jovann
7d31714908 Update ecosplay_code callback path (esy_lock -> ecosplay_code)
The Gitea OAuth2 provider name changed from esy_lock to
ecosplay_code, so the callback path follows:
  /user/oauth2/ecosplay_code/callback

Update both the realm import JSON and sync.sh reconciliation
for code.e-cosplay.fr and cos.local.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:51:02 +02:00
Serreau Jovann
40c36ef299 Drop Cloudflare DNS TLS block from Caddy vhost
Remove the tls { dns cloudflare ... } directive and fall back to
Caddy's default automatic HTTPS (HTTP-01 / TLS-ALPN). The
Cloudflare DNS plugin was causing issues during cert provisioning;
standard ACME works fine as long as port 80/443 reach the server.

Also drop the now-unused cloudflare_token variable from group_vars.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:48:56 +02:00
Serreau Jovann
cad8f5bb91 Add ecosplay_code OIDC client for Gitea SSO
New confidential client 'ecosplay_code' with PKCE S256, declared
in the realm import JSON for fresh installs and reconciled via
sync.sh (ensure_client + set_client_uris) for existing installs.

Redirect URIs match the Gitea OAuth2 callback format for the
esy_lock provider:
  https://code.e-cosplay.fr/user/oauth2/esy_lock/callback
  https://cos.local/user/oauth2/esy_lock/callback

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:24:28 +02:00
Serreau Jovann
5af94062d2 Add Postgres backup script
scripts/backup.sh runs pg_dump inside the ecosplay-auth-db
container, writes a gzipped, timestamped dump to /backup/
(overridable via BACKUP_DIR), and keeps the latest N dumps
(RETENTION=14 by default).

Only the Postgres volume carries state that isn't reproducible
from code (users, credentials, TOTP secrets, sessions, brute-
force counters), so the rest of the repo is not bundled here.

Intended as a base that can later feed a cron job or be piped
to off-site storage (S3, SFTP, borg).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:21:35 +02:00
Serreau Jovann
832be361c7 Switch admin to jovann@e-cosplay.fr + disable default admin
- Rename the bootstrap human admin from jovann@siteconseil.fr to
  jovann@e-cosplay.fr in docker-compose env vars and in the realm
  import JSON. Keycloak identifies users by username so a new user
  is created on the next sync run; the old jovann@siteconseil.fr
  is left in place and can be deleted manually from the admin UI.
- Introduce a service account client `sync-bot` in the master
  realm (confidential, service accounts enabled, direct grants off)
  granted the `admin` realm role. sync.sh now authenticates via
  client_credentials, falling back to the bootstrap admin only on
  the very first run — so reconciliation keeps working after the
  default admin is disabled.
- Add disable_default_admin() at the end of the sync script. It
  first verifies that sync-bot can authenticate, then flips the
  `admin` user's `enabled` flag to false. Idempotent and safe:
  refuses to run if sync-bot auth is broken, and is a no-op if
  admin is already disabled.
- SYNC_BOT_CLIENT / SYNC_BOT_SECRET env vars added to the init
  container for both bootstrap authentication and service client
  secret reconciliation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:15:46 +02:00
Serreau Jovann
1ed5c020b1 Style Keycloak PatternFly markup in login theme
Pages we don't override with a custom .ftl (change password, OTP,
verify email, required actions, etc.) render their inner form
with Keycloak's stock PatternFly/Bootstrap classes. The brutalist
card shell was styled but the fields inside were not.

Add resources/css/brutalist.css with targeted overrides on
.pf-c-form-control, .pf-c-button, .pf-c-input-group, .checkbox,
.form-group, alerts and headings, then link it from template.ftl
so every Keycloak auto-generated page inherits the E-Cosplay look
without touching each individual .ftl file.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:10:55 +02:00
Serreau Jovann
b6dde137b4 Drop custom Caddy log file, fall back to journald
Caddy failed to start because the caddy user could not open
/var/log/caddy/auth.e-cosplay.fr.log. Rather than manage a
dedicated log directory + permissions, remove the custom `log`
block from the vhost so Caddy logs to stderr, which systemd
captures via journald (read with `journalctl -u caddy -f`).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:07:18 +02:00
Serreau Jovann
74aec1f1c9 Fix Caddy sites directory path (sites, not site)
The target server uses /etc/caddy/sites/ (plural) for per-site
config files, not /etc/caddy/site/.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:52:47 +02:00
Serreau Jovann
88723b5e5f Add Ansible playbook for on-server deploy
Self-contained playbook intended to be run locally on the target
server, where this repo is already cloned (typically at
/var/www/e-auth). No SSH / inventory needed — hosts: localhost
with connection: local.

What it does:
- Installs Docker Engine + compose plugin from the official repo
  (idempotent, no-op if already present).
- Ensures /etc/caddy/site exists and templates the vhost file at
  /etc/caddy/site/e-auth.conf with the Cloudflare DNS-01 token for
  caddy-dns/cloudflare, reverse-proxying to 127.0.0.1:9450.
- Validates the Caddy config and reloads the service on change.
- Runs `docker compose pull` and `docker compose up -d` from the
  repo root.

Assumes Caddy is already installed with the caddy-dns/cloudflare
plugin and loads per-site files from /etc/caddy/site/*.conf.

Usage (on the server):
  cd /var/www/e-auth/ansible && ansible-playbook deploy.yml

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:45:15 +02:00
9 changed files with 704 additions and 8 deletions

156
ansible/deploy.yml Normal file
View File

@@ -0,0 +1,156 @@
---
# =============================================================
# ecosplay-auth deploy playbook (local execution on the server)
#
# Assumes:
# - This repo is cloned at {{ deploy_dir }} (default /var/www/e-auth)
# and the playbook is invoked from inside ansible/.
# - Caddy is already installed on the server with the
# caddy-dns/cloudflare plugin and loads per-site files from
# /etc/caddy/sites/*.conf.
# - The user running `ansible-playbook` has passwordless sudo.
#
# Usage:
# cd /var/www/e-auth/ansible
# ansible-playbook deploy.yml
# =============================================================
- name: Deploy ecosplay-auth (Keycloak + Caddy vhost)
hosts: localhost
connection: local
become: true
gather_facts: true
vars:
# Root of the repo (the parent of the ansible/ directory).
deploy_dir: "{{ playbook_dir | dirname }}"
tasks:
# ---------------------------------------------------------
# System prerequisites
# ---------------------------------------------------------
- name: Install base packages
ansible.builtin.apt:
name:
- ca-certificates
- curl
- gnupg
- python3-apt
- rsync
update_cache: yes
state: present
# ---------------------------------------------------------
# Docker Engine + compose plugin (idempotent)
# ---------------------------------------------------------
- name: Ensure /etc/apt/keyrings exists
ansible.builtin.file:
path: /etc/apt/keyrings
state: directory
mode: "0755"
- name: Add Docker GPG key
ansible.builtin.get_url:
url: "https://download.docker.com/linux/{{ ansible_distribution | lower }}/gpg"
dest: /etc/apt/keyrings/docker.asc
mode: "0644"
- name: Add Docker apt repository
ansible.builtin.apt_repository:
repo: >-
deb [arch={{ ansible_architecture |
replace('x86_64', 'amd64') |
replace('aarch64', 'arm64') }}
signed-by=/etc/apt/keyrings/docker.asc]
https://download.docker.com/linux/{{ ansible_distribution | lower }}
{{ ansible_distribution_release }} stable
state: present
filename: docker
update_cache: yes
- name: Install Docker Engine + compose plugin
ansible.builtin.apt:
name:
- docker-ce
- docker-ce-cli
- containerd.io
- docker-buildx-plugin
- docker-compose-plugin
state: present
- name: Ensure Docker service is running
ansible.builtin.systemd:
name: docker
state: started
enabled: yes
# ---------------------------------------------------------
# Repo files (already present at {{ deploy_dir }})
# ---------------------------------------------------------
- name: Ensure deploy directory exists
ansible.builtin.file:
path: "{{ deploy_dir }}"
state: directory
mode: "0755"
- name: Ensure init/sync.sh is executable
ansible.builtin.file:
path: "{{ deploy_dir }}/init/sync.sh"
mode: "0755"
# ---------------------------------------------------------
# Caddy vhost for auth.e-cosplay.fr
# ---------------------------------------------------------
- name: Ensure /etc/caddy/sites directory exists
ansible.builtin.file:
path: /etc/caddy/sites
state: directory
mode: "0755"
owner: root
group: root
- name: Deploy Caddy vhost for {{ auth_domain }}
ansible.builtin.template:
src: e-auth.conf.j2
dest: "{{ caddy_site_file }}"
mode: "0644"
owner: root
group: root
notify: Reload caddy
- name: Validate Caddy configuration
ansible.builtin.command: caddy validate --config /etc/caddy/Caddyfile
register: caddy_validate
changed_when: false
failed_when: caddy_validate.rc != 0
# ---------------------------------------------------------
# Bring up the docker-compose stack
# ---------------------------------------------------------
- name: Pull docker images
ansible.builtin.command: docker compose pull
args:
chdir: "{{ deploy_dir }}"
changed_when: false
- name: Start docker-compose stack
ansible.builtin.command: docker compose up -d --remove-orphans
args:
chdir: "{{ deploy_dir }}"
register: compose_up
changed_when: >-
'Started' in (compose_up.stderr | default(''))
or 'Created' in (compose_up.stderr | default(''))
or 'Recreated' in (compose_up.stderr | default(''))
- name: Show compose output
ansible.builtin.debug:
var: compose_up.stderr_lines
when: compose_up.stderr_lines is defined
handlers:
- name: Reload caddy
ansible.builtin.systemd:
name: caddy
state: reloaded

View File

@@ -0,0 +1,11 @@
---
# ecosplay-auth deploy vars
deploy_dir: /var/www/e-auth
auth_domain: auth.e-cosplay.fr
keycloak_local_port: 9450
# Caddy (assumed already installed and configured to load per-site
# files from /etc/caddy/sites/*.conf). TLS is handled by Caddy's
# default ACME flow (HTTP-01 / TLS-ALPN), no Cloudflare DNS plugin.
caddy_site_file: /etc/caddy/sites/e-auth.conf

View File

@@ -0,0 +1,8 @@
# Managed by Ansible - ecosplay-auth
# Reverse proxy for {{ auth_domain }} -> local Keycloak container on :{{ keycloak_local_port }}
{{ auth_domain }} {
encode gzip zstd
reverse_proxy 127.0.0.1:{{ keycloak_local_port }}
}

View File

@@ -69,10 +69,12 @@ services:
SMTP_PASSWORD: BBdgb6KxRQ8mNcpWFJsZCJxbSGNdgLhKFiITMErfBlQP
LOGIN_THEME: ecosplay
ECOSPLAY_GROUPS: "gp_asso gp_contest gp_mail gp_mailling gp_member gp_ndd gp_sign gp_ticket super_admin_asso superadmin"
ADMIN_USER_USERNAME: jovann@siteconseil.fr
ADMIN_USER_USERNAME: jovann@e-cosplay.fr
ADMIN_USER_PASSWORD: Shoko1997@
ADMIN_USER_FIRSTNAME: Jovann
ADMIN_USER_LASTNAME: Serreau
SYNC_BOT_CLIENT: sync-bot
SYNC_BOT_SECRET: dev-sync-bot-9f3b2a7c1e8d4f6a0b5c2e1d7f8a4b3c
volumes:
- ./init/sync.sh:/opt/init/sync.sh:ro
entrypoint: ["/bin/bash", "/opt/init/sync.sh"]

View File

@@ -16,6 +16,7 @@
# ECOSPLAY_GROUPS (space-separated list)
# ADMIN_USER_USERNAME, ADMIN_USER_PASSWORD,
# ADMIN_USER_FIRSTNAME, ADMIN_USER_LASTNAME
# SYNC_BOT_CLIENT, SYNC_BOT_SECRET
# =============================================================
set -euo pipefail
@@ -29,16 +30,44 @@ warn() { printf ' \033[1;33m! %s\033[0m\n' "$*"; }
# -------------------------------------------------------------
# Wait for Keycloak and authenticate
# -------------------------------------------------------------
log "Waiting for Keycloak at ${KC_SERVER}"
until $KC config credentials \
# Two authentication paths:
# 1. sync-bot service account (preferred, used after bootstrap).
# 2. Default bootstrap admin (used on first run, before sync-bot
# has been created and granted the admin role).
# -------------------------------------------------------------
KC_LOGIN_MODE=none
login_as_sync_bot() {
[ -n "${SYNC_BOT_SECRET:-}" ] || return 1
$KC config credentials \
--server "$KC_SERVER" \
--realm master \
--client "$SYNC_BOT_CLIENT" \
--secret "$SYNC_BOT_SECRET" >/dev/null 2>&1
}
login_as_admin() {
$KC config credentials \
--server "$KC_SERVER" \
--realm master \
--user "$KC_ADMIN" \
--password "$KC_ADMIN_PASSWORD" >/dev/null 2>&1; do
info "not ready yet, retrying in 5s..."
--password "$KC_ADMIN_PASSWORD" >/dev/null 2>&1
}
log "Authenticating to Keycloak at ${KC_SERVER}"
while true; do
if login_as_sync_bot; then
KC_LOGIN_MODE=sync-bot
break
fi
if login_as_admin; then
KC_LOGIN_MODE=bootstrap
break
fi
info "Keycloak not ready / no valid credentials yet, retrying in 5s..."
sleep 5
done
info "Keycloak ready."
info "Authenticated (mode=$KC_LOGIN_MODE)"
# -------------------------------------------------------------
# Helpers
@@ -144,6 +173,19 @@ set_client_uris() {
info " client $2 URIs synced ($1)"
}
set_client_pkce() {
# $1=realm $2=clientId $3=method ("S256", "plain", or "" to disable)
local cid
cid=$(client_internal_id "$1" "$2")
if [ -z "$cid" ]; then
warn "client $2 not found in $1, skipping PKCE update"
return
fi
$KC update "clients/$cid" -r "$1" \
-s "attributes.\"pkce.code.challenge.method\"=$3" >/dev/null
info " client $2 PKCE method set to '${3:-none}' ($1)"
}
ensure_client() {
# $1=realm $2=clientId $3=name $4=description $5=secret
# $6=redirectUris(json) $7=webOrigins(json) $8=postLogoutUris(##-separated)
@@ -171,6 +213,72 @@ ensure_client() {
info "+ client $2 ($1)"
}
ensure_sync_bot() {
# Create (or keep in sync) the automation service client in master.
# Grants its service account the master 'admin' realm role so that
# sync.sh can authenticate with client_credentials after the default
# admin user is disabled.
local cid
cid=$(client_internal_id master "$SYNC_BOT_CLIENT")
if [ -z "$cid" ]; then
$KC create clients -r master \
-s "clientId=$SYNC_BOT_CLIENT" \
-s "name=Sync Bot" \
-s "description=Service account used by init/sync.sh for unattended config reconciliation" \
-s 'protocol=openid-connect' \
-s 'enabled=true' \
-s 'publicClient=false' \
-s "secret=$SYNC_BOT_SECRET" \
-s 'standardFlowEnabled=false' \
-s 'implicitFlowEnabled=false' \
-s 'directAccessGrantsEnabled=false' \
-s 'serviceAccountsEnabled=true' >/dev/null
cid=$(client_internal_id master "$SYNC_BOT_CLIENT")
info "+ service client $SYNC_BOT_CLIENT (master)"
else
$KC update "clients/$cid" -r master \
-s 'enabled=true' \
-s 'serviceAccountsEnabled=true' \
-s 'standardFlowEnabled=false' \
-s 'directAccessGrantsEnabled=false' \
-s "secret=$SYNC_BOT_SECRET" >/dev/null
info "= service client $SYNC_BOT_CLIENT (master)"
fi
# Grant 'admin' realm role to the auto-generated service account user
$KC add-roles -r master \
--uusername "service-account-${SYNC_BOT_CLIENT}" \
--rolename admin >/dev/null 2>&1 || true
info " service-account-${SYNC_BOT_CLIENT} -> realm role admin"
}
disable_default_admin() {
# Only disable once we're sure sync-bot can authenticate (otherwise
# next run would be locked out).
if ! login_as_sync_bot; then
warn "sync-bot auth not working, refusing to disable default admin"
login_as_admin >/dev/null 2>&1 || true
return
fi
# Re-login as admin to perform the disable action (sync-bot's token
# was used only for verification)
login_as_admin >/dev/null 2>&1 || true
local admin_id current
admin_id=$(user_id master "$KC_ADMIN")
if [ -z "$admin_id" ]; then
info "no '$KC_ADMIN' user in master, nothing to disable"
return
fi
current=$($KC get "users/$admin_id" -r master --fields enabled 2>/dev/null \
| sed -n 's/.*"enabled"[ ]*:[ ]*\(true\|false\).*/\1/p' | head -n1)
if [ "$current" = "false" ]; then
info "= default admin user '$KC_ADMIN' already disabled"
return
fi
$KC update "users/$admin_id" -r master -s enabled=false >/dev/null
info "+ default admin user '$KC_ADMIN' disabled"
}
# =============================================================
# Master realm: SMTP, theme, locale
# =============================================================
@@ -191,6 +299,12 @@ $KC update realms/master \
-s "defaultLocale=fr"
info "master realm updated"
# =============================================================
# Master realm: automation service account (sync-bot)
# =============================================================
log "Ensuring sync-bot service client in master realm"
ensure_sync_bot
# =============================================================
# Master realm: global Keycloak admin user
# =============================================================
@@ -232,8 +346,28 @@ if realm_exists ecosplay; then
'["https://ticket.e-cosplay.fr/api/auth/login/sso/validate","https://cos.local/api/auth/login/sso/validate","https://ticket.e-cosplay.fr/connection/sso/check","https://cos.local/connection/sso/check"]' \
'["https://ticket.e-cosplay.fr","https://cos.local"]' \
'https://ticket.e-cosplay.fr/*##https://cos.local/*'
log "Reconciling ecosplay_code client"
ensure_client ecosplay ecosplay_code "E-Cosplay Code" \
"Forge de code (Gitea) - login SSO via ecosplay_code provider (Gitea ne supporte pas PKCE)" \
"change-me-in-admin-console" \
'["https://code.e-cosplay.fr/user/oauth2/ecosplay_code/callback","https://cos.local/user/oauth2/ecosplay_code/callback"]' \
'["https://code.e-cosplay.fr","https://cos.local"]' \
'https://code.e-cosplay.fr/*##https://cos.local/*'
set_client_uris ecosplay ecosplay_code \
'["https://code.e-cosplay.fr/user/oauth2/ecosplay_code/callback","https://cos.local/user/oauth2/ecosplay_code/callback"]' \
'["https://code.e-cosplay.fr","https://cos.local"]' \
'https://code.e-cosplay.fr/*##https://cos.local/*'
# Gitea does not support PKCE on OIDC sources, disable the requirement
set_client_pkce ecosplay ecosplay_code ""
else
warn "ecosplay realm not found — will be imported on next boot"
fi
# =============================================================
# Disable default bootstrap admin (only after sync-bot is proven)
# =============================================================
log "Disabling default bootstrap admin user"
disable_default_admin
log "Sync complete"

View File

@@ -69,8 +69,8 @@
"users": [
{
"username": "jovann@siteconseil.fr",
"email": "jovann@siteconseil.fr",
"username": "jovann@e-cosplay.fr",
"email": "jovann@e-cosplay.fr",
"firstName": "Jovann",
"lastName": "Serreau",
"enabled": true,
@@ -117,6 +117,31 @@
"pkce.code.challenge.method": "S256"
}
},
{
"clientId": "ecosplay_code",
"name": "E-Cosplay Code",
"description": "Forge de code (Gitea) - login SSO via ecosplay_code provider (Gitea ne supporte pas PKCE)",
"enabled": true,
"publicClient": false,
"secret": "change-me-in-admin-console",
"redirectUris": [
"https://code.e-cosplay.fr/user/oauth2/ecosplay_code/callback",
"https://cos.local/user/oauth2/ecosplay_code/callback"
],
"webOrigins": [
"https://code.e-cosplay.fr",
"https://cos.local"
],
"protocol": "openid-connect",
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": false,
"frontchannelLogout": true,
"attributes": {
"post.logout.redirect.uris": "https://code.e-cosplay.fr/*##https://cos.local/*"
}
},
{
"clientId": "eticket",
"name": "E-Ticket",

82
scripts/backup.sh Executable file
View File

@@ -0,0 +1,82 @@
#!/usr/bin/env bash
# =============================================================
# E-Cosplay Keycloak backup
# -------------------------------------------------------------
# Dumps the Keycloak Postgres database to /backup/ as a
# timestamped, gzip-compressed SQL file, then rotates old
# dumps (keeps the latest N).
#
# Usage:
# ./scripts/backup.sh # default settings
# BACKUP_DIR=/mnt/nas RETENTION=30 \
# ./scripts/backup.sh # override via env
#
# Suggested cron (daily at 03:15):
# 15 3 * * * /var/www/e-auth/scripts/backup.sh >>/var/log/e-auth-backup.log 2>&1
#
# Note: the repo itself (compose file, realm JSON, themes,
# Ansible, sync.sh, etc.) lives in git and does not need to
# be backed up here. Only the Postgres volume carries state
# that is not reproducible from code (users, credentials,
# TOTP secrets, sessions, brute-force counters, ...).
# =============================================================
set -euo pipefail
BACKUP_DIR=${BACKUP_DIR:-/backup}
RETENTION=${RETENTION:-14}
PG_CONTAINER=${PG_CONTAINER:-ecosplay-auth-db}
PG_USER=${PG_USER:-keycloak}
PG_DB=${PG_DB:-keycloak}
TS=$(date +%F_%H-%M-%S)
OUT="${BACKUP_DIR}/keycloak-${TS}.sql.gz"
log() { printf '[backup %s] %s\n' "$(date +%T)" "$*"; }
fail() { printf '[backup %s] ERROR: %s\n' "$(date +%T)" "$*" >&2; exit 1; }
# -------------------------------------------------------------
# Pre-flight checks
# -------------------------------------------------------------
command -v docker >/dev/null || fail "docker not found in PATH"
if ! docker ps --format '{{.Names}}' | grep -qx "${PG_CONTAINER}"; then
fail "container '${PG_CONTAINER}' is not running"
fi
# -------------------------------------------------------------
# Dump
# -------------------------------------------------------------
log "Preparing ${BACKUP_DIR}"
mkdir -p "${BACKUP_DIR}"
chmod 700 "${BACKUP_DIR}"
log "Dumping database '${PG_DB}' from container '${PG_CONTAINER}'"
if ! docker exec "${PG_CONTAINER}" \
pg_dump -U "${PG_USER}" -d "${PG_DB}" \
--no-owner --clean --if-exists \
| gzip -9 > "${OUT}.part"; then
rm -f "${OUT}.part"
fail "pg_dump failed"
fi
mv "${OUT}.part" "${OUT}"
chmod 600 "${OUT}"
SIZE=$(du -h "${OUT}" | cut -f1)
log "Wrote ${OUT} (${SIZE})"
# -------------------------------------------------------------
# Rotate: keep only the latest ${RETENTION} backups
# -------------------------------------------------------------
log "Pruning backups older than the last ${RETENTION}"
cd "${BACKUP_DIR}"
# shellcheck disable=SC2010
ls -1t keycloak-*.sql.gz 2>/dev/null \
| tail -n +"$((RETENTION + 1))" \
| while read -r old; do
log " removing ${old}"
rm -f "${old}"
done
log "Done."

View File

@@ -0,0 +1,277 @@
/* ============================================================
E-Cosplay brutalist overrides for default Keycloak markup
------------------------------------------------------------
The shell (card, header, marquee, footer) is rendered by our
template.ftl. Pages that we do NOT override (change password,
OTP, verify email, required actions, etc.) reuse Keycloak's
stock PatternFly/Bootstrap markup for their inner form. This
file restyles those classes so everything inside the card
matches the brutalist identity without having to override
every single .ftl file.
============================================================ */
/* Layout: kill Bootstrap grid side-padding that breaks our card */
.form-group {
margin-bottom: 1.5rem;
}
.form-group > div[class*="col-"] {
padding-left: 0 !important;
padding-right: 0 !important;
float: none !important;
width: 100% !important;
}
.form-horizontal {
margin: 0;
}
/* ---------- Labels ---------- */
.pf-c-form__label,
.pf-c-form__label-text,
label {
display: block;
font-weight: 900 !important;
text-transform: uppercase !important;
letter-spacing: 0.1em;
font-size: 11px;
color: #111827 !important;
margin-bottom: 0.5rem;
font-style: normal !important;
}
/* ---------- Inputs ---------- */
.pf-c-form-control,
input[type="text"],
input[type="email"],
input[type="password"],
input[type="tel"],
input[type="number"],
input[type="url"],
select,
textarea {
width: 100% !important;
padding: 14px 16px !important;
background: #ffffff !important;
color: #111827 !important;
border: 4px solid #111827 !important;
border-radius: 0 !important;
font-weight: 700 !important;
font-style: normal !important;
font-size: 16px !important;
line-height: 1.4 !important;
box-shadow: none !important;
outline: none !important;
transition: all 0.15s ease !important;
height: auto !important;
}
.pf-c-form-control:focus,
input[type="text"]:focus,
input[type="email"]:focus,
input[type="password"]:focus,
input[type="tel"]:focus,
input[type="number"]:focus,
input[type="url"]:focus,
select:focus,
textarea:focus {
background: #fef9c3 !important;
box-shadow: 6px 6px 0 #4f46e5 !important;
outline: none !important;
}
.pf-c-form-control[aria-invalid="true"] {
border-color: #dc2626 !important;
background: #fee2e2 !important;
}
/* ---------- Input group (password reveal button etc.) ---------- */
.pf-c-input-group {
display: flex !important;
align-items: stretch !important;
gap: 0 !important;
width: 100%;
}
.pf-c-input-group > .pf-c-form-control,
.pf-c-input-group > input {
flex: 1 1 auto !important;
}
.pf-c-input-group > .pf-c-button.pf-m-control {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
min-width: 56px !important;
padding: 0 18px !important;
background: #111827 !important;
color: #ffffff !important;
border: 4px solid #111827 !important;
border-left: 0 !important;
border-radius: 0 !important;
font-weight: 900 !important;
cursor: pointer !important;
box-shadow: none !important;
transform: none !important;
letter-spacing: 0 !important;
font-style: normal !important;
text-transform: none !important;
}
.pf-c-input-group > .pf-c-button.pf-m-control:hover {
background: #4f46e5 !important;
}
/* ---------- Buttons (primary / default / cancel) ---------- */
.pf-c-button,
input[type="submit"],
button[type="submit"],
button[type="button"].btn,
a.btn {
display: inline-block;
padding: 16px 28px !important;
font-weight: 900 !important;
text-transform: uppercase !important;
letter-spacing: 0.12em !important;
font-style: italic !important;
font-size: 14px !important;
line-height: 1 !important;
border: 4px solid #111827 !important;
border-radius: 0 !important;
cursor: pointer !important;
transition: all 0.15s ease !important;
text-decoration: none !important;
}
.pf-c-button.pf-m-primary,
input.pf-m-primary,
input[type="submit"].pf-m-primary,
input[type="submit"].btn-primary,
button.btn-primary {
background: #4f46e5 !important;
color: #ffffff !important;
box-shadow: 8px 8px 0 rgba(0, 0, 0, 1) !important;
}
.pf-c-button.pf-m-primary:hover,
input[type="submit"].pf-m-primary:hover,
button.btn-primary:hover {
box-shadow: none !important;
transform: translate(4px, 4px) !important;
background: #4338ca !important;
}
.pf-c-button.btn-default,
.pf-c-button.btn-secondary,
button.btn-default,
button.btn-secondary,
input[type="submit"].btn-default {
background: #ffffff !important;
color: #111827 !important;
box-shadow: 8px 8px 0 rgba(0, 0, 0, 1) !important;
}
.pf-c-button.btn-default:hover,
button.btn-default:hover {
background: #facc15 !important;
box-shadow: none !important;
transform: translate(4px, 4px) !important;
}
/* Avoid the input-group eye button inheriting the primary style above */
.pf-c-input-group .pf-c-button.pf-m-control {
padding: 0 18px !important;
font-size: 14px !important;
letter-spacing: 0 !important;
text-transform: none !important;
font-style: normal !important;
}
/* ---------- Checkboxes ---------- */
.checkbox {
margin: 1rem 0 !important;
}
.checkbox label {
display: flex !important;
align-items: center !important;
gap: 12px !important;
cursor: pointer !important;
font-size: 11px !important;
margin: 0 !important;
}
input[type="checkbox"],
input[type="radio"] {
width: 20px !important;
height: 20px !important;
margin: 0 !important;
border: 4px solid #111827 !important;
accent-color: #4f46e5 !important;
cursor: pointer !important;
}
/* ---------- Button row ---------- */
#kc-form-buttons {
display: flex !important;
gap: 12px !important;
flex-wrap: wrap !important;
margin-top: 1.5rem !important;
}
#kc-form-buttons > .pf-c-button,
#kc-form-buttons > input[type="submit"],
#kc-form-buttons > button {
flex: 1 1 160px;
}
/* ---------- Alerts / messages ---------- */
.alert,
.pf-c-alert {
border: 4px solid #111827 !important;
border-radius: 0 !important;
box-shadow: 6px 6px 0 rgba(0, 0, 0, 1) !important;
padding: 14px 18px !important;
font-weight: 800 !important;
margin-bottom: 1.5rem !important;
}
.alert-success,
.pf-c-alert.pf-m-success {
background: #bbf7d0 !important;
}
.alert-warning,
.pf-c-alert.pf-m-warning {
background: #fde68a !important;
}
.alert-danger,
.alert-error,
.pf-c-alert.pf-m-danger {
background: #fecaca !important;
}
.alert-info,
.pf-c-alert.pf-m-info {
background: #c7d2fe !important;
}
/* ---------- Links ---------- */
a {
color: #4f46e5 !important;
text-decoration: underline !important;
text-decoration-thickness: 3px !important;
text-underline-offset: 4px !important;
font-weight: 800 !important;
}
a:hover {
color: #111827 !important;
}
/* ---------- Headings inside the card ---------- */
.pf-c-title,
#kc-page-title,
h1, h2, h3 {
font-weight: 900 !important;
text-transform: uppercase !important;
font-style: italic !important;
letter-spacing: -0.02em !important;
color: #111827 !important;
}

View File

@@ -7,6 +7,7 @@
<meta name="robots" content="noindex, nofollow">
<title>${msg("loginTitle",(realm.displayName!''))}</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="${url.resourcesPath}/css/brutalist.css">
<style>
@keyframes marquee {
0% { transform: translateX(0); }