livrare lot 2

This commit is contained in:
EVOTECH IT SRL 2026-07-10 03:39:53 -07:00
commit 8ecc78e729
763 changed files with 164593 additions and 0 deletions

View file

@ -0,0 +1,362 @@
# didiKeycloak - Index
> **Deployment LOCAL (activ)**: Keycloak ruleaza ca un singur container `didi-keycloak` pe masina de deployment. Nu exista cluster SSO / Swarm.
>
> - Imagine: `quay.io/keycloak/keycloak:26.0`, pornit cu `start-dev --import-realm`.
> - Port: `28080` (host) -> `8080` (container), servit sub calea relativa `/auth` (`KC_HTTP_RELATIVE_PATH=/auth`).
> - `KC_HOSTNAME_STRICT=false`, `KC_PROXY_HEADERS=xforwarded` — hostname derivat din headerele proxy-ului din fata.
> - **Doua realm-uri** importate din `realm-import/`: `didi-clients` (useri finali) + `didi-admins` (operatori: admin / moderator / senior_moderator).
> - Temele custom sunt bind-mount-uite din folderul acesta in `/opt/keycloak/themes/`.
> - Master credentials: `admin/admin123` (`KEYCLOAK_ADMIN` / `KEYCLOAK_ADMIN_PASSWORD`).
Serviciul de autentificare si autorizare al platformei DIDI. Bazat pe Keycloak, gestioneaza utilizatori, roluri, grupuri, clienti OAuth2 si token-uri JWT. Include teme custom de login si template-uri email in romana.
**Imagine**: quay.io/keycloak/keycloak:26.0 (container local `didi-keycloak`)
**Container**: didi-keycloak (activ, pe masina de deployment)
**Port**: 28080 (host) -> 8080 (container), sub `/auth`
**Realm-uri**: `didi-clients` (useri) + `didi-admins` (operatori)
**Baza de date**: PostgreSQL `didi-postgres:5432/DIDI` (`KC_DB=postgres`, user `bos_interface`)
---
## Ce face
1. **Autentificare OAuth2/OIDC** -- login, logout, refresh token, SSO
2. **Management utilizatori** -- creare, roluri, grupuri, tier-uri
3. **Emitere token-uri JWT** -- access token (10 min), refresh token, SSO session (2h)
4. **Validare JWT** -- Kong valideaza token-urile emise de Keycloak
5. **Securitate cont** -- brute force (lockout dupa 5 incercari esuate), MFA TOTP, password policy
6. **Deep linking mobil** -- redirectare catre app mobila dupa verificare email
7. **Teme custom** -- login page dark purple, emailuri in romana
---
## Structura fisierelor
```
realm-import/
didi-clients-realm.json -- Configurare completa realm (clienti, roluri, grupuri, utilizatori)
themes/
didi-clients-theme/ -- Tema principala (dark purple)
login/
theme.properties -- Configurare tema login
register.ftl -- Formular inregistrare
login-reset-password.ftl -- Resetare parola
login-verify-email.ftl -- Pagina verificare email
info.ftl -- Routing mobil/web dupa actiuni
register-commons.ftl -- Macro acceptare termeni
messages/
messages_en.properties -- Etichete UI engleza
resources/
css/login.css -- Stil dark purple (784 linii)
js/placeholders.js -- Placeholders formulare
email/
theme.properties -- Configurare tema email
html/
email-verification.ftl -- Template verificare email (romana, dark theme)
executeActions.ftl -- Template actiuni (dark purple gradient)
text/
email-verification.ftl -- Versiune text plain
didi-ai-theme/ -- Tema alternativa (white, blue accents)
login/
theme.properties
resources/
css/login.css
img/logo.png
didi-backend-theme/ -- Tema backend (white, "didi - Backend")
login/
theme.properties
resources/
css/login.css
img/logo.png
```
Zero cod custom backend. Doar configurare realm JSON + teme FreeMarker/CSS.
---
## Clienti OAuth2 (4)
| Client ID | Tip | Scop | Flow-uri | PKCE |
|-----------|-----|------|----------|------|
| didi-web-app | Public | Frontend web utilizatori | Standard + Direct Access | nu |
| admin-dashboard | Public | Dashboard admin React | Standard + Direct Access | S256 |
| orchestrator-api | Confidential | Serviciu backend orchestrator | Direct Access + Service Account | nu |
| kong-api-gateway | Bearer Only | Gateway JWT validation | Service Account only | nu |
### didi-web-app
- Redirect URIs: localhost:3001, localhost:5173, localhost:13001, localhost:33001 (+ 127.0.0.1)
- Web Origins: aceleasi + wildcard
- Scopes: web-origins, acr, profile, roles, email
### admin-dashboard
- Root URL: http://localhost:13003
- PKCE: S256 (obligatoriu)
- Redirect URIs: localhost:13003, localhost:3003, localhost:33003, localhost:33001, localhost:3001, 127.0.0.1:13003, 127.0.0.1:3003, 127.0.0.1:33003, 127.0.0.1:33001, 10.11.50.11:33003, 10.11.50.11:3003
- Post Logout: localhost:13003, localhost:3003, localhost:33003, 10.11.50.11:33003
### orchestrator-api
- Secret: nmmImrmPAcADuPh-ZTqLY7GDhCAjfXsolDOM6TxZbHg
- Service Account: activat
- Bearer Only: implicit (confidential)
### kong-api-gateway
- Secret: Fu1rJ8QsjCj4j4_qZiMXyx6Ewo3xC2ik7X5m_MvSLOE
- Bearer Only: da (nu face login, doar valideaza)
- Service Account: activat
---
## Roluri (doua realm-uri)
Operatorii (admin / moderator / senior_moderator) traiesc in realm-ul **`didi-admins`**; realm-ul **`didi-clients`** contine doar capabilitati de user si tier-uri de abonament.
### Realm `didi-admins` (operatori)
| Rol | Scop |
|-----|------|
| admin | Acces complet la platforma + admin dashboard |
| moderator | HIL moderator -- poate revendica si rezolva intrari din coada (admin dashboard /moderation) |
| senior_moderator | Senior HIL moderator -- poate escalada si forta gold atom in brain |
### Realm `didi-clients` (useri finali)
| Rol | Scop |
|-----|------|
| viewer | Poate vizualiza rezultate analize |
| analyst | Poate crea si gestiona analize |
| api_user | Poate accesa endpoint-uri API |
| free_tier | Privilegii tier gratuit |
| paid_tier | Privilegii tier platit |
| enterprise_tier | Privilegii tier enterprise |
Roluri implicite la inregistrare (didi-clients): viewer + free_tier
---
## Grupuri (6)
| Grup | Roluri | Tier | Limita zilnica | Rate limit |
|------|--------|------|----------------|------------|
| free-users | free_tier, viewer, api_user | free | 10 | 10/min |
| paid-users | paid_tier, viewer, analyst, api_user | paid | 100 | 60/min |
| enterprise-users | enterprise_tier, viewer, analyst, api_user | enterprise | nelimitat | 600/min |
| administrators | admin, analyst, viewer, api_user, enterprise_tier | admin | nelimitat | nelimitat |
| Grup | Roluri | Scop |
|------|--------|------|
| moderators-team | moderator | HIL review staff |
| senior-moderators-team | moderator + senior_moderator | Lead moderators with brain gold-promotion authority |
Atributele de grup (tier, daily_limit, rate_limit) sunt disponibile in token-ul JWT si pot fi folosite de Kong/backend pentru rate limiting.
---
## Acces admin dashboard
| Pagina admin dashboard | viewer / paid_tier / etc | moderator | senior_moderator | admin |
|---|---|---|---|---|
| /admin/* (any) | 403 (Unauthorized page -> public app) | Dashboard + History + Moderation | same + force_gold_brain | tot |
| /users, /framework, /llm-components, /providers | nu | nu | nu | da |
| /history | nu | da | da | da |
| /moderation/* | nu | da | da | da |
Note: `viewer` este rolul implicit asignat la toate signup-urile (`defaultRoles: [viewer, free_tier]`). End-userii (clientii) primesc acest rol; ei NU vad niciodata admin dashboard.
---
## Utilizatori pre-configurati (5)
| Email | Parola | Grup | Rol principal |
|-------|--------|------|---------------|
| admin@didi.local | admin123 | administrators | admin |
| demo@didi.local | Demo123! | free-users | viewer |
| free@didi.local | password123 | free-users | free_tier |
| paid@didi.local | password123 | paid-users | paid_tier |
| enterprise@didi.local | password123 | enterprise-users | enterprise_tier |
Toti au emailVerified: true. Parolele nu sunt temporare.
---
## Setari token
| Parametru | Valoare |
|-----------|---------|
| Access Token Lifespan | 600s (10 minute) |
| Access Token Implicit | 900s (15 minute) |
| SSO Session Idle | 7200s (2 ore) |
| SSO Session Max | 86400s (24 ore) |
| Algoritm semnatura | RS256 |
---
## Securitate
Aplicata pe **ambele realm-uri** (`didi-clients` + `didi-admins`).
### Brute force protection
- Activat (`bruteForceProtected: true`)
- Max incercari esuate: 5 (`failureFactor`)
- Timp asteptare: 60s (increment) / min quick-login wait 60s / quick-login check 1000ms
- Max wait: 900s (15 minute)
- Fereastra glisanta: 43200s (12 ore)
- Lockout permanent: dezactivat
### MFA / TOTP (livrabil Lot 2)
- Politica OTP: `otpPolicyType=totp` (HmacSHA1, 6 cifre, perioada 30s) — pe ambele realm-uri.
- Required action `CONFIGURE_TOTP` **enabled** pe realm-ul `didi-admins` (operatorii sunt fortati sa configureze TOTP; userii noi de admin primesc `CONFIGURE_TOTP` in `requiredActions` la prima logare, alaturi de `UPDATE_PASSWORD`).
- Realm-ul `didi-clients` are politica TOTP configurata (MFA disponibil pentru enrolment).
### Password policy (ambele realm-uri)
```
length(10) and digits(1) and upperCase(1) and lowerCase(1) and notUsername and passwordHistory(3)
```
Minim 10 caractere, cel putin o cifra, o majuscula, o minuscula, parola != username, fara reutilizarea ultimelor 3 parole.
### Setari realm
- Inregistrare: dezactivata (registrationAllowed: false)
- Login cu email: da
- Email ca username: da
- Verificare email: dezactivata (verifyEmail: false)
- Editare username: nu
- Emailuri duplicate: nu
- Remember me: da
- Reset parola: da
---
## Teme
### didi-clients-theme (principala, dark purple)
- Background: #050510 (foarte inchis)
- Accent: #A855F7 -> #7C3AED -> #6D28D9 (gradient purple)
- Card: glassmorphism (backdrop blur, border semi-transparent)
- Logo: "didi" (48px, font Outfit)
- Subtitle: "Misinformation Detection Platform"
- Font: Outfit (display) + Inter (body)
- Butoane: gradient purple cu glow la hover
- Responsive: suporta mobile (100dvh)
### didi-ai-theme (alternativa)
- Background: alb
- Accent: #0052CC (albastru)
- Subtitle: "didi - AI Platform"
### didi-backend-theme (alternativa)
- Background: alb
- Accent: #0052CC (albastru)
- Subtitle: "didi - Backend"
---
## Template-uri email
### email-verification.ftl
- Limba: romana
- Titlu: "Verifica adresa de email"
- Stil: dark purple gradient header
- URL custom: https://didi365.eu/api/auth/verify-email?key=...
- Afiseaza timpul de expirare (convertit din secunde)
- Deep link mobil: didi://email-verified, com.didi365.app://email-verified
### executeActions.ftl
- Stil: dark purple gradient
- Suporta actiuni multiple
- Deep linking mobil
### info.ftl (routing dupa actiuni)
- Detecteaza client ID (didi-mobile-app vs didi-web-app)
- Mobile: deep link cu fallback dupa 1.5-3s
- Web: redirect la /email-verified dupa 2s
- Butoane: "Deschide in aplicatie" / "Continua in browser"
---
## Fluxul de autentificare
```
Utilizator deschide aplicatia
|
v
Redirect la Keycloak login (tema didi-clients-theme)
|
v
Utilizatorul introduce email + parola
|
v
Keycloak valideaza + emite JWT (access token 10 min, refresh token)
|
v
Redirect inapoi la aplicatie cu authorization code
|
v
Aplicatia schimba codul in token-uri (PKCE pentru admin-dashboard)
|
v
Requesturi API cu Authorization: Bearer {access_token}
|
v
Kong valideaza JWT-ul (plugin jwt, consumer didi-keycloak-users, RS256, match pe iss)
|
v
Backend-ul decodeaza JWT pentru user_id/email (fara re-validare)
|
v
La fiecare 30s, aplicatia face refresh token daca expira in < 70s
```
---
## Cum comunica cu restul platformei
| Cine | Ce face | Cum |
|------|---------|-----|
| admin-dashboard | Login/logout utilizator | OAuth2 Standard Flow + PKCE |
| didi-web-app (frontend) | Login/logout utilizator | OAuth2 Standard Flow |
| Kong | Valideaza JWT pe fiecare request (RS256, match pe iss) | plugin jwt + consumer didi-keycloak-users |
| didiFramework (auth.ts) | Auto-inregistrare utilizator, Keycloak Admin API | Direct Access + Admin credentials |
| didiFramework (admin.ts) | Lista utilizatori, update emailVerified | Keycloak Admin API |
| agent-v3 | Decodeaza JWT din header (sub, email) | Doar decodare, fara validare (Kong a validat deja) |
---
## Admin API folosit de automatizari
- Admin API base: `http://localhost:28080/auth/admin/realms/{didi-clients|didi-admins}/` (Keycloak local, sub `/auth`)
- Master token via `POST /auth/realms/master/protocol/openid-connect/token` cu `client_id=admin-cli, username=admin, password=admin123`
- Folosit de fluxul de auto-inregistrare didiFramework + scripturi viitoare de automatizare.
---
## Roluri JWT in token-urile clientilor
Token-ul JWT contine acum array-ul `realm_access.roles`, parsat de agent-v3 (`req.jwtRoles`) pentru verificarile de rol pe endpoint-urile de moderare. Token-ul se reimprospateaza automat la fiecare 30s (comportament existent).
---
## Baza de date
Keycloak foloseste PostgreSQL local, aceeasi instanta ca restul platformei:
- `KC_DB=postgres`
- `KC_DB_URL=jdbc:postgresql://didi-postgres:5432/DIDI` (schema `public`)
- User: `bos_interface`
- Schema proprie Keycloak (gestionata automat)
Datele stocate: realm config, utilizatori, sesiuni, events, client sessions.
---
## Audit si evenimente
- Evenimente utilizator: activate (jboss-logging)
- Evenimente admin: activate cu detalii
- Logare: in stdout Docker (accesibil prin docker logs)
## Recent Changes
- **MFA / TOTP (livrabil Lot 2)**: `otpPolicyType=totp` pe ambele realm-uri; required action `CONFIGURE_TOTP` enabled pe `didi-admins` (operatorii sunt fortati sa configureze TOTP la prima logare, alaturi de `UPDATE_PASSWORD`).
- **Password policy** pe ambele realm-uri: `length(10) and digits(1) and upperCase(1) and lowerCase(1) and notUsername and passwordHistory(3)`.
- **Realm `didi-admins` (operatori)**: 3 roluri `admin` / `moderator` / `senior_moderator`; clienti publici `admin-dashboard` + `ai-platform-dashboard`; useri de test `moderator.test@didi.local`, `senior.moderator.test@didi.local`.
- **Realm `didi-clients` (useri finali)**: capabilitati `viewer`, `analyst`, `api_user` + tier-uri `free_tier`, `paid_tier`, `enterprise_tier`; clienti `didi-web-app`, `admin-dashboard`, `orchestrator-api`, `kong-api-gateway`.
- **Deployment local**: container unic `didi-keycloak` (`quay.io/keycloak/keycloak:26.0`, `start-dev --import-realm`), port `28080` sub `/auth`, `KC_HOSTNAME_STRICT=false`, `KC_PROXY_HEADERS=xforwarded`, DB `didi-postgres:5432/DIDI`. Fara cluster SSO / Swarm / Infinispan.

View file

@ -0,0 +1,203 @@
# Keycloak — migrat pe SSO cluster (2026-04-30)
> **TL;DR**: containerul local `keycloak` (Keycloak 22) nu mai rulează. DIDI folosește acum **SSO cluster** la `https://<sso-extern>` (Keycloak 26 HA, 3 replicas pe Dev Docker Swarm). Realm `didi-clients` migrat cu toate datele (users, clients, groups). Theme custom `didi-clients-theme` deployed pe SSO via bind mount pe nodurile Swarm.
---
## Ce era aici (înainte de 2026-04-30)
Container `keycloak` (Keycloak 22, image `quay.io/keycloak/keycloak:22.0`) definit în `backend/production/docker-compose.yml`. Single-instance pe didi12 (10.11.10.12:28000). DB pe Patroni cluster (`keycloak_db`). Hostname fix `KC_HOSTNAME_URL=https://didi365.eu/auth`.
Folosit doar de DIDI. Theme custom `didi-clients-theme` (purple gradient).
## De ce migrare
1. **Single-tenant lock-in**: Keycloak local servea doar didi365.eu. Pentru alte produse (lege365, rafai, etc.) ar fi trebuit instanțe separate sau hostname dinamic complex.
2. **Single-point-of-failure**: 1 container, 1 host. Down când didi12 down.
3. **DB password issue**: 2026-04-29 cineva a rotat parola `keycloak` user în Patroni → connection pool fail → service degraded.
4. **SSO cluster live**: 2026-04-29 Lucian a deploy-uit Keycloak 26 HA pe Dev Swarm, cu hostname public `<sso-extern>`.
## Ce e acum
### SSO Cluster
| Componentă | Detaliu |
|---|---|
| Hostname public | `<sso-extern>` (DNS public, cert Let's Encrypt valid) |
| Hostname intern | `<sso-extern-admin>` (admin URL via `KC_HOSTNAME_ADMIN`) |
| IP public | `82.79.147.181` (port-forward la Traefik intern) |
| Edge router | Traefik central (`10.11.10.171:443`) |
| Keycloak version | 26.0 (`quay.io/keycloak/keycloak:26.0`) |
| HA | 3 replicas, max 1 per node |
| Cluster | Dev Docker Swarm (`10.11.50.151-154`) |
| DB | Patroni cluster (`10.11.50.166:5000/keycloak_db`) |
| Cache | ispn (Infinispan, dns.query=tasks.keycloak) |
| Stack name | `keycloak-cluster` (`docker service ls`) |
### Hostname configuration
```yaml
KC_HOSTNAME: https://<sso-extern> # public URL (used in tokens, redirects)
KC_HOSTNAME_ADMIN: https://<sso-extern-admin> # admin endpoints (internal-only via 307 redirect)
KC_HOSTNAME_STRICT_BACKCHANNEL: false
KC_PROXY_HEADERS: xforwarded
```
Issuer in tokens: `https://<sso-extern>/realms/didi-clients`. Endpoints (no `/auth/` prefix in K26):
- `/realms/didi-clients/.well-known/openid-configuration`
- `/realms/didi-clients/protocol/openid-connect/auth`
- `/realms/didi-clients/protocol/openid-connect/token`
- `/realms/didi-clients/protocol/openid-connect/certs` (JWKS)
## Realm-uri pe SSO
- `master` — admin Keycloak (NU folosi pentru apps)
- **`didi-clients`** — DIDI customer-facing app (migrat 1:1 din local)
- `didi-admins` — DIDI admin panel (creat de Lucian, neutilizat încă)
## Theme deployment (didi-clients-theme)
Themes sunt mounted ca **bind mount** pe fiecare nod Swarm:
```yaml
mount:
type: bind
source: /var/keycloak-themes/didi-clients-theme
target: /opt/keycloak/themes/didi-clients-theme
readonly: true
```
Adăugat via `docker service update --mount-add` (nu via stack file). Pentru ca toate 3 replicas să găsească tema, fișierele trebuie pe **toate 4 nodurile** Swarm (10.11.50.151-154).
### Procedură deploy theme update
1. Pack theme local pe didi12:
```bash
cd backend/services/gateway-auth-layer/didiKeycloak/themes
tar -czf /home/admin365/didi-clients-theme.tar.gz didi-clients-theme/
```
2. Pe `dev-docker-mgr` (Swarm manager — 10.11.50.151):
```bash
scp admin365@10.11.10.12:/home/admin365/didi-clients-theme.tar.gz /tmp/
sudo tar -xzf /tmp/didi-clients-theme.tar.gz -C /var/keycloak-themes/
for n in 152 153 154; do
scp /tmp/didi-clients-theme.tar.gz admin365@10.11.50.$n:/tmp/
ssh -t admin365@10.11.50.$n 'sudo tar -xzf /tmp/didi-clients-theme.tar.gz -C /var/keycloak-themes/'
done
sudo docker service update --force keycloak-cluster_keycloak
```
3. Verify:
```bash
curl -ksm 5 https://<sso-extern>/resources/<version>/login/didi-clients-theme/css/login.css | head
```
### Theme structure (PatternFly v4 specific)
Keycloak 26 default theme (`keycloak`) folosește PatternFly v4 markup. Custom theme cu `parent=keycloak` moștenește template-urile, dar PF4 are reguli CSS specifice care necesită overrides în login.css:
- **Password input wrap** — în `<div class="pf-c-input-group">` cu eye-icon button. Necesită CSS specific pentru `.pf-c-input-group .pf-c-form-control`
- **Pseudo-element `::after` pe button** — PF4 button-uri au `<button>::after { border: ...; position: absolute }` care creează "chenarul". Trebuie killed cu `display:none !important` și `content:none !important`
Vezi `themes/didi-clients-theme/login/resources/css/login.css` secțiunile `PatternFly v4 input-group fix` și `Eye-icon button` pentru detalii.
## Cluster Kong JWT consumer
Consumer `didi-keycloak-users` în cluster Kong (`10.11.10.176:8001`) are **4 issuers acceptate** pentru tranziție smooth:
```
https://didi365.eu/auth/realms/didi-clients # legacy local Keycloak
https://<host-local>/auth/realms/didi-clients # legacy intern alias
https://<sso-extern-admin>/realms/didi-clients # SSO intern (transition)
https://<sso-extern>/realms/didi-clients # SSO public canonical (CURRENT)
```
Toate 4 au **același RSA public key** (Lucian a exportat realm-ul cu key preserved la migrare). Tokens emise acum de SSO au `iss=https://<sso-extern>/realms/didi-clients` — cluster Kong validează corect.
Vezi `didiKong/declarative/kong-cluster.yml` pentru config consumer.
## SPA configuration (didi-frontend)
`web/src/services/keycloak.service.ts`:
```typescript
const keycloakUrl =
(import.meta.env.VITE_KEYCLOAK_URL as string | undefined) ||
'https://<sso-extern>';
const keycloak = new Keycloak({
url: keycloakUrl,
realm: 'didi-clients',
clientId: 'didi-web-app',
});
```
SPA construiește toate URL-urile (auth, token, logout) relativ la `https://<sso-extern>`. Browser-ul user-ului se redirectează direct la SSO (NU prin proxy local). Cookie-urile Keycloak sunt setate pentru `<sso-extern>` domain.
## Ce a rămas local
În folder-ul ăsta (`didiKeycloak/`):
- `themes/` — sursa originală a temelor (didi-clients-theme, didi-ai-theme, didi-backend-theme). Folosită ca master pentru deployment pe SSO cluster.
- `realm-import/didi-clients-realm.json` — backup realm config (legacy, nu mai e mounted)
- `Dockerfile` — pentru imagine custom Keycloak 22 (legacy, nemai folosit)
## Volume cleanup
Volume-ul `didi-production-keycloak-data` (DB H2 local + cache) e păstrat **1 săptămână** pentru rollback safety.
```bash
# Remove după 2026-05-07:
docker volume rm didi-production-keycloak-data
```
## Rollback (în caz de probleme)
Containerul local Keycloak nu mai există. Pentru rollback:
1. **Reset DB password** (era broken pentru user `keycloak`):
```sql
ALTER USER keycloak WITH PASSWORD 'keycloak123';
```
2. **Restore docker-compose**:
```bash
git revert <commit-care-sterge-keycloak-din-compose>
cd backend/production && docker compose up -d keycloak
```
3. **Update SPA** să re-folosească local:
- În `frontend/web/src/services/keycloak.service.ts`, schimbă URL la `${origin}/auth`
- Rebuild + redeploy didi-frontend
4. **Restore /auth proxy** în `frontend/nginx-default.conf` (la cluster Kong sau local Keycloak)
## Linkuri rapide
- SSO public: <https://<sso-extern>/admin/master/console/> (admin: admin365 / parolă din credentials)
- SSO intern: `https://<sso-extern-admin>/admin/` (via /etc/hosts → 10.11.10.171, doar intern)
- Cluster setup repo: `landingzone/keycloak-sso/` (`git.finesynergy.eu/lucian/landingzone`)
- Stack file: pe `dev-docker-mgr` (Lucian) sau în repo
## Status
- ✅ Migrare aplicată: 2026-04-30
- ✅ Container local oprit + șters
- ✅ Service definition removed din docker-compose
- ✅ Theme deployed pe SSO 3 replicas
- ✅ End-to-end auth flow verificat (login, dashboard, JWT validare prin cluster Kong)
- ⏳ Volume `didi-production-keycloak-data` păstrat până 2026-05-07
## Lecții importante
1. **`KC_HOSTNAME` schimbă tot răspunsul Keycloak** — toate URL-urile generate, issuer-ul în tokens, cookie domain. Pentru aplicații web publice trebuie hostname public DNS-resolvable (NU `.local`).
2. **Keycloak 26 a renunțat la `/auth` prefix** — endpoints sunt `/realms/...`. Theme cu `parent=keycloak` moștenește templates PF4. Custom CSS trebuie să acopere PF4 markup specific (input-group, button::after pseudo-elements).
3. **`KC_HOSTNAME_ADMIN`** — separă admin de URL-ul public (security best practice). Admin via `<sso-extern-admin>` (intern), client-facing via `<sso-extern>`.
4. **Bind mount pe Swarm** cere theme files pe **toate** nodurile (constraint `max 1 per node` cu 3 replicas → cel puțin 3 din 4 noduri rulează task). Fișiere pe 4 noduri = sigur.
5. **Realm export/import** păstrează RSA signing keys — JWT-urile vechi rămân valide după migrare. Cluster Kong consumer poate avea multiple issuers cu **același** public_key.
6. **PatternFly v4 button `::after`** — chenarul "fantomă" pe button-uri vine din pseudo-element absolut poziționat. Trebuie `content: none !important` ca să-l killezi.

View file

@ -0,0 +1,446 @@
{
"realm": "didi-clients",
"enabled": true,
"displayName": "DIDI - Misinformation Detection Platform",
"displayNameHtml": "<strong>DIDI</strong> - Misinformation Detection Platform",
"registrationAllowed": false,
"registrationEmailAsUsername": true,
"rememberMe": true,
"verifyEmail": false,
"loginWithEmailAllowed": true,
"duplicateEmailsAllowed": false,
"resetPasswordAllowed": true,
"editUsernameAllowed": false,
"bruteForceProtected": true,
"permanentLockout": false,
"maxFailureWaitSeconds": 900,
"minimumQuickLoginWaitSeconds": 60,
"waitIncrementSeconds": 60,
"quickLoginCheckMilliSeconds": 1000,
"maxDeltaTimeSeconds": 43200,
"failureFactor": 5,
"defaultSignatureAlgorithm": "RS256",
"ssoSessionIdleTimeout": 7200,
"ssoSessionMaxLifespan": 86400,
"accessTokenLifespan": 600,
"accessTokenLifespanForImplicitFlow": 900,
"roles": {
"realm": [
{
"name": "admin",
"description": "Administrator with full access",
"composite": false
},
{
"name": "analyst",
"description": "Can create and manage analyses",
"composite": false
},
{
"name": "viewer",
"description": "Can view analysis results",
"composite": false
},
{
"name": "api_user",
"description": "Can access API endpoints",
"composite": false
},
{
"name": "free_tier",
"description": "Free tier privileges",
"composite": false
},
{
"name": "paid_tier",
"description": "Paid tier privileges",
"composite": false
},
{
"name": "enterprise_tier",
"description": "Enterprise tier privileges",
"composite": false
}
]
},
"groups": [
{
"name": "free-users",
"path": "/free-users",
"attributes": {
"tier": [
"free"
],
"daily_limit": [
"10"
],
"rate_limit": [
"10"
]
},
"realmRoles": [
"free_tier",
"viewer",
"api_user"
]
},
{
"name": "paid-users",
"path": "/paid-users",
"attributes": {
"tier": [
"paid"
],
"daily_limit": [
"100"
],
"rate_limit": [
"60"
]
},
"realmRoles": [
"paid_tier",
"viewer",
"analyst",
"api_user"
]
},
{
"name": "enterprise-users",
"path": "/enterprise-users",
"attributes": {
"tier": [
"enterprise"
],
"daily_limit": [
"unlimited"
],
"rate_limit": [
"600"
]
},
"realmRoles": [
"enterprise_tier",
"viewer",
"analyst",
"api_user"
]
},
{
"name": "administrators",
"path": "/administrators",
"attributes": {
"tier": [
"admin"
],
"daily_limit": [
"unlimited"
],
"rate_limit": [
"unlimited"
]
},
"realmRoles": [
"admin",
"analyst",
"viewer",
"api_user",
"enterprise_tier"
]
}
],
"defaultRoles": [
"viewer",
"free_tier"
],
"requiredCredentials": [
"password"
],
"clients": [
{
"clientId": "didi-web-app",
"name": "DIDI Web Application",
"description": "Main web frontend for end users",
"enabled": true,
"publicClient": true,
"standardFlowEnabled": true,
"directAccessGrantsEnabled": true,
"redirectUris": [
"http://localhost:3001/*",
"http://localhost:5173/*",
"http://localhost:13001/*",
"http://localhost:33001/*",
"http://127.0.0.1:3001/*",
"http://127.0.0.1:5173/*",
"http://127.0.0.1:13001/*",
"http://127.0.0.1:33001/*"
],
"webOrigins": [
"http://localhost:3001",
"http://localhost:5173",
"http://localhost:13001",
"http://localhost:33001",
"http://127.0.0.1:3001",
"http://127.0.0.1:5173",
"http://127.0.0.1:13001",
"http://127.0.0.1:33001",
"+"
],
"defaultClientScopes": [
"web-origins",
"acr",
"profile",
"roles",
"email"
],
"optionalClientScopes": [
"address",
"phone",
"offline_access",
"microprofile-jwt"
]
},
{
"clientId": "admin-dashboard",
"name": "Admin Dashboard",
"description": "React admin dashboard application",
"rootUrl": "http://localhost:13003",
"adminUrl": "http://localhost:13003",
"baseUrl": "/",
"enabled": true,
"publicClient": true,
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": true,
"serviceAccountsEnabled": false,
"attributes": {
"pkce.code.challenge.method": "S256",
"post.logout.redirect.uris": "http://localhost:13003/* http://localhost:3003/* http://localhost:33003/* http://10.11.50.11:33003/*"
},
"redirectUris": [
"http://localhost:13003/*",
"http://localhost:3003/*",
"http://localhost:33003/*",
"http://localhost:33001/*",
"http://localhost:3001/*",
"http://127.0.0.1:13003/*",
"http://127.0.0.1:3003/*",
"http://127.0.0.1:33003/*",
"http://127.0.0.1:33001/*",
"http://10.11.50.11:33003/*",
"http://10.11.50.11:3003/*"
],
"webOrigins": [
"http://localhost:13003",
"http://localhost:3003",
"http://localhost:33003",
"http://localhost:33001",
"http://localhost:3001",
"http://127.0.0.1:13003",
"http://127.0.0.1:3003",
"http://127.0.0.1:33003",
"http://127.0.0.1:33001",
"http://10.11.50.11:33003",
"http://10.11.50.11:3003",
"+"
]
},
{
"clientId": "orchestrator-api",
"name": "Analysis Orchestrator API",
"description": "Backend orchestrator service",
"rootUrl": "http://localhost:18000",
"enabled": true,
"publicClient": false,
"serviceAccountsEnabled": true,
"standardFlowEnabled": false,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": true,
"secret": "nmmImrmPAcADuPh-ZTqLY7GDhCAjfXsolDOM6TxZbHg",
"redirectUris": [
"http://localhost:18000/*",
"http://localhost:8000/*"
],
"webOrigins": [
"http://localhost:18000",
"http://localhost:8000"
]
},
{
"clientId": "kong-api-gateway",
"name": "Kong API Gateway",
"description": "API Gateway for JWT validation",
"rootUrl": "http://localhost:18100",
"enabled": true,
"publicClient": false,
"bearerOnly": true,
"standardFlowEnabled": false,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": true,
"secret": "Fu1rJ8QsjCj4j4_qZiMXyx6Ewo3xC2ik7X5m_MvSLOE"
}
],
"users": [
{
"username": "admin@didi.local",
"email": "admin@didi.local",
"firstName": "Admin",
"lastName": "User",
"enabled": true,
"emailVerified": true,
"credentials": [
{
"type": "password",
"value": "Admin12345",
"temporary": false
}
],
"realmRoles": [
"admin",
"analyst",
"viewer",
"api_user",
"enterprise_tier"
],
"groups": [
"/administrators"
],
"attributes": {
"tier": [
"admin"
]
}
},
{
"username": "demo@didi.local",
"email": "demo@didi.local",
"firstName": "Demo",
"lastName": "User",
"enabled": true,
"emailVerified": true,
"credentials": [
{
"type": "password",
"value": "Demo12345!",
"temporary": false
}
],
"realmRoles": [
"viewer",
"api_user",
"free_tier"
],
"groups": [
"/free-users"
],
"attributes": {
"tier": [
"free"
]
}
},
{
"username": "free@didi.local",
"email": "free@didi.local",
"firstName": "Free",
"lastName": "User",
"enabled": true,
"emailVerified": true,
"credentials": [
{
"type": "password",
"value": "Password123",
"temporary": false
}
],
"realmRoles": [
"free_tier",
"viewer",
"api_user"
],
"groups": [
"/free-users"
],
"attributes": {
"tier": [
"free"
]
}
},
{
"username": "paid@didi.local",
"email": "paid@didi.local",
"firstName": "Paid",
"lastName": "User",
"enabled": true,
"emailVerified": true,
"credentials": [
{
"type": "password",
"value": "Password123",
"temporary": false
}
],
"realmRoles": [
"paid_tier",
"viewer",
"analyst",
"api_user"
],
"groups": [
"/paid-users"
],
"attributes": {
"tier": [
"paid"
]
}
},
{
"username": "enterprise@didi.local",
"email": "enterprise@didi.local",
"firstName": "Enterprise",
"lastName": "User",
"enabled": true,
"emailVerified": true,
"credentials": [
{
"type": "password",
"value": "Password123",
"temporary": false
}
],
"realmRoles": [
"enterprise_tier",
"viewer",
"analyst",
"api_user"
],
"groups": [
"/enterprise-users"
],
"attributes": {
"tier": [
"enterprise"
]
}
}
],
"eventsEnabled": true,
"eventsListeners": [
"jboss-logging"
],
"adminEventsEnabled": true,
"adminEventsDetailsEnabled": true,
"internationalizationEnabled": true,
"supportedLocales": [
"en"
],
"defaultLocale": "en",
"passwordPolicy": "length(10) and digits(1) and upperCase(1) and lowerCase(1) and notUsername(undefined) and passwordHistory(3)",
"otpPolicyType": "totp",
"otpPolicyAlgorithm": "HmacSHA1",
"otpPolicyDigits": 6,
"otpPolicyPeriod": 30
}

View file

@ -0,0 +1,72 @@
# Keycloak Custom Theme Setup
## Automatic Setup
Run the provided script after Keycloak is running:
```bash
./docker/keycloak/set-theme.sh
```
## Manual Setup
1. Access Keycloak Admin Console:
- URL: http://localhost:8180
- Username: admin
- Password: keycloak_admin_password_123
2. Select your realm:
- Click on the realm dropdown (top left)
- Select "misinformation-analyzer"
3. Configure the theme:
- Go to "Realm Settings" in the left menu
- Click on the "Themes" tab
- In "Login theme" dropdown, select "misinformation-theme"
- Click "Save"
4. Test the theme:
- Logout from admin console
- Go to your application: http://localhost:3001
- You should see the styled login page
## Theme Customization
The theme files are located in:
- CSS: `misinformation-theme/login/resources/css/login.css`
- Logo: `misinformation-theme/login/resources/img/logo.png`
- Properties: `misinformation-theme/login/theme.properties`
### Customization Options:
1. **Colors**: Edit the CSS variables in login.css
2. **Logo**: Replace logo.png with your own
3. **Fonts**: Update font-family in the CSS
4. **Layout**: Modify the CSS selectors
### Available CSS Variables:
```css
--primary-color: #1976d2;
--primary-dark: #115293;
--primary-light: #4791db;
--secondary-color: #dc004e;
--gradient-trust: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--gradient-bloom: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
```
## Troubleshooting
If the theme doesn't appear:
1. Ensure the theme folder is mounted correctly in docker-compose.yml
2. Restart Keycloak: `docker compose restart keycloak`
3. Clear browser cache
4. Check Keycloak logs: `docker compose logs keycloak`
## Preview
The custom theme includes:
- Gradient background matching your app
- Custom logo
- Styled input fields with focus effects
- Matching button styles
- Consistent color scheme
- Responsive design

View file

@ -0,0 +1,148 @@
/* Custom theme for didi - AI Platform */
/* Remove the black striped background */
body,
.login-pf body,
.login-pf-page {
background: #FFFFFF !important;
background-image: none !important;
}
/* Hide elements we don't want */
#kc-header,
#kc-header-wrapper,
.alert-info {
display: none;
}
/* Center the entire login container */
.login-pf-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
/* Style the card container */
.card-pf {
background: #FFFFFF !important;
border-radius: 12px !important;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06) !important;
padding: 48px !important;
max-width: 400px !important;
width: 100% !important;
margin: 20px !important;
}
/* Style the login box */
#kc-content {
text-align: center;
}
/* Form styling */
#kc-form {
text-align: left;
margin-top: 32px;
}
/* Input fields */
.pf-c-form-control,
input[type="text"],
input[type="password"] {
width: 100% !important;
padding: 12px 16px !important;
border: 1px solid #E5E7EB !important;
border-radius: 6px !important;
font-size: 16px !important;
margin-top: 8px !important;
}
.pf-c-form-control:focus,
input[type="text"]:focus,
input[type="password"]:focus {
border-color: #0052CC !important;
outline: none !important;
box-shadow: 0 0 0 3px rgba(0, 82, 204, 0.1) !important;
}
/* Labels */
label {
color: #374151 !important;
font-weight: 500 !important;
font-size: 14px !important;
display: block !important;
margin-bottom: 4px !important;
}
/* Form groups spacing */
.form-group {
margin-bottom: 20px !important;
}
/* Add logo before title */
#kc-page-title::before {
content: '';
display: block;
background-image: url('../img/logo.png');
background-repeat: no-repeat;
background-position: center;
background-size: contain;
width: 150px;
height: 150px;
margin: 0 auto 20px;
}
/* Add subtitle after title */
#kc-page-title::after {
content: 'didi - AI Platform';
display: block;
font-size: 16px;
font-weight: 400;
color: #6B7280;
margin-top: 10px;
}
/* Style the submit button */
#kc-login {
background: #0052CC !important;
border: none !important;
width: 100% !important;
padding: 12px 24px !important;
border-radius: 6px !important;
font-size: 16px !important;
font-weight: 500 !important;
margin-top: 24px !important;
transition: all 0.2s !important;
}
#kc-login:hover {
background: #003d99 !important;
transform: translateY(-1px) !important;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06) !important;
}
/* Checkbox styling */
.checkbox {
margin: 16px 0 !important;
}
.checkbox label {
font-weight: 400 !important;
}
/* Links below form */
#kc-registration,
#kc-passwd-reset-wrapper {
margin-top: 24px !important;
}
#kc-registration a,
#kc-passwd-reset-wrapper a {
color: #0052CC !important;
text-decoration: none !important;
}
#kc-registration a:hover,
#kc-passwd-reset-wrapper a:hover {
text-decoration: underline !important;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -0,0 +1,9 @@
# Inherit from keycloak to get templates
parent=keycloak
# Override styles
styles=css/login.css
# Messages
displayName=Misinformation Analysis Platform
displayNameHtml=<strong>Misinformation Analysis Platform</strong>

View file

@ -0,0 +1,148 @@
/* Custom theme for didi - Backend Platform */
/* Remove the black striped background */
body,
.login-pf body,
.login-pf-page {
background: #FFFFFF !important;
background-image: none !important;
}
/* Hide elements we don't want */
#kc-header,
#kc-header-wrapper,
.alert-info {
display: none;
}
/* Center the entire login container */
.login-pf-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
/* Style the card container */
.card-pf {
background: #FFFFFF !important;
border-radius: 12px !important;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06) !important;
padding: 48px !important;
max-width: 400px !important;
width: 100% !important;
margin: 20px !important;
}
/* Style the login box */
#kc-content {
text-align: center;
}
/* Form styling */
#kc-form {
text-align: left;
margin-top: 32px;
}
/* Input fields */
.pf-c-form-control,
input[type="text"],
input[type="password"] {
width: 100% !important;
padding: 12px 16px !important;
border: 1px solid #E5E7EB !important;
border-radius: 6px !important;
font-size: 16px !important;
margin-top: 8px !important;
}
.pf-c-form-control:focus,
input[type="text"]:focus,
input[type="password"]:focus {
border-color: #0052CC !important;
outline: none !important;
box-shadow: 0 0 0 3px rgba(0, 82, 204, 0.1) !important;
}
/* Labels */
label {
color: #374151 !important;
font-weight: 500 !important;
font-size: 14px !important;
display: block !important;
margin-bottom: 4px !important;
}
/* Form groups spacing */
.form-group {
margin-bottom: 20px !important;
}
/* Add logo before title */
#kc-page-title::before {
content: '';
display: block;
background-image: url('../img/logo.png');
background-repeat: no-repeat;
background-position: center;
background-size: contain;
width: 150px;
height: 150px;
margin: 0 auto 20px;
}
/* Add subtitle after title */
#kc-page-title::after {
content: 'didi - Backend';
display: block;
font-size: 16px;
font-weight: 400;
color: #6B7280;
margin-top: 10px;
}
/* Style the submit button */
#kc-login {
background: #0052CC !important;
border: none !important;
width: 100% !important;
padding: 12px 24px !important;
border-radius: 6px !important;
font-size: 16px !important;
font-weight: 500 !important;
margin-top: 24px !important;
transition: all 0.2s !important;
}
#kc-login:hover {
background: #003d99 !important;
transform: translateY(-1px) !important;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06) !important;
}
/* Checkbox styling */
.checkbox {
margin: 16px 0 !important;
}
.checkbox label {
font-weight: 400 !important;
}
/* Links below form */
#kc-registration,
#kc-passwd-reset-wrapper {
margin-top: 24px !important;
}
#kc-registration a,
#kc-passwd-reset-wrapper a {
color: #0052CC !important;
text-decoration: none !important;
}
#kc-registration a:hover,
#kc-passwd-reset-wrapper a:hover {
text-decoration: underline !important;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -0,0 +1,9 @@
# Inherit from keycloak to get templates
parent=keycloak
# Override styles
styles=css/login.css
# Messages
displayName=Misinformation Analysis Platform
displayNameHtml=<strong>Misinformation Analysis Platform</strong>

View file

@ -0,0 +1,50 @@
<#assign customVerifyUrl = "https://didi365.eu/api/auth/verify-email?key=" + link?keep_after("key=")>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Verifică adresa de email</title>
</head>
<body style="margin: 0; padding: 0; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background-color: #f4f4f4;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color: #f4f4f4;">
<tr>
<td align="center" style="padding: 40px 20px;">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="background-color: #ffffff; border-radius: 12px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);">
<tr>
<td style="background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); padding: 40px 40px 30px; border-radius: 12px 12px 0 0; text-align: center;">
<h1 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: 600;">DIDI</h1>
<p style="color: #a0a0a0; margin: 8px 0 0; font-size: 14px;">Misinformation Detection Platform</p>
</td>
</tr>
<tr>
<td style="padding: 40px;">
<h2 style="color: #1a1a2e; margin: 0 0 20px; font-size: 22px; font-weight: 600;">Verifică adresa de email</h2>
<p style="color: #555555; font-size: 16px; line-height: 1.6; margin: 0 0 25px;">Salut <strong>${user.firstName!""}</strong>,</p>
<p style="color: #555555; font-size: 16px; line-height: 1.6; margin: 0 0 25px;">Mulțumim pentru înregistrare! Pentru a activa contul tău DIDI, te rugăm să confirmi adresa de email apăsând butonul de mai jos:</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="margin: 30px 0;">
<tr>
<td align="center">
<a href="${customVerifyUrl}" target="_blank" style="display: inline-block; background: linear-gradient(135deg, #4CAF50 0%, #45a049 100%); color: #ffffff; text-decoration: none; padding: 16px 40px; border-radius: 8px; font-size: 16px; font-weight: 600; box-shadow: 0 4px 12px rgba(76, 175, 80, 0.3);">Verifică Email</a>
</td>
</tr>
</table>
<p style="color: #888888; font-size: 14px; line-height: 1.6; margin: 25px 0 0;">Sau copiază acest link în browser:</p>
<p style="color: #4CAF50; font-size: 13px; word-break: break-all; background-color: #f8f8f8; padding: 12px; border-radius: 6px; margin: 10px 0 25px;">${customVerifyUrl}</p>
<p style="color: #888888; font-size: 14px; line-height: 1.6; margin: 0;">Link-ul expiră în <strong>${linkExpiration}</strong>.</p>
<hr style="border: none; border-top: 1px solid #eeeeee; margin: 30px 0;">
<p style="color: #999999; font-size: 13px; line-height: 1.6; margin: 0;">Dacă nu ai creat un cont pe DIDI, poți ignora acest email.</p>
</td>
</tr>
<tr>
<td style="background-color: #f8f8f8; padding: 25px 40px; border-radius: 0 0 12px 12px; text-align: center;">
<p style="color: #999999; font-size: 12px; margin: 0 0 8px;">&copy; 2025 didi - Misinformation Detection Platform</p>
<p style="color: #bbbbbb; font-size: 11px; margin: 0;">Acest email a fost trimis automat. Te rugăm să nu răspunzi.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View file

