livrare lot 2
This commit is contained in:
commit
8ecc78e729
763 changed files with 164593 additions and 0 deletions
362
backend/services/gateway-auth-layer/didiKeycloak/INDEX.md
Normal file
362
backend/services/gateway-auth-layer/didiKeycloak/INDEX.md
Normal 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.
|
||||
203
backend/services/gateway-auth-layer/didiKeycloak/MIGRATION.md
Normal file
203
backend/services/gateway-auth-layer/didiKeycloak/MIGRATION.md
Normal 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.
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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 |
|
|
@ -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>
|
||||
|
|
@ -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 |
|
|
@ -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>
|
||||
|
|
@ -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;">© 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>
|
||||
|
|
@ -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;">© 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>
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1 @@
|
|||
parent=base
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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...
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
});
|
||||
|
|
@ -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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue