diff --git a/k8s-ng/core/keycloak/hacker-theme-configmap.yaml b/k8s-ng/core/keycloak/hacker-theme-configmap.yaml deleted file mode 100644 index 223270d..0000000 --- a/k8s-ng/core/keycloak/hacker-theme-configmap.yaml +++ /dev/null @@ -1,366 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: keycloak-hacker-theme - namespace: keycloak -data: - theme.properties: | - parent=keycloak.v2 - import=common/keycloak - - styles=css/hacker.css - - hacker.css: | - /* ============================================================ - HEXOR — Hacker Terminal Theme for Keycloak - ============================================================ */ - - @import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;600&display=swap'); - - :root { - --hk-bg: #0a0a0a; - --hk-panel: #0d0d0d; - --hk-border: #00ff4180; - --hk-green: #00ff41; - --hk-green-dim: #00cc33; - --hk-green-glow: rgba(0, 255, 65, 0.15); - --hk-green-glow-strong: rgba(0, 255, 65, 0.35); - --hk-cyan: #00e5ff; - --hk-red: #ff3333; - --hk-text: #b0ffb0; - --hk-text-dim: #5a8a5a; - --hk-input-bg: #050505; - --hk-font: 'Fira Code', 'Cascadia Code', 'JetBrains Mono', monospace; - } - - /* -- Force dark always --------------------------------------------------- */ - html.login-pf, - html.login-pf.pf-v5-theme-dark { - color-scheme: dark; - } - - /* -- Background: scanlines + CRT ---------------------------------------- */ - body#keycloak-bg { - font-family: var(--hk-font) !important; - background: var(--hk-bg) !important; - color: var(--hk-text) !important; - min-height: 100vh; - position: relative; - } - - body#keycloak-bg::before { - content: ''; - position: fixed; - inset: 0; - background: repeating-linear-gradient( - 0deg, - transparent, - transparent 2px, - rgba(0, 255, 65, 0.03) 2px, - rgba(0, 255, 65, 0.03) 4px - ); - pointer-events: none; - z-index: 9999; - } - - /* -- Login container ----------------------------------------------------- */ - .pf-v5-c-login { - background: transparent !important; - } - - .pf-v5-c-login__container { - max-width: 480px !important; - } - - /* -- Header / Brand ------------------------------------------------------ */ - #kc-header-wrapper { - font-family: var(--hk-font) !important; - color: var(--hk-green) !important; - text-shadow: 0 0 10px var(--hk-green), 0 0 30px var(--hk-green-glow); - font-size: 1.5rem !important; - letter-spacing: 0.15em; - text-transform: uppercase; - } - - #kc-header-wrapper .kc-logo-text span::before { - content: '> '; - opacity: 0.6; - } - - /* -- Main card ----------------------------------------------------------- */ - .pf-v5-c-login__main { - background: var(--hk-panel) !important; - border: 1px solid var(--hk-border) !important; - border-radius: 0 !important; - box-shadow: - 0 0 15px var(--hk-green-glow), - inset 0 0 30px rgba(0, 0, 0, 0.5) !important; - } - - /* -- Title --------------------------------------------------------------- */ - h1#kc-page-title, - .pf-v5-c-title { - font-family: var(--hk-font) !important; - color: var(--hk-green) !important; - text-shadow: 0 0 8px var(--hk-green-glow); - font-weight: 600 !important; - font-size: 1.25rem !important; - letter-spacing: 0.05em; - } - - /* -- Labels -------------------------------------------------------------- */ - .pf-v5-c-form__label-text { - font-family: var(--hk-font) !important; - color: var(--hk-green-dim) !important; - font-size: 0.85rem !important; - text-transform: uppercase; - letter-spacing: 0.08em; - } - - /* -- Input fields -------------------------------------------------------- */ - .pf-v5-c-form-control { - background-color: var(--hk-input-bg) !important; - border: 1px solid var(--hk-border) !important; - border-radius: 0 !important; - color: var(--hk-green) !important; - font-family: var(--hk-font) !important; - font-size: 0.9rem !important; - caret-color: var(--hk-green); - transition: border-color 0.2s, box-shadow 0.2s; - } - - .pf-v5-c-form-control:focus-within, - .pf-v5-c-form-control:focus { - border-color: var(--hk-green) !important; - box-shadow: 0 0 8px var(--hk-green-glow), inset 0 0 4px var(--hk-green-glow) !important; - outline: none !important; - } - - .pf-v5-c-form-control input { - color: var(--hk-green) !important; - font-family: var(--hk-font) !important; - } - - .pf-v5-c-form-control input::placeholder { - color: var(--hk-text-dim) !important; - } - - /* -- Primary button (Sign In) -------------------------------------------- */ - .pf-v5-c-button.pf-m-primary { - background: transparent !important; - border: 1px solid var(--hk-green) !important; - border-radius: 0 !important; - color: var(--hk-green) !important; - font-family: var(--hk-font) !important; - font-weight: 600 !important; - text-transform: uppercase; - letter-spacing: 0.12em; - font-size: 0.9rem !important; - transition: all 0.2s; - position: relative; - overflow: hidden; - } - - .pf-v5-c-button.pf-m-primary:hover { - background: var(--hk-green-glow) !important; - box-shadow: 0 0 15px var(--hk-green-glow-strong), inset 0 0 15px var(--hk-green-glow) !important; - color: #fff !important; - text-shadow: 0 0 5px var(--hk-green); - } - - /* -- Secondary button (Try Another Way, Passkey) ------------------------- */ - .pf-v5-c-button.pf-m-secondary { - background: transparent !important; - border: 1px solid var(--hk-cyan) !important; - border-radius: 0 !important; - color: var(--hk-cyan) !important; - font-family: var(--hk-font) !important; - text-transform: uppercase; - letter-spacing: 0.1em; - font-size: 0.85rem !important; - transition: all 0.2s; - } - - .pf-v5-c-button.pf-m-secondary:hover { - background: rgba(0, 229, 255, 0.1) !important; - box-shadow: 0 0 12px rgba(0, 229, 255, 0.3) !important; - color: #fff !important; - } - - /* -- Passkey authenticate button ----------------------------------------- */ - #authenticateWebAuthnButton { - background: transparent !important; - border: 1px solid var(--hk-cyan) !important; - border-radius: 0 !important; - color: var(--hk-cyan) !important; - font-family: var(--hk-font) !important; - text-transform: uppercase; - letter-spacing: 0.1em; - font-size: 0.85rem !important; - padding: 0.75rem 1rem; - display: block; - width: 100%; - text-align: center; - text-decoration: none; - transition: all 0.2s; - margin-top: 1rem; - } - - #authenticateWebAuthnButton::before { - content: '\f084 '; - font-family: 'Font Awesome 6 Free', 'FontAwesome'; - font-weight: 900; - } - - #authenticateWebAuthnButton:hover { - background: rgba(0, 229, 255, 0.1) !important; - box-shadow: 0 0 15px rgba(0, 229, 255, 0.3), inset 0 0 10px rgba(0, 229, 255, 0.1) !important; - color: #fff !important; - text-shadow: 0 0 5px var(--hk-cyan); - } - - /* -- Show password button ------------------------------------------------ */ - .pf-v5-c-button.pf-m-control { - background: var(--hk-input-bg) !important; - border: 1px solid var(--hk-border) !important; - border-left: none !important; - border-radius: 0 !important; - color: var(--hk-text-dim) !important; - } - - .pf-v5-c-button.pf-m-control:hover { - color: var(--hk-green) !important; - } - - /* -- Social providers (Google, etc) -------------------------------------- */ - .pf-v5-c-login__main-footer { - border-top: 1px solid var(--hk-border) !important; - } - - .kc-social-section .pf-v5-c-button.pf-m-secondary { - border-color: var(--hk-text-dim) !important; - color: var(--hk-text) !important; - } - - .kc-social-section .pf-v5-c-button.pf-m-secondary:hover { - border-color: var(--hk-green) !important; - color: var(--hk-green) !important; - box-shadow: 0 0 10px var(--hk-green-glow) !important; - } - - /* -- Try another way link ------------------------------------------------ */ - #try-another-way { - color: var(--hk-cyan) !important; - text-decoration: none !important; - font-family: var(--hk-font) !important; - } - - #try-another-way:hover { - text-shadow: 0 0 8px rgba(0, 229, 255, 0.5); - } - - /* -- Links --------------------------------------------------------------- */ - a { - color: var(--hk-cyan) !important; - text-decoration: none; - transition: text-shadow 0.2s; - } - - a:hover { - text-shadow: 0 0 6px rgba(0, 229, 255, 0.4); - } - - /* -- Alerts / messages --------------------------------------------------- */ - .pf-v5-c-alert { - background: rgba(0, 255, 65, 0.05) !important; - border: 1px solid var(--hk-border) !important; - border-radius: 0 !important; - font-family: var(--hk-font) !important; - } - - .pf-v5-c-alert.pf-m-danger { - border-color: var(--hk-red) !important; - background: rgba(255, 51, 51, 0.05) !important; - } - - .pf-v5-c-alert__title { - color: var(--hk-text) !important; - font-family: var(--hk-font) !important; - } - - /* -- Checkbox (Remember me) ---------------------------------------------- */ - .pf-v5-c-check__label { - color: var(--hk-text-dim) !important; - font-family: var(--hk-font) !important; - font-size: 0.8rem !important; - } - - /* -- Helper text (Forgot password, etc.) --------------------------------- */ - .pf-v5-c-helper-text .pf-v5-c-button.pf-m-link { - color: var(--hk-cyan) !important; - font-family: var(--hk-font) !important; - font-size: 0.8rem !important; - } - - /* -- Select auth list (Try another way options) -------------------------- */ - .select-auth-container { - background: var(--hk-panel) !important; - font-family: var(--hk-font) !important; - } - - .pf-v5-c-data-list__item { - border-color: var(--hk-border) !important; - background: transparent !important; - } - - .pf-v5-c-data-list__item:hover { - background: var(--hk-green-glow) !important; - } - - .select-auth-box-headline { - color: var(--hk-green) !important; - } - - .select-auth-box-desc { - color: var(--hk-text-dim) !important; - } - - /* -- Footer -------------------------------------------------------------- */ - .pf-v5-c-login__main-footer-band { - background: transparent !important; - border-top: 1px solid var(--hk-border) !important; - } - - /* -- Language selector --------------------------------------------------- */ - select#login-select-toggle { - background: var(--hk-input-bg) !important; - color: var(--hk-green) !important; - border-color: var(--hk-border) !important; - font-family: var(--hk-font) !important; - } - - /* -- Blinking cursor animation for header -------------------------------- */ - @keyframes blink { - 0%, 100% { opacity: 1; } - 50% { opacity: 0; } - } - - #kc-header-wrapper::after { - content: '_'; - animation: blink 1s step-end infinite; - color: var(--hk-green); - } - - /* -- Subtle CRT flicker ------------------------------------------------- */ - @keyframes flicker { - 0% { opacity: 0.97; } - 5% { opacity: 0.95; } - 10% { opacity: 0.98; } - 15% { opacity: 0.96; } - 20% { opacity: 0.99; } - 100% { opacity: 0.98; } - } - - .pf-v5-c-login__main { - animation: flicker 4s infinite; - } diff --git a/k8s-ng/core/keycloak/kustomization.yaml b/k8s-ng/core/keycloak/kustomization.yaml index b50b6d9..d6baf54 100644 --- a/k8s-ng/core/keycloak/kustomization.yaml +++ b/k8s-ng/core/keycloak/kustomization.yaml @@ -3,10 +3,24 @@ kind: Kustomization resources: - secrets.yaml - - hacker-theme-configmap.yaml - auth-proxy.yaml # ConfigMap auth-proxy-routes is managed by Terraform (terraform/keycloak, kubernetes_config_map_v1). +configMapGenerator: + - name: keycloak-hexor-lab-theme + namespace: keycloak + files: + - theme.properties=themes/hexor-lab/login/theme.properties + - template.ftl=themes/hexor-lab/login/template.ftl + - hexor.css=themes/hexor-lab/login/resources/css/hexor.css + - keycloak.css=themes/hexor-lab/login/resources/css/keycloak.css + - background.js=themes/hexor-lab/login/resources/js/background.js + - favicon.svg=themes/hexor-lab/login/resources/img/favicon.svg + +generatorOptions: + annotations: + argocd.argoproj.io/sync-wave: "-1" + helmCharts: - name: keycloakx repo: https://codecentric.github.io/helm-charts diff --git a/k8s-ng/core/keycloak/preview/app.js b/k8s-ng/core/keycloak/preview/app.js new file mode 100644 index 0000000..ed75cf5 --- /dev/null +++ b/k8s-ng/core/keycloak/preview/app.js @@ -0,0 +1,110 @@ +/* Preview behavior only. Keycloak will supply the real authentication later. */ +"use strict"; +const $ = (selector) => document.querySelector(selector); +const screens = { + login: ["Sign in", ""], + otp: ["Verify identity", ""], + reset: ["Reset password", "We’ll send a reset link to your email."], + success: ["Access granted", ""], +}; + +function notice(message = "", error = false) { + $("#notice").textContent = message; + $("#notice").hidden = !message; + $("#notice").classList.toggle("error", error); +} + +function showScreen(requested, focus = false) { + const screen = requested === "error" ? "login" : (Object.hasOwn(screens, requested) ? requested : "login"); + const [title, description] = screens[screen]; + document.querySelectorAll("[data-view]").forEach((element) => { element.hidden = element.dataset.view !== screen; }); + $("#page-title").replaceChildren(document.createTextNode(title)); + const cursor = document.createElement("span"); + cursor.className = "cursor"; + cursor.setAttribute("aria-hidden", "true"); + cursor.textContent = "_"; + $("#page-title").append(cursor); + $("#page-description").textContent = description; + $("#page-description").hidden = !description; + document.querySelectorAll("[data-screen]").forEach((button) => button.setAttribute("aria-pressed", String(button.dataset.screen === requested))); + document.querySelectorAll(".field-error").forEach((element) => { element.hidden = true; }); + document.querySelectorAll("[aria-invalid]").forEach((element) => element.removeAttribute("aria-invalid")); + notice(requested === "error" ? "[ DENIED ] Invalid username or password. Please try again." : "", requested === "error"); + $("#password").value = ""; + $("#password").type = "password"; + $("#reveal-password").textContent = "SHOW"; + $("#reveal-password").setAttribute("aria-label", "Show password"); + $("#reveal-password").setAttribute("aria-pressed", "false"); + $("#otp").value = ""; + if (focus) document.querySelector(`[data-view="${screen}"] input`)?.focus(); +} + +function navigate(screen) { + if (location.hash === `#${screen}`) showScreen(screen, true); + else location.hash = screen; +} + +function fieldError(field, output, message) { + field.setAttribute("aria-invalid", "true"); + output.textContent = message; + output.hidden = false; + field.focus(); +} + +document.querySelectorAll("[data-screen]").forEach((button) => button.addEventListener("click", () => navigate(button.dataset.screen))); +window.addEventListener("hashchange", () => showScreen(location.hash.slice(1), true)); + +$("#login-form").addEventListener("submit", (event) => { + event.preventDefault(); + notice(); + if (!$("#username").value.trim()) return fieldError($("#username"), $("#login-error"), "Enter your username or email address."); + if (!$("#password").value) return fieldError($("#password"), $("#login-error"), "Enter your password."); + $("#password").value = ""; + navigate("otp"); +}); +$("#otp-form").addEventListener("submit", (event) => { + event.preventDefault(); + if (!/^[0-9]{6}$/.test($("#otp").value)) return fieldError($("#otp"), $("#otp-error"), "Enter a 6-digit verification code."); + $("#otp").value = ""; + navigate("success"); +}); +$("#reset-form").addEventListener("submit", (event) => { + event.preventDefault(); + if (!$("#reset-email").validity.valid) return fieldError($("#reset-email"), $("#reset-error"), "Enter a valid email address."); + $("#reset-error").hidden = true; + $("#reset-email").removeAttribute("aria-invalid"); + notice("[ OK ] Reset-link confirmation preview. No email was sent."); +}); +$("#reveal-password").addEventListener("click", () => { + const reveal = $("#password").type === "password"; + $("#password").type = reveal ? "text" : "password"; + $("#reveal-password").textContent = reveal ? "HIDE" : "SHOW"; + $("#reveal-password").setAttribute("aria-label", reveal ? "Hide password" : "Show password"); + $("#reveal-password").setAttribute("aria-pressed", String(reveal)); +}); +$("#passkey").addEventListener("click", () => notice("[ DEMO ] Passkey authentication will be handled by Keycloak. No device prompt is opened here.")); +$("#google").addEventListener("click", () => notice("[ DEMO ] Google sign-in will be handled by Keycloak. This preview stays local.")); +document.querySelectorAll("input").forEach((input) => input.addEventListener("input", () => { + input.removeAttribute("aria-invalid"); + input.closest("form")?.querySelectorAll(".field-error").forEach((element) => { element.hidden = true; }); +})); + +showScreen(location.hash.slice(1)); + +// The optional stdlib server reloads the page when HTML/CSS/JS files change. +if (location.protocol === "http:" && ["127.0.0.1", "localhost"].includes(location.hostname)) { + let version; + let checking = false; + setInterval(async () => { + if (checking || document.hidden) return; + checking = true; + try { + const response = await fetch("/__version", { cache: "no-store" }); + if (!response.ok || !response.headers.get("content-type")?.includes("application/json")) return; + const current = (await response.json()).version; + if (version !== undefined && current !== version) location.reload(); + version = current; + } catch { /* The preview also works without the local server. */ } + finally { checking = false; } + }, 1000); +} diff --git a/k8s-ng/core/keycloak/preview/background.js b/k8s-ng/core/keycloak/preview/background.js new file mode 120000 index 0000000..ab278aa --- /dev/null +++ b/k8s-ng/core/keycloak/preview/background.js @@ -0,0 +1 @@ +../themes/hexor-lab/login/resources/js/background.js \ No newline at end of file diff --git a/k8s-ng/core/keycloak/preview/index.html b/k8s-ng/core/keycloak/preview/index.html new file mode 100644 index 0000000..7985c89 --- /dev/null +++ b/k8s-ng/core/keycloak/preview/index.html @@ -0,0 +1,62 @@ + + + + + + + + Hexor · Access terminal + + + + + + + + + + +
+
+
+
auth@hexor
+
+