@ -0,0 +1,50 @@
<#assign customVerifyUrl = "https://didi365.eu/api/auth/verify-email?key=" + link?keep_after("key=")>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Verifică adresa de email</title>
</head>
<body style="margin: 0; padding: 0; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background-color: #050510;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color: #050510;">
<tr>
<td align="center" style="padding: 40px 20px;">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="background-color: #0f0f1a; border-radius: 16px; box-shadow: 0 8px 32px rgba(124, 58, 237, 0.3); border: 1px solid rgba(124, 58, 237, 0.2);">
<tr>
<td style="background: linear-gradient(135deg, #7c3aed 0%, #4f46e5 100%); padding: 40px 40px 30px; border-radius: 16px 16px 0 0; text-align: center;">
<h1 style="color: #ffffff; margin: 0; font-size: 36px; font-weight: 700; letter-spacing: 2px;">didi</h1>
<p style="color: rgba(255,255,255,0.7); margin: 8px 0 0; font-size: 14px; letter-spacing: 1px;">Misinformation Detection Platform</p>
</td>
</tr>
<tr>
<td style="padding: 40px;">
<h2 style="color: #E8E8E8; margin: 0 0 20px; font-size: 22px; font-weight: 600;">Verifică adresa de email</h2>
<p style="color: #D1D1D1; font-size: 16px; line-height: 1.6; margin: 0 0 25px;">Salut <strong style="color: #00d4ff;">${user.firstName!""}</strong>,</p>
<p style="color: #D1D1D1; font-size: 16px; line-height: 1.6; margin: 0 0 25px;">Pentru a-ți activa contul în platforma <strong style="color: #7c3aed;">didi</strong>, te rugăm să confirmi adresa de email apăsând butonul de mai jos:</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="margin: 30px 0;">
<tr>
<td align="center">
<a href="${customVerifyUrl}" target="_blank" style="display: inline-block; background: linear-gradient(135deg, #7c3aed 0%, #4f46e5 100%); color: #ffffff; text-decoration: none; padding: 16px 48px; border-radius: 12px; font-size: 16px; font-weight: 600; box-shadow: 0 4px 20px rgba(124, 58, 237, 0.4); letter-spacing: 0.5px;">Verifică Email</a>
</td>
</tr>
</table>
<p style="color: #9CA3AF; font-size: 14px; line-height: 1.6; margin: 25px 0 0;">Sau copiază acest link în browser:</p>
<p style="color: #00d4ff; font-size: 12px; word-break: break-all; background-color: rgba(124, 58, 237, 0.1); padding: 14px; border-radius: 8px; margin: 10px 0 25px; border: 1px solid rgba(124, 58, 237, 0.2);">${customVerifyUrl}</p>
<p style="color: #9CA3AF; font-size: 14px; line-height: 1.6; margin: 0;">Link-ul expiră în <strong style="color: #E8E8E8;"><#if linkExpiration?number gt 60>${(linkExpiration?number / 60)?round} minute<#else>${linkExpiration} secunde</#if></strong>.</p>
<hr style="border: none; border-top: 1px solid rgba(124, 58, 237, 0.2); margin: 30px 0;">
<p style="color: #6B7280; font-size: 13px; line-height: 1.6; margin: 0;">Dacă nu ai solicitat această acțiune, poți ignora acest email.</p>
</td>
</tr>
<tr>
<td style="background-color: rgba(124, 58, 237, 0.05); padding: 25px 40px; border-radius: 0 0 16px 16px; text-align: center; border-top: 1px solid rgba(124, 58, 237, 0.1);">
<p style="color: #9CA3AF; font-size: 12px; margin: 0 0 8px;">&copy; 2025 <span style="color: #7c3aed; font-weight: 600;">didi</span> - Misinformation Detection Platform</p>
<p style="color: #6B7280; font-size: 11px; margin: 0;">Acest email a fost trimis automat. Te rugăm să nu răspunzi.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View file

@ -0,0 +1,14 @@
Salut ${user.firstName!""},
Mulțumim pentru înregistrare pe DIDI!
Pentru a activa contul tău, te rugăm să accesezi link-ul de mai jos:
${link}
Link-ul expiră în ${linkExpiration}.
Dacă nu ai creat un cont pe DIDI, poți ignora acest email.
---
DIDI - Misinformation Detection Platform

View file

@ -0,0 +1,117 @@
<#import "template.ftl" as layout>
<@layout.registrationLayout displayMessage=true; section>
<#if section = "header">
<#if messageHeader??>
${messageHeader}
<#else>
${message.summary}
</#if>
<#elseif section = "form">
<div id="kc-info-message">
<p class="instruction">${message.summary}<#if requiredActions??><#list requiredActions>: <b><#items as reqActionItem>${kcSanitize(msg("requiredAction.${reqActionItem}"))?no_esc}<#sep>, </#items></b></#list><#else></#if></p>
<#if skipLink??>
<#else>
<#-- Mobile app deep link redirect -->
<#if client?? && client.clientId?? && client.clientId == "didi-mobile-app">
<#assign mobileRedirectUrl = "didi://email-verified">
<p style="text-align: center; margin-top: 20px;">
<a href="${mobileRedirectUrl}" style="display: inline-block; background: linear-gradient(135deg, #4CAF50 0%, #45a049 100%); color: #ffffff; text-decoration: none; padding: 14px 32px; border-radius: 8px; font-size: 16px; font-weight: 600;">
Deschide aplicația DIDI
</a>
</p>
<p style="color: #666; font-size: 14px; text-align: center; margin-top: 15px;">
Vei fi redirecționat automat către aplicație...
</p>
<script>
// Try to redirect to mobile app after 1.5 seconds
setTimeout(function() {
window.location.href = "${mobileRedirectUrl}";
}, 1500);
// Fallback: if deep link fails, try alternative scheme
setTimeout(function() {
window.location.href = "com.didi365.app://email-verified";
}, 3000);
</script>
<#-- Web app redirect -->
<#elseif client?? && client.clientId?? && client.clientId == "didi-web-app">
<#assign webRedirectUrl = "/email-verified">
<p style="text-align: center; margin-top: 20px;">
<a href="${webRedirectUrl}" style="display: inline-block; background: linear-gradient(135deg, #4CAF50 0%, #45a049 100%); color: #ffffff; text-decoration: none; padding: 14px 32px; border-radius: 8px; font-size: 16px; font-weight: 600;">
Continuă către aplicație
</a>
</p>
<p style="color: #666; font-size: 14px; text-align: center; margin-top: 15px;">
Vei fi redirecționat automat...
</p>
<script>
setTimeout(function() {
window.location.href = "${webRedirectUrl}";
}, 2000);
</script>
<#-- Standard Keycloak redirects -->
<#elseif pageRedirectUri?has_content>
<p><a href="${pageRedirectUri}">${kcSanitize(msg("backToApplication"))?no_esc}</a></p>
<script>
setTimeout(function() {
window.location.href = "${pageRedirectUri}";
}, 2000);
</script>
<#elseif actionUri?has_content>
<p><a href="${actionUri}">${kcSanitize(msg("proceedWithAction"))?no_esc}</a></p>
<script>
setTimeout(function() {
window.location.href = "${actionUri}";
}, 2000);
</script>
<#elseif (client.baseUrl)?has_content>
<p><a href="${client.baseUrl}">${kcSanitize(msg("backToApplication"))?no_esc}</a></p>
<script>
setTimeout(function() {
window.location.href = "${client.baseUrl}";
}, 2000);
</script>
<#-- Fallback: detect mobile browser and redirect accordingly -->
<#else>
<div id="redirect-buttons" style="text-align: center; margin-top: 20px;">
<p style="color: #666; font-size: 14px; margin-bottom: 15px;">
Alege cum dorești să continui:
</p>
<p>
<a href="didi://email-verified" id="mobile-link" style="display: inline-block; background: linear-gradient(135deg, #4CAF50 0%, #45a049 100%); color: #ffffff; text-decoration: none; padding: 14px 32px; border-radius: 8px; font-size: 16px; font-weight: 600; margin: 5px;">
📱 Deschide în aplicație
</a>
</p>
<p>
<a href="/email-verified" id="web-link" style="display: inline-block; background: linear-gradient(135deg, #2196F3 0%, #1976D2 100%); color: #ffffff; text-decoration: none; padding: 14px 32px; border-radius: 8px; font-size: 16px; font-weight: 600; margin: 5px;">
🌐 Continuă în browser
</a>
</p>
</div>
<script>
(function() {
var ua = navigator.userAgent || navigator.vendor || window.opera;
var isMobile = /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(ua.toLowerCase());
if (isMobile) {
// On mobile, try deep link first
setTimeout(function() {
window.location.href = "didi://email-verified";
}, 1500);
// Fallback to alternative scheme
setTimeout(function() {
window.location.href = "com.didi365.app://email-verified";
}, 3000);
} else {
// On desktop, redirect to web app
setTimeout(function() {
window.location.href = "/email-verified";
}, 2000);
}
})();
</script>
</#if>
</#if>
</div>
</#if>
</@layout.registrationLayout>