Sign in

+ + + +
+
+
+ +
Forgot password?
+ +
OR CONTINUE WITH
+
+
+ + + + + + +
+
+
+
+ + + + diff --git a/k8s-ng/core/keycloak/preview/preview.css b/k8s-ng/core/keycloak/preview/preview.css new file mode 100644 index 0000000..36231b7 --- /dev/null +++ b/k8s-ng/core/keycloak/preview/preview.css @@ -0,0 +1,11 @@ +.preview { border-top: 1px solid #ffffff0c; padding: 18px 24px 21px; color: var(--muted); font-size: 12px; background: var(--bg); } +.preview-heading, .preview-controls { max-width: 760px; margin: 0 auto; display: flex; gap: 15px; flex-wrap: wrap; justify-content: space-between; align-items: center; } +.preview-heading { margin-bottom: 12px; } +.preview-label { color: var(--text); letter-spacing: 1px; font-size: 11px; } +.preview-group { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; } +.preview button, .preview select { border: 1px solid var(--border); border-radius: 0; background: transparent; padding: 5px 9px; color: var(--muted); font-size: 13px; } +.preview select { background: var(--bg); } +.preview button[aria-pressed="true"] { color: var(--accent); border-color: var(--accent); } +.preview button:hover { color: var(--text); } +.preview .checkbox { margin-left: 8px; } +@media (max-width: 580px) { .preview { padding: 16px; } .preview-controls { gap: 12px; } } diff --git a/k8s-ng/core/keycloak/preview/realm.json b/k8s-ng/core/keycloak/preview/realm.json new file mode 100644 index 0000000..e6636cd --- /dev/null +++ b/k8s-ng/core/keycloak/preview/realm.json @@ -0,0 +1,49 @@ +{ + "realm": "hexor-preview", + "enabled": true, + "displayName": "Hexor Lab", + "loginTheme": "hexor-lab", + "sslRequired": "none", + "registrationAllowed": false, + "resetPasswordAllowed": true, + "rememberMe": true, + "loginWithEmailAllowed": true, + "clients": [ + { + "clientId": "theme-preview", + "enabled": true, + "publicClient": true, + "standardFlowEnabled": true, + "redirectUris": ["http://127.0.0.1:8765/*"], + "webOrigins": ["http://127.0.0.1:8765"] + } + ], + "users": [ + { + "username": "demo", + "enabled": true, + "emailVerified": true, + "email": "demo@example.test", + "firstName": "Demo", + "lastName": "User", + "credentials": [{"type": "password", "value": "demo-password", "temporary": false}] + }, + { + "username": "demo-otp", + "enabled": true, + "emailVerified": true, + "email": "demo-otp@example.test", + "firstName": "Demo", + "lastName": "OTP", + "credentials": [ + {"type": "password", "value": "demo-password", "temporary": false}, + { + "type": "otp", + "userLabel": "Local preview", + "secretData": "{\"value\":\"local-preview-secret\"}", + "credentialData": "{\"subType\":\"totp\",\"digits\":6,\"counter\":0,\"period\":30,\"algorithm\":\"HmacSHA1\"}" + } + ] + } + ] +} diff --git a/k8s-ng/core/keycloak/preview/run-keycloak.sh b/k8s-ng/core/keycloak/preview/run-keycloak.sh new file mode 100755 index 0000000..909d0cb --- /dev/null +++ b/k8s-ng/core/keycloak/preview/run-keycloak.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Run the real login theme in an isolated, disposable Keycloak 26.5.6 instance. +# Test users: demo / demo-password and demo-otp / demo-password. +# The existing deployment, Terraform and production realm are not involved. +preview_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +theme_dir="$(cd -- "$preview_dir/../themes/hexor-lab" && pwd)" + +printf '%s\n' \ + 'After Keycloak starts, open:' \ + 'http://127.0.0.1:8780/realms/hexor-preview/protocol/openid-connect/auth?client_id=theme-preview&redirect_uri=http%3A%2F%2F127.0.0.1%3A8765%2F&response_type=code&scope=openid' \ + 'Test account: demo / demo-password' + +docker run --rm --name hexor-lab-preview \ + --publish 127.0.0.1:8780:8080 \ + --mount "type=bind,source=$theme_dir,target=/opt/keycloak/themes/hexor-lab,readonly" \ + --mount "type=bind,source=$preview_dir/realm.json,target=/opt/keycloak/data/import/hexor-preview.json,readonly" \ + quay.io/keycloak/keycloak:26.5.6 \ + start-dev --import-realm \ + --spi-theme--static-max-age=-1 \ + --spi-theme--cache-themes=false \ + --spi-theme--cache-templates=false diff --git a/k8s-ng/core/keycloak/preview/serve.py b/k8s-ng/core/keycloak/preview/serve.py new file mode 100644 index 0000000..92a5b97 --- /dev/null +++ b/k8s-ng/core/keycloak/preview/serve.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Local theme preview: python3 serve.py [--port 8765]. No dependencies.""" + +import argparse +from functools import partial +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +import json +from pathlib import Path +from urllib.parse import urlsplit + +ROOT = Path(__file__).resolve().parent +ASSETS = ("index.html", "styles.css", "preview.css", "app.js", "background.js") + + +class PreviewHandler(SimpleHTTPRequestHandler): + def end_headers(self): + self.send_header("Cache-Control", "no-store") + super().end_headers() + + def do_GET(self): + if urlsplit(self.path).path == "/__version": + version = ":".join(str((ROOT / name).stat().st_mtime_ns) for name in ASSETS) + body = json.dumps({"version": version}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + else: + super().do_GET() + + def log_message(self, format, *args): + if not self.path.startswith("/__version"): + super().log_message(format, *args) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--port", type=int, default=8765) + args = parser.parse_args() + server = ThreadingHTTPServer(("127.0.0.1", args.port), partial(PreviewHandler, directory=str(ROOT))) + print(f"Local: http://127.0.0.1:{server.server_port}", flush=True) + print("Edit index.html, styles.css or app.js; the browser reloads on save. Ctrl+C to stop.", flush=True) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() diff --git a/k8s-ng/core/keycloak/preview/styles.css b/k8s-ng/core/keycloak/preview/styles.css new file mode 120000 index 0000000..8c28443 --- /dev/null +++ b/k8s-ng/core/keycloak/preview/styles.css @@ -0,0 +1 @@ +../themes/hexor-lab/login/resources/css/hexor.css \ No newline at end of file diff --git a/k8s-ng/core/keycloak/themes/hexor-lab/LICENSE.txt b/k8s-ng/core/keycloak/themes/hexor-lab/LICENSE.txt new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/k8s-ng/core/keycloak/themes/hexor-lab/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/k8s-ng/core/keycloak/themes/hexor-lab/login/resources/css/hexor.css b/k8s-ng/core/keycloak/themes/hexor-lab/login/resources/css/hexor.css new file mode 100644 index 0000000..31645c4 --- /dev/null +++ b/k8s-ng/core/keycloak/themes/hexor-lab/login/resources/css/hexor.css @@ -0,0 +1,83 @@ +/* Start here: all palette and layout settings are ordinary CSS variables. */ +:root { + --bg: #0c0f0d; + --surface: #111612; + --input-bg: #0c110d; + --accent: #b9ef70; + --accent-ink: #121c09; + --text: #e1e7dd; + --muted: #8b9989; + --border: #354031; + --error: #ffa595; + --font: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + --terminal-width: 560px; + --code-opacity: .16; +} +* { box-sizing: border-box; } +[hidden] { display: none !important; } +body { margin: 0; min-height: 100svh; color: var(--text); background: var(--bg); font: 16px/1.6 var(--font); display: flex; flex-direction: column; isolation: isolate; } +#code-background { position: fixed; inset: 0; width: 100%; height: 100%; pointer-events: none; z-index: -1; opacity: var(--code-opacity); mask-image: linear-gradient(to bottom, transparent, #000 15%, #000 85%, transparent); } +button, input, select { font: inherit; } +button, select { cursor: pointer; } +button, a, input, select { -webkit-tap-highlight-color: transparent; } +button, a, select { touch-action: manipulation; } +a { color: var(--muted); text-underline-offset: 4px; text-decoration-thickness: 1px; } +a:hover { color: var(--accent); } +button:focus-visible, a:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; } +::selection { background: var(--accent); color: var(--accent-ink); } +.site-header { padding: 27px 38px; display: flex; justify-content: space-between; gap: 20px; align-items: center; } +.wordmark { font: 500 clamp(26px, 4vw, 34px)/1.2 var(--font); letter-spacing: -1px; text-decoration: none; color: var(--text); } +.page-main { flex: 1; display: grid; place-items: center; padding: 35px 24px 64px; } +.terminal-wrap { width: min(100%, var(--terminal-width)); } +.terminal { background: var(--surface); border: 1px solid var(--border); box-shadow: 0 24px 90px #0004; } +.titlebar { display: flex; padding: 12px 20px; border-bottom: 1px solid var(--border); font-size: 14px; color: var(--muted); } +.titlebar span span { color: var(--accent); margin-right: 9px; } +.terminal-body { padding: 30px 36px 34px; } +.heading { margin: 0 0 28px; } +h1 { margin: 0 0 8px; font-size: clamp(28px, 6vw, 34px); line-height: 1.3; font-weight: 500; letter-spacing: -1px; } +.cursor { color: var(--accent); animation: blink 1.2s step-end infinite; } +@keyframes blink { 50% { opacity: 0; } } +.description { color: var(--muted); margin: 0; font-size: 15px; } +.field { margin-bottom: 21px; } +.field > label { display: block; margin-bottom: 8px; font-size: 16px; } +.prompt { color: var(--accent); margin-right: 7px; } +.label-hint { color: var(--muted); font-size: 14px; } +input:not([type="checkbox"]) { width: 100%; min-height: 50px; padding: 12px 14px; background: var(--input-bg); border: 1px solid var(--border); border-radius: 0; color: var(--text); caret-color: var(--accent); outline: none; box-shadow: none; font-size: 16px; } +input:not([type="checkbox"]):hover { border-color: var(--muted); } +input:not([type="checkbox"]):focus { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); } +input::placeholder { color: var(--muted); opacity: .7; } +input[aria-invalid="true"]:not([type="checkbox"]) { border-color: var(--error); } +input:-webkit-autofill { -webkit-text-fill-color: var(--text); box-shadow: 0 0 0 1000px var(--input-bg) inset; } +.password-wrap { position: relative; } +.password-wrap input { padding-right: 72px; } +.reveal { position: absolute; right: 5px; top: 5px; bottom: 5px; padding: 0 10px; background: transparent; color: var(--muted); border: 0; font-size: 12px; letter-spacing: .5px; } +.reveal:hover { color: var(--accent); } +.form-options { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 10px; margin: 1px 0 26px; font-size: 14px; } +.checkbox { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; color: var(--muted); } +input[type="checkbox"] { width: 16px; height: 16px; margin: 0; accent-color: var(--accent); cursor: pointer; } +.primary { width: 100%; min-height: 50px; display: flex; align-items: center; justify-content: space-between; padding: 11px 16px; background: var(--accent); color: var(--accent-ink); border: 1px solid var(--accent); border-radius: 0; font-weight: bold; transition: filter .15s; } +.primary:hover { filter: brightness(1.1); } +.primary:active { transform: translateY(1px); } +.primary span:last-child { font-size: 20px; font-weight: normal; line-height: 1; } +.divider { display: flex; align-items: center; gap: 15px; margin: 24px 0 18px; color: var(--muted); font-size: 11px; letter-spacing: 1px; } +.divider::before, .divider::after { content: ""; height: 1px; background: var(--border); flex: 1; } +.alternatives { display: flex; gap: 12px; } +.secondary { display: inline-flex; justify-content: center; align-items: center; gap: 10px; min-height: 48px; padding: 10px 15px; background: transparent; color: var(--text); border: 1px solid var(--border); border-radius: 0; font-size: 16px; text-decoration: none; } +.alternatives .secondary { flex: 1; } +.secondary:hover { border-color: var(--accent); color: var(--accent); background: #ffffff03; } +.secondary > span { color: var(--muted); font-size: 16px; } +.google-icon { font-family: sans-serif; font-weight: bold; } +.notice { border-left: 2px solid var(--accent); padding: 10px 13px; background: #ffffff04; color: var(--text); font-size: 15px; margin: 0 0 22px; overflow-wrap: anywhere; } +.notice.error { border-color: var(--error); color: var(--error); } +.field-error { color: var(--error); font-size: 14px; margin: -8px 0 18px; } +.field-help { margin: 10px 0 0; color: var(--muted); font-size: 14px; } +input.otp-input { letter-spacing: 10px; font-size: 24px; padding: 15px; } +.back-link { display: block; text-align: center; margin-top: 24px; font-size: 15px; } +.success-panel { text-align: center; padding: 15px 0; } +.success-mark { color: var(--accent); font-size: 32px; } +@media (max-width: 580px) { + .site-header { padding: 20px; } + .page-main { padding: 25px 16px 40px; } + .terminal-body { padding: 24px; } +} +@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } } diff --git a/k8s-ng/core/keycloak/themes/hexor-lab/login/resources/css/keycloak.css b/k8s-ng/core/keycloak/themes/hexor-lab/login/resources/css/keycloak.css new file mode 100644 index 0000000..dec850e --- /dev/null +++ b/k8s-ng/core/keycloak/themes/hexor-lab/login/resources/css/keycloak.css @@ -0,0 +1,130 @@ +/* Adapt the native Keycloak 26.5.6 form components to the shared design. */ +html.login-pf { + color-scheme: dark; + --pf-v5-global--FontFamily--text: var(--font); + --pf-v5-global--FontFamily--heading: var(--font); + --pf-v5-global--FontFamily--monospace: var(--font); + --pf-v5-global--FontSize--md: 16px; + --pf-v5-global--Color--100: var(--text); + --pf-v5-global--Color--200: var(--muted); + --pf-v5-global--primary-color--100: var(--accent); + --pf-v5-global--primary-color--200: var(--accent); + --pf-v5-global--link--Color: var(--accent); + --pf-v5-global--link--Color--hover: var(--text); + --pf-v5-global--BackgroundColor--100: var(--surface); + --pf-v5-global--BackgroundColor--200: var(--input-bg); + --pf-v5-global--BorderColor--100: var(--border); + --pf-v5-global--danger-color--100: var(--error); + --pf-v5-global--success-color--100: var(--accent); +} +html.login-pf body#keycloak-bg { + background: var(--bg); + color: var(--text); + font: 16px/1.6 var(--font); + height: auto; +} +.hexor-heading { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; padding: 30px 36px 0; } +.hexor-title { margin: 0; font: 500 clamp(28px, 6vw, 34px)/1.3 var(--font); letter-spacing: -1px; } +.hexor-title::after { content: "_"; color: var(--accent); animation: blink 1.2s step-end infinite; } +.hexor-body { padding: 28px 36px 34px; } +.hexor-body > :last-child { margin-bottom: 0; } +.hexor-locale { max-width: 100%; } +.hexor-locale select { max-width: 180px; } +.hexor-body .pf-v5-c-form { gap: 21px; } +.hexor-body .pf-v5-c-form__group { gap: 8px; } +.hexor-body .pf-v5-c-form__group-label { padding-bottom: 0 !important; } +.hexor-body .pf-v5-c-form__label { color: var(--text); font-size: 16px; line-height: 1.6; } +.hexor-body .pf-v5-c-form__label-text::before { content: ">"; color: var(--accent); margin-inline-end: 12px; } +.hexor-body .pf-v5-c-form__helper-text { margin-top: 10px; } +.hexor-body .pf-v5-c-helper-text { gap: 10px; flex-wrap: wrap; } +.hexor-body .pf-v5-c-helper-text__item-text { color: var(--muted); font-size: 14px; } +.hexor-body .pf-v5-c-check { color: var(--muted); font-size: 14px; align-items: center; } +.hexor-body .pf-v5-c-check__label { color: var(--muted); font-size: 14px; } +.hexor-body .pf-v5-c-form-control, .hexor-locale .pf-v5-c-form-control { + background: var(--input-bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: 0; + --pf-v5-c-form-control--after--BorderBottomWidth: 0; + --pf-v5-c-form-control--before--BorderWidth: 0; + --pf-v5-c-form-control--Height: auto; +} +.hexor-body .pf-v5-c-form-control::before, +.hexor-body .pf-v5-c-form-control::after, +.hexor-locale .pf-v5-c-form-control::before, +.hexor-locale .pf-v5-c-form-control::after { display: none; } +.hexor-body .pf-v5-c-form-control:focus-within { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); } +.hexor-body .pf-v5-c-form-control.pf-m-error { border-color: var(--error); } +.hexor-body .pf-v5-c-form-control > :is(input, textarea, select), +.hexor-locale .pf-v5-c-form-control > select { + border: 0; + border-radius: 0; + padding: 12px 14px; + min-height: 50px; + background: transparent; + color: var(--text); + font: 16px/1.6 var(--font); + box-shadow: none; +} +.hexor-body .pf-v5-c-form-control > input:focus { box-shadow: none; } +.hexor-body .pf-v5-c-form-control input[readonly] { color: var(--muted); } +.hexor-body .pf-v5-c-input-group { gap: 0; } +.hexor-body .pf-v5-c-input-group__item { min-width: 0; } +.hexor-body .pf-v5-c-form-control__utilities { background: transparent; color: var(--muted); } +.hexor-body .pf-v5-c-button { + min-height: 48px; + border: 1px solid var(--border); + border-radius: 0; + padding: 10px 15px; + background: transparent; + color: var(--text); + font: 16px/1.6 var(--font); + box-shadow: none; + text-decoration: none; + white-space: normal; +} +.hexor-body .pf-v5-c-button::after { display: none; } +.hexor-body .pf-v5-c-button:hover { border-color: var(--accent); color: var(--accent); } +.hexor-body .pf-v5-c-button:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; } +.hexor-body .pf-v5-c-button.pf-m-primary { + width: 100%; + min-height: 50px; + background: var(--accent); + border-color: var(--accent); + color: var(--accent-ink); + font-weight: bold; +} +.hexor-body .pf-v5-c-button.pf-m-primary:hover { filter: brightness(1.1); } +.hexor-body .pf-v5-c-button:disabled, .hexor-body .pf-m-aria-disabled { opacity: .5; pointer-events: none; } +.hexor-body .pf-v5-c-button.pf-m-control { min-width: 46px; height: 100%; color: var(--muted); background: var(--input-bg); } +.hexor-body .pf-v5-c-button.pf-m-link { min-height: auto; border-color: transparent; padding-inline: 0; color: var(--accent); } +.hexor-body .pf-v5-c-form__actions { padding-top: 0 !important; } +.hexor-body a:not(.pf-v5-c-button) { color: var(--muted); } +.hexor-body a:not(.pf-v5-c-button):hover { color: var(--accent); } +.hexor-body .pf-v5-c-alert { border: 0; border-inline-start: 2px solid var(--accent); padding: 10px 13px; background: #ffffff04; color: var(--text); box-shadow: none; } +.hexor-body .pf-v5-c-alert.pf-m-danger { border-color: var(--error); } +.hexor-body .pf-v5-c-alert__icon { display: none; } +.hexor-body .pf-v5-c-alert__title { color: var(--text); font: 15px/1.6 var(--font); } +.hexor-body .pf-v5-c-alert.pf-m-danger .pf-v5-c-alert__title, +.hexor-body .pf-v5-c-helper-text__item.pf-m-error .pf-v5-c-helper-text__item-text { color: var(--error); } +.hexor-footer:empty { display: none; } +.hexor-footer-band { padding: 0; margin-top: 22px; color: var(--muted); font-size: 14px; } +.hexor-divider-label { display: flex; align-items: center; gap: 15px; margin: 24px 0 18px; font-size: 11px; letter-spacing: 1px; } +.hexor-divider-label::before, .hexor-divider-label::after { content: ""; flex: 1; height: 1px; background: var(--border); } +#kc-social-providers ul { display: flex; flex-wrap: wrap; gap: 12px; padding: 0; margin: 0; list-style: none; } +#kc-social-providers li { flex: 1 1 160px; padding: 0; } +#kc-social-providers a { display: flex; justify-content: center !important; align-items: center; gap: 10px; } +#kc-social-providers svg { width: 20px; height: 20px; flex-shrink: 0; fill: currentColor; } +#kc-social-providers svg:not(.google) { filter: none; } +#kc-social-providers .pf-v5-u-m-auto { margin: 0 !important; } +#kc-select-try-another-way-form { margin-top: 20px; } +#kc-info-wrapper, #kc-registration { color: var(--muted); font-size: 14px; } +#kc-otp-login-form #otp { font-size: 24px; letter-spacing: 8px; } +.hexor-body :is(.pf-v5-c-tile, .pf-v5-c-data-list__item, .pf-v5-c-panel) { background: var(--input-bg); color: var(--text); border: 1px solid var(--border); box-shadow: none; } +.hexor-body .pf-v5-c-tile.pf-m-selected { border-color: var(--accent); } +.hexor-body img, .hexor-body canvas, .hexor-body svg { max-width: 100%; } +.hexor-body pre { overflow-wrap: anywhere; white-space: pre-wrap; } +@media (max-width: 580px) { + .hexor-heading { padding: 24px 24px 0; } + .hexor-body { padding: 24px; } +} diff --git a/k8s-ng/core/keycloak/themes/hexor-lab/login/resources/img/favicon.svg b/k8s-ng/core/keycloak/themes/hexor-lab/login/resources/img/favicon.svg new file mode 100644 index 0000000..4789d12 --- /dev/null +++ b/k8s-ng/core/keycloak/themes/hexor-lab/login/resources/img/favicon.svg @@ -0,0 +1 @@ +>_ diff --git a/k8s-ng/core/keycloak/themes/hexor-lab/login/resources/js/background.js b/k8s-ng/core/keycloak/themes/hexor-lab/login/resources/js/background.js new file mode 100644 index 0000000..8e0e630 --- /dev/null +++ b/k8s-ng/core/keycloak/themes/hexor-lab/login/resources/js/background.js @@ -0,0 +1,126 @@ +/* Decorative code stream. Brightness: --code-opacity in css/hexor.css. */ +(() => { + "use strict"; + const canvas = document.querySelector("#code-background"); + const context = canvas?.getContext("2d"); + if (!context) return; + + const source = [ + "use std::sync::Arc;", + "use tokio::net::TcpListener;", + "", + "async fn main() -> Result<()> {", + " let config = Config::load()?;", + " let state = Arc::new(State::new());", + " let router = routes(state.clone());", + "", + " server::bind(config.address)", + " .serve(router)", + " .await?;", + " Ok(())", + "}", + "", + "impl Session {", + " pub fn verify(&self) -> bool {", + " self.expires_at > clock::now()", + " && self.signature.is_valid()", + " }", + "}", + "", + "fn routes(state: Arc) -> Router {", + " Router::new()", + ' .route("/", get(index))', + ' .route("/session", post(create))', + " .with_state(state)", + "}", + "", + "match identity.authenticate().await {", + " Ok(session) => session.commit(),", + " Err(error) => trace!(?error),", + "}", + "", + "let transport = Transport::builder()", + " .with_timeout(Duration::from_secs(30))", + " .with_keepalive(true)", + " .connect(endpoint).await?;", + "", + ]; + const motion = window.matchMedia("(prefers-reduced-motion: reduce)"); + const lineHeight = 24; + let streams = []; + let width = 0; + let height = 0; + let color; + + function updateColor() { + color = getComputedStyle(document.documentElement).getPropertyValue("--accent").trim(); + } + + function resize() { + width = window.innerWidth; + height = window.innerHeight; + const ratio = Math.min(window.devicePixelRatio || 1, 1.5); + canvas.width = Math.round(width * ratio); + canvas.height = Math.round(height * ratio); + context.setTransform(ratio, 0, 0, ratio, 0, 0); + context.font = '13px "SFMono-Regular", Consolas, "Liberation Mono", monospace'; + const columns = Math.max(1, Math.ceil(width / 470)); + const rows = Math.ceil(height / lineHeight); + streams = Array.from({ length: columns }, (_, column) => { + const start = Math.floor(Math.random() * source.length); + const count = Math.max(4, rows - 7 + Math.floor(Math.random() * 5)); + return { + x: column * (width / columns) + 24, + y: 44 - Math.random() * 80, + lines: Array.from({ length: count }, (_, i) => source[(start + i) % source.length]), + next: (start + count) % source.length, + typed: Math.random() * 12, + pause: 0, + number: 20 + start, + speed: .55 + Math.random() * .6, + }; + }); + draw(false); + } + + function draw(advance) { + context.clearRect(0, 0, width, height); + context.fillStyle = color; + for (const stream of streams) { + const current = source[stream.next]; + if (advance) { + if (stream.typed < current.length) stream.typed += stream.speed; + else if (++stream.pause > 8) { + stream.lines.push(current); + stream.next = (stream.next + 1) % source.length; + stream.typed = 0; + stream.pause = 0; + } + // Scroll gently as new lines reach the bottom of the viewport. + const overflow = stream.y + (stream.lines.length + 2) * lineHeight - height; + if (overflow > 0) stream.y -= Math.min(1.5, overflow); + if (stream.y < -lineHeight) { + stream.lines.shift(); + stream.y += lineHeight; + stream.number++; + } + } + stream.lines.concat(current.slice(0, Math.floor(stream.typed))).forEach((line, i) => { + const y = stream.y + i * lineHeight; + context.globalAlpha = .3; + context.fillText(String(stream.number + i).padStart(3, "0"), stream.x, y); + context.globalAlpha = i === stream.lines.length ? .95 : .6; + context.fillText(line, stream.x + 42, y); + }); + } + context.globalAlpha = 1; + } + + updateColor(); + resize(); + window.addEventListener("resize", resize); + // Ten frames per second is enough for slow typing; no work in hidden tabs. + setInterval(() => { + if (!document.hidden && !motion.matches) draw(true); + }, 100); +})(); diff --git a/k8s-ng/core/keycloak/themes/hexor-lab/login/template.ftl b/k8s-ng/core/keycloak/themes/hexor-lab/login/template.ftl new file mode 100644 index 0000000..d2fade8 --- /dev/null +++ b/k8s-ng/core/keycloak/themes/hexor-lab/login/template.ftl @@ -0,0 +1,247 @@ +<#-- Based on Keycloak 26.5.6 keycloak.v2/login/template.ftl (Apache-2.0). --> +<#import "field.ftl" as field> +<#import "footer.ftl" as loginFooter> +<#macro username> + <#assign label> + <#if !realm.loginWithEmailAllowed>${msg("username")}<#elseif !realm.registrationEmailAsUsername>${msg("usernameOrEmail")}<#else>${msg("email")} + + <@field.group name="username" label=label> +
+
+ + + +
+
+ +
+
+ + + +<#macro registrationLayout bodyClass="" displayInfo=false displayMessage=true displayRequiredFields=false> + + dir="${(locale.rtl)?then('rtl','ltr')}"> + + + + + + + + <#if properties.meta?has_content> + <#list properties.meta?split(' ') as meta> + + + + ${msg("loginTitle",(realm.displayName!''))} + + <#if properties.stylesCommon?has_content> + <#list properties.stylesCommon?split(' ') as style> + + + + <#if properties.styles?has_content> + <#list properties.styles?split(' ') as style> + + + + + <#if properties.scripts?has_content> + <#list properties.scripts?split(' ') as script> + + + + <#if scripts??> + <#list scripts as script> + + + + + + + <#if authenticationSession??> + + + + + + + + +
+
+
+
auth@hexor
+
+

<#nested "header">

+ <#if realm.internationalizationEnabled && locale.supported?size gt 1> +
+
+ + + + + + +
+
+ +
+
+ <#if !(auth?has_content && auth.showUsername() && !auth.showResetCredentials())> + <#if displayRequiredFields> +
+
+ + * ${msg("requiredFields")} + +
+
+ + <#else> + <#if displayRequiredFields> +
+
+ + * ${msg("requiredFields")} + +
+
+ <#nested "show-username"> + <@username /> +
+
+ <#else> +
+ <#nested "show-username"> + <@username /> +
+ + + + <#-- 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??)> +
+
+ <#if message.type = 'success'> + <#if message.type = 'warning'> + <#if message.type = 'error'> + <#if message.type = 'info'> +
+ +
+ + + <#nested "form"> + + <#if auth?has_content && auth.showTryAnotherWayLink()> +
+ + + ${msg("doTryAnotherWay")} + +
+ + +
+ <#nested "socialProviders"> + + <#if displayInfo> +
+
+ <#nested "info"> +
+
+ +
+
+ +
+ <@loginFooter.content/> +
+
+
+
+ + + diff --git a/k8s-ng/core/keycloak/themes/hexor-lab/login/theme.properties b/k8s-ng/core/keycloak/themes/hexor-lab/login/theme.properties new file mode 100644 index 0000000..f458296 --- /dev/null +++ b/k8s-ng/core/keycloak/themes/hexor-lab/login/theme.properties @@ -0,0 +1,18 @@ +parent=keycloak.v2 +import=common/keycloak +styles=css/styles.css css/hexor.css css/keycloak.css +scripts=js/background.js +darkMode=false + +# Own layout; inherit Keycloak's form fields, authentication and translations. +kcLogin=hexor-page +kcLoginContainer=terminal-wrap +kcLoginMain=hexor-main +kcLoginMainHeader=hexor-heading +kcLoginMainTitle=hexor-title +kcLoginMainBody=hexor-body +kcLoginMainHeaderUtilities=hexor-locale +kcLoginMainFooter=hexor-footer +kcLoginMainFooterBand=hexor-footer-band +kcLoginMainFooterBandItem=hexor-footer-item +kcLoginMainFooterHelperText=hexor-divider-label diff --git a/k8s-ng/core/keycloak/values.yaml b/k8s-ng/core/keycloak/values.yaml index f50e756..61d576b 100644 --- a/k8s-ng/core/keycloak/values.yaml +++ b/k8s-ng/core/keycloak/values.yaml @@ -28,17 +28,27 @@ extraEnv: | value: "-Djgroups.dns.query=keycloak-keycloakx-headless.keycloak.svc" extraVolumes: | - - name: hacker-theme + - name: hexor-lab-theme configMap: - name: keycloak-hacker-theme + name: keycloak-hexor-lab-theme + items: + - key: theme.properties + path: theme.properties + - key: template.ftl + path: template.ftl + - key: hexor.css + path: resources/css/hexor.css + - key: keycloak.css + path: resources/css/keycloak.css + - key: background.js + path: resources/js/background.js + - key: favicon.svg + path: resources/img/favicon.svg extraVolumeMounts: | - - name: hacker-theme - mountPath: /opt/keycloak/themes/hacker/login/theme.properties - subPath: theme.properties - - name: hacker-theme - mountPath: /opt/keycloak/themes/hacker/login/css/hacker.css - subPath: hacker.css + - name: hexor-lab-theme + mountPath: /opt/keycloak/themes/hexor-lab/login + readOnly: true dbchecker: enabled: true