View file

@ -0,0 +1,42 @@
<#import "template.ftl" as layout>
<@layout.registrationLayout displayInfo=true displayMessage=!messagesPerField.existsError('username'); section>
<#if section = "header">
${msg("emailForgotTitle")}
<#elseif section = "form">
<form id="kc-reset-password-form" class="${properties.kcFormClass!}" action="${url.loginAction}" method="post">
<div class="back-link">
<a href="${url.loginUrl}">${msg("backToLogin")}</a>
</div>
<div class="${properties.kcFormGroupClass!}">
<div class="${properties.kcLabelWrapperClass!}">
<label for="username" class="${properties.kcLabelClass!}">
<#if !realm.loginWithEmailAllowed>${msg("username")}<#elseif !realm.registrationEmailAsUsername>${msg("usernameOrEmail")}<#else>${msg("email")}</#if>
</label>
</div>
<div class="${properties.kcInputWrapperClass!}">
<#if auth?has_content && auth.showUsername()>
<input type="text" id="username" name="username" class="${properties.kcInputClass!}" autofocus value="${auth.attemptedUsername}" aria-invalid="<#if messagesPerField.existsError('username')>true</#if>"/>
<#else>
<input type="text" id="username" name="username" class="${properties.kcInputClass!}" autofocus aria-invalid="<#if messagesPerField.existsError('username')>true</#if>"/>
</#if>
<#if messagesPerField.existsError('username')>
<span id="input-error-username" class="${properties.kcInputErrorMessageClass!}" aria-live="polite">
${kcSanitize(messagesPerField.get('username'))?no_esc}
</span>
</#if>
</div>
</div>
<div class="${properties.kcFormGroupClass!}">
<div id="kc-form-buttons" class="${properties.kcFormButtonsClass!}">
<input class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}" type="submit" value="${msg("doSubmit")}"/>
</div>
</div>
</form>
<#elseif section = "info" >
<#if realm.duplicateEmailsAllowed>
${msg("emailInstructionUsername")}
<#else>
${msg("emailInstruction")}
</#if>
</#if>
</@layout.registrationLayout>

View file

@ -0,0 +1,25 @@
<#import "template.ftl" as layout>
<@layout.registrationLayout displayInfo=true displayMessage=false; section>
<#if section = "header">
${msg("emailVerifyTitle")}
<#elseif section = "form">
<div class="verify-email-box">
<p class="instruction">
${msg("emailVerifyInstruction1")}
</p>
<p class="instruction auto-check">
<span id="check-status">${msg("emailVerifyAutoCheck")}</span>
<span class="loader"></span>
</p>
</div>
<script>
setTimeout(function() { location.reload(); }, 5000);
</script>
<#elseif section = "info">
<p class="instruction">
${msg("emailVerifyInstruction3")}
<br/><br/>
<a href="${url.loginAction}">${msg("doClickHere")}</a> ${msg("emailVerifyInstruction2")}
</p>
</#if>
</@layout.registrationLayout>

View file

@ -0,0 +1,33 @@
# DIDI Custom Messages
# Forgot Password
emailForgotTitle=Reset your password
emailInstruction=Enter your email address and we will send you instructions to reset your password.
# Labels - hide them, use placeholders instead
usernameOrEmail=Email
username=Email
email=Email
password=Password
passwordConfirm=Confirm Password
firstName=First Name
lastName=Last Name
# Login
loginTitle=Sign in
doLogIn=Sign in
loginAccountTitle=Sign in to your account
# Register
registerTitle=Create account
doRegister=Create account
# Back link - keep it simple
backToLogin=\u2190 Back to sign in
# Email Verification
emailVerifyTitle=Verify your email
emailVerifyInstruction1=You need to verify your email address to activate your account.
emailVerifyInstruction2=to re-send the email.
emailVerifyInstruction3=Haven't received a verification code in your email?
emailVerifyAutoCheck=Checking verification status...

View file

@ -0,0 +1,27 @@
<#macro termsAcceptance>
<#if termsAcceptanceRequired??>
<div class="form-group">
<div class="${properties.kcInputWrapperClass!}">
${msg("termsTitle")}
<div id="kc-registration-terms-text">
${kcSanitize(msg("termsText"))?no_esc}
</div>
</div>
</div>
<div class="form-group">
<div class="${properties.kcLabelWrapperClass!}">
<input type="checkbox" id="termsAccepted" name="termsAccepted" class="${properties.kcCheckboxInputClass!}"
aria-invalid="<#if messagesPerField.existsError('termsAccepted')>true</#if>"
/>
<label for="termsAccepted" class="${properties.kcLabelClass!}">${msg("acceptTerms")}</label>
</div>
<#if messagesPerField.existsError('termsAccepted')>
<div class="${properties.kcLabelWrapperClass!}">
<span id="input-error-terms-accepted" class="${properties.kcInputErrorMessageClass!}" aria-live="polite">
${kcSanitize(messagesPerField.get('termsAccepted'))?no_esc}
</span>
</div>
</#if>
</div>
</#if>
</#macro>

View file

@ -0,0 +1,135 @@
<#import "template.ftl" as layout>
<#import "register-commons.ftl" as registerCommons>
<@layout.registrationLayout displayMessage=!messagesPerField.existsError('firstName','lastName','email','username','password','password-confirm','termsAccepted'); section>
<#if section = "header">
${msg("registerTitle")}
<#elseif section = "form">
<form id="kc-register-form" class="${properties.kcFormClass!}" action="${url.registrationAction}" method="post">
<div class="back-link">
<a href="${url.loginUrl}">${kcSanitize(msg("backToLogin"))?no_esc}</a>
</div>
<div class="${properties.kcFormGroupClass!}">
<div class="${properties.kcLabelWrapperClass!}">
<label for="firstName" class="${properties.kcLabelClass!}">${msg("firstName")}</label>
</div>
<div class="${properties.kcInputWrapperClass!}">
<input type="text" id="firstName" class="${properties.kcInputClass!}" name="firstName"
value="${(register.formData.firstName!'')}"
aria-invalid="<#if messagesPerField.existsError('firstName')>true</#if>"
/>
<#if messagesPerField.existsError('firstName')>
<span id="input-error-firstname" class="${properties.kcInputErrorMessageClass!}" aria-live="polite">
${kcSanitize(messagesPerField.get('firstName'))?no_esc}
</span>
</#if>
</div>
</div>
<div class="${properties.kcFormGroupClass!}">
<div class="${properties.kcLabelWrapperClass!}">
<label for="lastName" class="${properties.kcLabelClass!}">${msg("lastName")}</label>
</div>
<div class="${properties.kcInputWrapperClass!}">
<input type="text" id="lastName" class="${properties.kcInputClass!}" name="lastName"
value="${(register.formData.lastName!'')}"
aria-invalid="<#if messagesPerField.existsError('lastName')>true</#if>"
/>
<#if messagesPerField.existsError('lastName')>
<span id="input-error-lastname" class="${properties.kcInputErrorMessageClass!}" aria-live="polite">
${kcSanitize(messagesPerField.get('lastName'))?no_esc}
</span>
</#if>
</div>
</div>
<div class="${properties.kcFormGroupClass!}">
<div class="${properties.kcLabelWrapperClass!}">
<label for="email" class="${properties.kcLabelClass!}">${msg("email")}</label>
</div>
<div class="${properties.kcInputWrapperClass!}">
<input type="text" id="email" class="${properties.kcInputClass!}" name="email"
value="${(register.formData.email!'')}" autocomplete="email"
aria-invalid="<#if messagesPerField.existsError('email')>true</#if>"
/>
<#if messagesPerField.existsError('email')>
<span id="input-error-email" class="${properties.kcInputErrorMessageClass!}" aria-live="polite">
${kcSanitize(messagesPerField.get('email'))?no_esc}
</span>
</#if>
</div>
</div>
<#if !realm.registrationEmailAsUsername>
<div class="${properties.kcFormGroupClass!}">
<div class="${properties.kcLabelWrapperClass!}">
<label for="username" class="${properties.kcLabelClass!}">${msg("username")}</label>
</div>
<div class="${properties.kcInputWrapperClass!}">
<input type="text" id="username" class="${properties.kcInputClass!}" name="username"
value="${(register.formData.username!'')}" autocomplete="username"
aria-invalid="<#if messagesPerField.existsError('username')>true</#if>"
/>
<#if messagesPerField.existsError('username')>
<span id="input-error-username" class="${properties.kcInputErrorMessageClass!}" aria-live="polite">
${kcSanitize(messagesPerField.get('username'))?no_esc}
</span>
</#if>
</div>
</div>
</#if>
<#if passwordRequired??>
<div class="${properties.kcFormGroupClass!}">
<div class="${properties.kcLabelWrapperClass!}">
<label for="password" class="${properties.kcLabelClass!}">${msg("password")}</label>
</div>
<div class="${properties.kcInputWrapperClass!}">
<input type="password" id="password" class="${properties.kcInputClass!}" name="password"
autocomplete="new-password"
aria-invalid="<#if messagesPerField.existsError('password','password-confirm')>true</#if>"
/>
<#if messagesPerField.existsError('password')>
<span id="input-error-password" class="${properties.kcInputErrorMessageClass!}" aria-live="polite">
${kcSanitize(messagesPerField.get('password'))?no_esc}
</span>
</#if>
</div>
</div>
<div class="${properties.kcFormGroupClass!}">
<div class="${properties.kcLabelWrapperClass!}">
<label for="password-confirm" class="${properties.kcLabelClass!}">${msg("passwordConfirm")}</label>
</div>
<div class="${properties.kcInputWrapperClass!}">
<input type="password" id="password-confirm" class="${properties.kcInputClass!}"
name="password-confirm"
aria-invalid="<#if messagesPerField.existsError('password-confirm')>true</#if>"
/>
<#if messagesPerField.existsError('password-confirm')>
<span id="input-error-password-confirm" class="${properties.kcInputErrorMessageClass!}" aria-live="polite">
${kcSanitize(messagesPerField.get('password-confirm'))?no_esc}
</span>
</#if>
</div>
</div>
</#if>
<@registerCommons.termsAcceptance/>
<#if recaptchaRequired??>
<div class="form-group">
<div class="${properties.kcInputWrapperClass!}">
<div class="g-recaptcha" data-size="compact" data-sitekey="${recaptchaSiteKey}"></div>
</div>
</div>
</#if>
<div class="${properties.kcFormGroupClass!}">
<div id="kc-form-buttons" class="${properties.kcFormButtonsClass!}">
<input class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}" type="submit" value="${msg("doRegister")}"/>
</div>
</div>
</form>
</#if>
</@layout.registrationLayout>

View file

@ -0,0 +1,896 @@
/* DIDI Login Theme - Dark Purple */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Outfit:wght@400;500;600;700;800&display=swap');
/* ========== ROOT VARIABLES ========== */
:root {
/* Background */
--bg-primary: #050510;
--bg-secondary: #0a0a1a;
--surface: rgba(255, 255, 255, 0.03);
--surface-hover: rgba(255, 255, 255, 0.06);
/* Borders */
--border: rgba(255, 255, 255, 0.08);
--border-hover: rgba(255, 255, 255, 0.15);
--border-focus: rgba(139, 92, 246, 0.5);
/* Text */
--text-primary: #ffffff;
--text-secondary: rgba(255, 255, 255, 0.6);
--text-muted: rgba(255, 255, 255, 0.4);
/* Purple accent */
--purple-light: #A855F7;
--purple-main: #8B5CF6;
--purple-dark: #7C3AED;
--purple-deeper: #6D28D9;
--gradient-cta: linear-gradient(135deg, #A855F7 0%, #7C3AED 50%, #6D28D9 100%);
--glow: linear-gradient(135deg, #8B5CF6, #7C3AED);
/* Semantic */
--warning: #f59e0b;
--success: #10b981;
--error: #E63946;
/* Typography */
--font-display: 'Outfit', 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
--font-body: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
/* Spacing */
--radius-card: 20px;
--radius-normal: 16px;
--radius-button: 12px;
--radius-tag: 8px;
}
/* ========== GLOBAL RESET ========== */
* {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
min-height: 100vh;
}
body {
font-family: var(--font-body);
font-weight: 400;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
}
/* ========== LOGIN PAGE LAYOUT ========== */
html.login-pf,
.login-pf body {
background: var(--bg-primary) !important;
min-height: 100vh !important;
min-height: 100dvh !important; /* Dynamic viewport height for mobile */
margin: 0 !important;
padding: 0 !important;
}
#kc-header {
display: none;
}
#kc-header-wrapper {
display: none;
}
/* Force perfect centering */
.login-pf .container-fluid,
.login-pf .container {
background: var(--bg-primary) !important;
min-height: 100vh !important;
min-height: 100dvh !important;
width: 100% !important;
max-width: 100% !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
padding: 20px !important;
margin: 0 !important;
position: relative !important;
}
/* Override any row/column that might break centering */
.login-pf .row,
.login-pf .col-sm-12,
.login-pf .col-md-12,
.login-pf [class*="col-"] {
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 100% !important;
max-width: 100% !important;
margin: 0 !important;
padding: 0 !important;
float: none !important;
}
/* ========== LOGIN CARD ========== */
#kc-form-wrapper,
#kc-content,
#kc-content-wrapper {
background: transparent !important;
border: none !important;
box-shadow: none !important;
width: 100% !important;
}
.card-pf {
display: flex !important;
flex-direction: column !important;
align-items: center !important;
background: transparent !important;
border: none !important;
box-shadow: none !important;
max-width: 420px;
width: 100%;
margin: 0 auto !important;
padding: 20px;
flex-shrink: 0;
/* Prevent overflow on small screens */
overflow: hidden;
}
#kc-form,
#kc-register-form,
#kc-reset-password-form {
background: var(--surface);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--border);
border-radius: var(--radius-card);
padding: 48px 40px;
max-width: 420px;
width: 100%;
margin: 0 auto;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
/* ========== LOGO / HEADER - Inside card ========== */
.card-pf::before {
content: 'didi';
display: block;
text-align: center;
font-family: var(--font-display);
font-size: 48px;
font-weight: 800;
letter-spacing: -0.04em;
color: #ffffff;
margin-bottom: 4px;
order: -2;
}
.card-pf::after {
content: 'Misinformation Detection Platform';
display: block;
text-align: center;
font-family: var(--font-body);
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 24px;
letter-spacing: 0.5px;
order: -1;
}
/* Hide header outside modal */
#kc-form-wrapper::before,
#kc-form-wrapper::after,
.login-pf-header,
#kc-header,
#kc-header-wrapper {
display: none !important;
}
/* ========== FORM TITLES ========== */
#kc-page-title,
.kc-page-title,
h1#kc-page-title {
font-family: var(--font-display);
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
text-align: center;
margin: 0 0 24px 0;
letter-spacing: -0.02em;
line-height: 1.2;
}
/* ========== FORM GROUPS ========== */
.form-group {
margin-bottom: 20px;
}
/* ========== LABELS ========== */
label,
.control-label,
#kc-form-options label {
display: block;
font-family: var(--font-body);
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 8px;
}
/* ========== INPUT FIELDS ========== */
input[type="text"],
input[type="password"],
input[type="email"],
input[type="tel"],
.form-control {
width: 100%;
padding: 14px 16px;
font-family: var(--font-body);
font-size: 15px;
color: var(--text-primary);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-button);
outline: none;
transition: all 0.2s ease;
}
input[type="text"]:hover,
input[type="password"]:hover,
input[type="email"]:hover,
input[type="tel"]:hover,
.form-control:hover {
border-color: var(--border-hover);
background: var(--surface-hover);
}
input[type="text"]:focus,
input[type="password"]:focus,
input[type="email"]:focus,
input[type="tel"]:focus,
.form-control:focus {
border-color: var(--border-focus);
background: var(--surface);
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.15);
}
input::placeholder,
.form-control::placeholder {
color: var(--text-muted);
}
/* ========== PRIMARY BUTTON ========== */
input[type="submit"],
button[type="submit"],
.btn-primary,
#kc-login,
#kc-form-buttons input[type="submit"] {
width: 100%;
padding: 16px 32px;
font-family: var(--font-body);
font-size: 16px;
font-weight: 600;
color: #ffffff;
background: var(--gradient-cta);
border: none;
border-radius: var(--radius-button);
cursor: pointer;
transition: all 0.2s ease;
margin-top: 8px;
}
input[type="submit"]:hover,
button[type="submit"]:hover,
.btn-primary:hover,
#kc-login:hover {
transform: scale(1.02);
box-shadow: 0 0 30px rgba(139, 92, 246, 0.4);
}
input[type="submit"]:active,
button[type="submit"]:active,
.btn-primary:active {
transform: scale(0.98);
}
/* Spacing between Submit and Cancel buttons */
#kc-form-buttons {
display: flex;
flex-direction: column;
gap: 12px;
}
/* ========== SECONDARY / LINK BUTTONS ========== */
.btn-default,
a.btn {
display: inline-block;
padding: 12px 24px;
font-family: var(--font-body);
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
background: transparent;
border: 1px solid var(--border-hover);
border-radius: var(--radius-button);
text-decoration: none;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-default:hover,
a.btn:hover {
color: var(--text-primary);
border-color: rgba(255, 255, 255, 0.3);
background: var(--surface-hover);
}
/* ========== LINKS ========== */
a,
#kc-registration a,
#kc-form-options a,
.kc-form-options a {
color: var(--purple-light);
text-decoration: none;
font-weight: 500;
transition: color 0.2s ease;
}
a:hover {
color: var(--purple-main);
text-decoration: underline;
}
/* ========== FORM OPTIONS (Remember me, Forgot password) ========== */
/* Both #kc-form-options and the Forgot Password div are children of .login-pf-settings.
We make the parent a flex row so they sit side by side. */
.login-pf-settings {
display: flex !important;
flex-direction: row !important;
flex-wrap: nowrap !important;
align-items: center !important;
justify-content: space-between !important;
}
#kc-form-options {
flex: 0 0 auto;
}
/* Forgot Password - the anonymous div right after #kc-form-options */
#kc-form-options + div {
flex: 0 0 auto;
text-align: right;
}
/* Back link at top of reset password form */
.back-link {
margin-bottom: 24px;
text-align: center;
}
.back-link a {
color: var(--purple-light);
font-size: 14px;
text-decoration: none;
}
.back-link a:hover {
text-decoration: underline;
}
/* Mobile responsive */
@media (max-width: 480px) {
.back-link {
text-align: center;
margin-bottom: 20px;
}
}
#kc-form-options span,
#kc-form-options .checkbox {
font-size: 14px;
color: var(--text-secondary);
}
/* ========== CHECKBOX ========== */
input[type="checkbox"] {
width: 15px;
height: 15px;
accent-color: var(--purple-main);
cursor: pointer;
}
/* Fix Remember-me alignment: cancel inherited block label + Patternfly padding,
make label a flex row so the box and the text sit on the same baseline.
Patternfly default uses `position: absolute` on the checkbox + `padding-left`
on the label both must be reset for flex layout to work. */
#kc-form-options .checkbox {
margin: 0 !important;
padding: 0 !important;
position: static !important;
}
#kc-form-options .checkbox label {
display: flex !important;
align-items: center;
gap: 8px;
margin: 0 !important;
padding: 0 !important;
cursor: pointer;
font-size: 14px;
line-height: 1;
color: var(--text-secondary);
font-weight: 500;
position: static !important;
}
#kc-form-options .checkbox input[type="checkbox"] {
position: static !important; /* override Patternfly position: absolute */
margin: 0 !important; /* override Patternfly negative margin-left */
flex-shrink: 0;
float: none !important; /* in case Patternfly floats it */
vertical-align: middle;
}
/* ========== REGISTRATION LINK ========== */
#kc-registration {
text-align: center;
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid var(--border);
}
#kc-registration span {
color: var(--text-secondary);
font-size: 14px;
}
/* ========== INFO / MESSAGES ========== */
#kc-info,
.kc-feedback-text,
#kc-info-wrapper {
text-align: center;
margin-top: 20px;
}
#kc-info-message,
.kc-feedback-text {
font-size: 14px;
color: var(--text-secondary);
line-height: 1.6;
}
/* ========== ALERTS ========== */
.alert {
padding: 14px 18px;
border-radius: var(--radius-button);
margin-bottom: 20px;
font-size: 14px;
line-height: 1.5;
}
.alert-error,
.alert-danger {
background: rgba(230, 57, 70, 0.1);
border: 1px solid rgba(230, 57, 70, 0.3);
color: var(--error);
}
.alert-warning {
background: rgba(245, 158, 11, 0.1);
border: 1px solid rgba(245, 158, 11, 0.3);
color: var(--warning);
}
.alert-success {
background: rgba(16, 185, 129, 0.1);
border: 1px solid rgba(16, 185, 129, 0.3);
color: var(--success);
}
.alert-info {
background: rgba(139, 92, 246, 0.1);
border: 1px solid rgba(139, 92, 246, 0.3);
color: var(--purple-light);
}
/* ========== SOCIAL PROVIDERS ========== */
#kc-social-providers {
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid var(--border);
}
#kc-social-providers h4,
#kc-social-providers .kc-social-title {
text-align: center;
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 16px;
}
#kc-social-providers ul {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
#kc-social-providers li a,
.kc-social-provider-link {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 14px 20px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-button);
color: var(--text-primary);
text-decoration: none;
font-size: 14px;
font-weight: 500;
transition: all 0.2s ease;
}
#kc-social-providers li a:hover,
.kc-social-provider-link:hover {
background: var(--surface-hover);
border-color: var(--border-hover);
}
/* ========== TERMS / RECAPTCHA ========== */
#kc-terms-text {
font-size: 13px;
color: var(--text-muted);
line-height: 1.6;
text-align: center;
margin-top: 16px;
}
/* ========== PASSWORD VISIBILITY TOGGLE ========== */
.kc-form-password-container {
position: relative;
}
.kc-form-password-container button {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
background: transparent;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 4px;
}
.kc-form-password-container button:hover {
color: var(--text-secondary);
}
/* ========== RESPONSIVE ========== */
@media (max-width: 480px) {
#kc-form,
#kc-register-form,
#kc-reset-password-form {
padding: 28px 28px;
margin: 0 auto;
border-radius: var(--radius-normal);
max-width: 100%;
}
.card-pf::before {
font-size: 36px;
}
input[type="submit"],
button[type="submit"],
.btn-primary {
padding: 14px 24px;
font-size: 15px;
}
}
/* ========== HIDE UNNECESSARY ELEMENTS ========== */
#kc-locale-wrapper,
.kc-locale-dropdown,
.pf-c-alert__icon,
#kc-header-wrapper {
display: none !important;
}
/* Hide PatternFly error icons (red circles) */
.pf-c-form-control__icon,
.pf-c-form-control__utilities,
.pf-c-form__helper-text,
input[aria-invalid="true"] + .pf-c-form-control__utilities,
.kc-feedback-text::before,
span[class*="error"]::before,
span[class*="Error"]::before {
display: none !important;
}
/* Simple red border for invalid inputs */
input[aria-invalid="true"],
input.pf-m-error,
.pf-c-form-control.pf-m-error {
border-color: var(--error) !important;
background-image: none !important;
background: var(--surface) !important;
}
/* ========== FOOTER ========== */
#kc-content::after {
content: '© 2025 didi';
display: block;
text-align: center;
font-size: 12px;
color: var(--text-muted);
margin-top: 32px;
}
/* ========== BACK TO LOGIN LINK ========== */
#kc-info {
order: 100;
margin-top: 24px !important;
}
#kc-info-wrapper {
text-align: center;
}
/* ========== PLACEHOLDERS ========== */
input#username::placeholder,
input#password::placeholder,
input#email::placeholder,
input#firstName::placeholder,
input#lastName::placeholder,
input#password-new::placeholder,
input#password-confirm::placeholder {
color: var(--text-muted);
opacity: 1;
}
/* Add placeholder text via CSS for inputs that don't have it */
input#username:placeholder-shown::placeholder { content: 'Enter your email'; }
input#password:placeholder-shown::placeholder { content: 'Enter your password'; }
/* ========== FIX FORM LAYOUT ========== */
#kc-form-login,
#kc-register-form,
#kc-reset-password-form {
display: flex !important;
flex-direction: column !important;
}
/* Form groups (inputs) - first */
.form-group {
order: 1 !important;
}
/* Remember me / Forgot password settings row - between inputs and button */
.login-pf-settings {
order: 2 !important;
}
/* Submit button - last */
#kc-form-buttons {
order: 3 !important;
}
/* ========== KC-INFO SECTION ========== */
#kc-info {
text-align: center !important;
margin-top: 20px !important;
}
#kc-info-wrapper {
text-align: center !important;
}
#kc-info a {
color: var(--purple-light) !important;
font-weight: 500 !important;
}
/* ========== EMAIL VERIFICATION PAGE ========== */
#kc-content,
#kc-content-wrapper {
text-align: center !important;
}
/* Purple box for main instruction */
.verify-email-box {
background: rgba(139, 92, 246, 0.1) !important;
border: 1px solid rgba(139, 92, 246, 0.3) !important;
border-radius: 12px !important;
padding: 28px !important;
margin: 0 0 24px 0 !important;
text-align: center !important;
}
.verify-email-box .instruction {
text-align: center !important;
margin: 0 !important;
color: var(--text-primary) !important;
}
.verify-email-box .instruction.auto-check {
margin-top: 20px !important;
font-size: 13px !important;
color: var(--text-muted) !important;
}
.instruction {
text-align: center !important;
font-size: 15px;
color: var(--text-secondary);
line-height: 1.6;
margin: 0 0 16px 0;
}
/* Haven't received section - also in purple box */
#kc-info .instruction {
margin: 0 !important;
text-align: center !important;
}
/* Loader animation for auto-check */
.loader {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(139, 92, 246, 0.3);
border-top-color: var(--purple-main);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-left: 8px;
vertical-align: middle;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ========== FALLBACK CENTERING (for older browsers) ========== */
@supports not (min-height: 100dvh) {
.login-pf .container-fluid,
.login-pf .container {
min-height: 100vh !important;
min-height: calc(var(--vh, 1vh) * 100) !important;
}
}
/* ========== MOBILE SPECIFIC FIXES ========== */
@media screen and (max-width: 768px) {
.login-pf .container-fluid,
.login-pf .container {
padding: 12px !important;
min-height: 100vh !important;
min-height: 100dvh !important;
}
.card-pf {
max-width: 100%;
padding: 8px;
margin: 0 auto !important;
}
.card-pf::before {
font-size: 40px;
margin-bottom: 4px;
}
.card-pf::after {
font-size: 13px;
margin-bottom: 20px;
}
/* Prevent horizontal overflow */
#kc-form,
#kc-register-form {
max-width: calc(100vw - 40px);
}
}
/* ========== iOS SAFARI SPECIFIC ========== */
@supports (-webkit-touch-callout: none) {
.login-pf .container-fluid,
.login-pf .container {
min-height: -webkit-fill-available !important;
}
/* Fix iOS input zoom */
input[type="text"],
input[type="password"],
input[type="email"],
.form-control {
font-size: 16px !important;
}
}
/* ========== PatternFly v4 input-group fix (Keycloak 26) ========== */
/* Password input wrapped in .pf-c-input-group for eye-icon toggle.
PF4 specificity overrides our base input rules force theme. */
.pf-c-input-group {
background: transparent !important;
border-radius: var(--radius-button) !important;
overflow: hidden;
border: 1px solid var(--border) !important;
}
.pf-c-input-group:hover {
border-color: var(--border-hover) !important;
}
.pf-c-input-group:focus-within {
border-color: var(--border-focus) !important;
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.15) !important;
}
.pf-c-input-group .pf-c-form-control,
.pf-c-input-group input[type="password"],
.pf-c-input-group input[type="text"],
.pf-c-input-group input[type="email"] {
background: var(--surface) !important;
color: var(--text-primary) !important;
border: none !important;
border-radius: 0 !important;
box-shadow: none !important;
}
.pf-c-input-group .pf-c-button.pf-m-control {
background: var(--surface) !important;
color: var(--text-primary) !important;
border: none !important;
padding: 0 16px !important;
}
.pf-c-input-group .pf-c-button.pf-m-control:hover {
background: var(--surface-hover) !important;
color: var(--accent) !important;
}
.pf-c-input-group .pf-c-button.pf-m-control .fa {
color: var(--text-muted);
}
/* Eye-icon button: kill all PF4 borders/shadows */
.pf-c-input-group .pf-c-button,
.pf-c-input-group .pf-c-button.pf-m-control {
border: 0 !important;
border-left: 1px solid rgba(255, 255, 255, 0.04) !important;
outline: 0 !important;
box-shadow: none !important;
border-radius: 0 !important;
}
.pf-c-input-group .pf-c-button:focus,
.pf-c-input-group .pf-c-button:hover {
outline: 0 !important;
box-shadow: none !important;
}
/* Force ALL borders off on eye button (override previous border-left) */
.pf-c-input-group > .pf-c-button,
.pf-c-input-group > .pf-c-button.pf-m-control {
border: 0 !important;
border-left: 0 !important;
border-right: 0 !important;
border-top: 0 !important;
border-bottom: 0 !important;
outline: 0 !important;
box-shadow: none !important;
border-radius: 0 !important;
background: var(--surface) !important;
--pf-c-button--BorderColor: transparent !important;
--pf-c-button--BorderWidth: 0 !important;
--pf-c-button--m-control--BorderBottomColor: transparent !important;
}
/* THE actual chenar comes from ::after pseudo-element (PF4 pattern) */
.pf-c-input-group .pf-c-button::after,
.pf-c-input-group .pf-c-button.pf-m-control::after,
.pf-c-input-group .pf-c-button:after,
.pf-c-input-group .pf-c-button.pf-m-control:after {
display: none !important;
border: 0 !important;
content: none !important;
}

View file

@ -0,0 +1,28 @@
// Add placeholders to Keycloak forms
document.addEventListener('DOMContentLoaded', function() {
// Common fields
var username = document.getElementById('username');
if (username) username.placeholder = 'Enter your email';
var email = document.getElementById('email');
if (email) email.placeholder = 'Enter your email';
// Detect register page by presence of password-confirm
var passwordConfirm = document.getElementById('password-confirm');
var password = document.getElementById('password');
if (password) {
password.placeholder = passwordConfirm ? 'Create a password' : 'Enter your password';
}
if (passwordConfirm) {
passwordConfirm.placeholder = 'Confirm password';
}
// Register-only fields
var firstName = document.getElementById('firstName');
if (firstName) firstName.placeholder = 'First name';
var lastName = document.getElementById('lastName');
if (lastName) lastName.placeholder = 'Last name';
});

View file

@ -0,0 +1,12 @@
# Inherit from keycloak to get templates
parent=keycloak
# Override styles
styles=css/login.css
# Scripts
scripts=js/placeholders.js
# Messages
displayName=DIDI - Misinformation Detection Platform
displayNameHtml=<strong>DIDI</strong> - Detect. Investigate. Decide. Inform.