diff --git a/AGENTS.md b/AGENTS.md index f83e693..43892ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ Online Mancala (the Turkish *Mangala* rules) with a web app, an Android/iOS app | `apps/backend` | Express + `ws`: matchmaking, games, auth, saved games, HTTP API | | `apps/frontend` | React web app, built with Parcel | | `apps/mobile` | React Native app | -| `apps/keycloak` | Keycloak image with the realm and a Java extension for guest login | +| `apps/keycloak` | Keycloak image with the realm, the login theme and a Java extension for guest login | | `apps/postgres` | Init script creating the `keycloak` and `mancala` databases | | `apps/compose.yaml` | Postgres, Valkey, Keycloak, backend, frontend | | `config/nginx.conf` | The production server's nginx config (Certbot managed) | @@ -108,6 +108,9 @@ There is no automated end-to-end suite. Changes to the game flow were checked by - The guest authenticator uses an internal Keycloak SPI: keep the version in `extension/pom.xml` and `Dockerfile` in sync and run `mvn test` when upgrading. - Keycloak sets `Secure` cookies; browsers send them to `http://localhost`, simple HTTP clients do not. +- The login theme (`apps/keycloak/themes/mancala`) is only CSS on top of `keycloak.v2`. Keycloak in `start` mode caches + themes and keeps gzipped resources in `data/tmp/kc-gzip-cache` across restarts; see `apps/keycloak/README.md` for + reloading it in a running container. **Mobile** - `apps/mobile/release_android.sh` contains signing passwords (see `TODO.md`); do not copy them anywhere. diff --git a/TODO.md b/TODO.md index 7ab0e8c..b14ca5a 100644 --- a/TODO.md +++ b/TODO.md @@ -34,6 +34,8 @@ The web app has game history (see `docs/specs/game-history`); the mobile app nee - [ ] Let guests turn their guest user into an account without changing identity (Keycloak required action) - [ ] Statistics for logged in users (wins, losses), now that games are saved - [ ] Social logins (Keycloak identity providers) +- [ ] Keycloak email theme in the game's look (password reset emails still use Keycloak's) +- [ ] Login pages follow the system color scheme; they could follow the theme chosen in the app instead - [ ] Restrict the Keycloak admin console in production ## Game history diff --git a/apps/frontend/src/auth/AuthService.ts b/apps/frontend/src/auth/AuthService.ts index 6629a18..0ad08df 100644 --- a/apps/frontend/src/auth/AuthService.ts +++ b/apps/frontend/src/auth/AuthService.ts @@ -1,3 +1,4 @@ +import i18n from "i18next"; import { User, UserManager, WebStorageStateStore } from "oidc-client-ts"; const CALLBACK_PATH = "/auth/callback"; @@ -68,11 +69,11 @@ export class AuthService { // prompt=login shows the login form even though the guest already has a session. public login(returnTo = currentPath()): Promise { - return this.userManager.signinRedirect({ prompt: "login", state: { returnTo } }); + return this.userManager.signinRedirect({ prompt: "login", ui_locales: i18n.language, state: { returnTo } }); } public signUp(returnTo = currentPath()): Promise { - return this.userManager.signinRedirect({ prompt: "create", state: { returnTo } }); + return this.userManager.signinRedirect({ prompt: "create", ui_locales: i18n.language, state: { returnTo } }); } public logout(): Promise { diff --git a/apps/keycloak/Dockerfile b/apps/keycloak/Dockerfile index e3d9541..01512b6 100644 --- a/apps/keycloak/Dockerfile +++ b/apps/keycloak/Dockerfile @@ -18,5 +18,6 @@ FROM quay.io/keycloak/keycloak:26.7.5 COPY --from=builder /opt/keycloak/ /opt/keycloak/ # Imported on startup only if the realm does not exist yet. COPY realm/mancala-realm.json /opt/keycloak/data/import/ +COPY themes/ /opt/keycloak/themes/ ENTRYPOINT ["/opt/keycloak/bin/kc.sh"] CMD ["start", "--optimized", "--import-realm"] diff --git a/apps/keycloak/README.md b/apps/keycloak/README.md index b0a5c21..e96e678 100644 --- a/apps/keycloak/README.md +++ b/apps/keycloak/README.md @@ -33,6 +33,24 @@ are not asked for an email or a name. Keycloak imports the file only if the realm does not exist yet. After changing it, apply the change in the admin console too, or delete the realm and restart Keycloak. +## Login theme + +`themes/mancala/login` gives the login, registration and password reset pages the game's look: its app bar and logo, +the board's colors and a board with stones on top of the card. It extends Keycloak's `keycloak.v2` theme and only +adds `resources/css/mancala.css`, so its templates and behaviour stay Keycloak's own and upgrades need no template +changes. The colors come from `LightTheme` and `DarkTheme` in `packages/core/src/theme`; the dark ones are used when +the system prefers a dark color scheme. The realm turns the theme on (`loginTheme`) and offers English and Turkish; +the web app passes its language as `ui_locales`. + +To try a change without rebuilding the image, copy the theme into the running container, clear Keycloak's gzip cache +(it survives restarts) and restart: + +```sh +docker cp apps/keycloak/themes/mancala apps-keycloak-1:/opt/keycloak/themes/ +docker exec apps-keycloak-1 rm -rf /opt/keycloak/data/tmp/kc-gzip-cache +docker restart apps-keycloak-1 +``` + ## Running ```sh @@ -59,7 +77,8 @@ cd apps/keycloak/extension mvn test ``` -The tests start Keycloak with the extension and the real realm file in a container, so they need Docker. +The tests start Keycloak with the extension, the real realm file and the login theme in a container, so they need +Docker. ## Upgrading Keycloak diff --git a/apps/keycloak/extension/src/test/java/app/playmancala/keycloak/guest/GuestLoginTest.java b/apps/keycloak/extension/src/test/java/app/playmancala/keycloak/guest/GuestLoginTest.java index 115c46b..5bfb016 100644 --- a/apps/keycloak/extension/src/test/java/app/playmancala/keycloak/guest/GuestLoginTest.java +++ b/apps/keycloak/extension/src/test/java/app/playmancala/keycloak/guest/GuestLoginTest.java @@ -27,6 +27,7 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.keycloak.admin.client.resource.RealmResource; import org.keycloak.representations.idm.UserRepresentation; +import org.testcontainers.utility.MountableFile; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -34,7 +35,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import dasniko.testcontainers.keycloak.KeycloakContainer; /** - * Runs the extension and the real realm export in a Keycloak container. Needs Docker. + * Runs the extension, the real realm export and the login theme in a Keycloak container. Needs Docker. */ class GuestLoginTest { @@ -50,6 +51,7 @@ class GuestLoginTest { keycloak = new KeycloakContainer("quay.io/keycloak/keycloak:26.7.5") .withProviderClassesFrom("target/classes") .withRealmImportFile("/mancala-realm.json") + .withCopyFileToContainer(MountableFile.forHostPath("../themes/mancala"), "/opt/keycloak/themes/mancala") .withEnv("KC_SPI_AUTHENTICATOR__MANCALA_GUEST_AUTHENTICATOR__CLEANUP_INTERVAL_SECONDS", "2") .withEnv("KC_SPI_AUTHENTICATOR__MANCALA_GUEST_AUTHENTICATOR__CLEANUP_MIN_AGE_SECONDS", "0"); keycloak.start(); @@ -87,6 +89,17 @@ class GuestLoginTest { assertTrue(response.body().contains("id=\"kc-form-login\"")); } + @Test + void loginFormUsesTheMancalaThemeInTheRequestedLanguage() throws Exception { + HttpResponse response = newHttpClient().send( + HttpRequest.newBuilder(authorizationUri(Map.of("ui_locales", "tr"), randomUrlSafe(32))).GET().build(), + HttpResponse.BodyHandlers.ofString()); + + assertEquals(200, response.statusCode()); + assertTrue(response.body().contains("/login/mancala/css/mancala.css"), "mancala stylesheet missing"); + assertTrue(response.body().contains("lang=\"tr\""), "page is not in Turkish"); + } + @Test void cleanupRemovesGuestsWithoutSessionsAndKeepsEveryoneElse() throws Exception { String activeGuest = decodeJwt(loginAsGuest().get("access_token").asText()).get("preferred_username").asText(); diff --git a/apps/keycloak/realm/mancala-realm.json b/apps/keycloak/realm/mancala-realm.json index 38007f4..181f2e1 100644 --- a/apps/keycloak/realm/mancala-realm.json +++ b/apps/keycloak/realm/mancala-realm.json @@ -11,6 +11,10 @@ "rememberMe": true, "verifyEmail": false, "bruteForceProtected": true, + "loginTheme": "mancala", + "internationalizationEnabled": true, + "supportedLocales": ["en", "tr"], + "defaultLocale": "en", "offlineSessionIdleTimeout": 2592000, "browserFlow": "browser with guest", "requiredActions": [ diff --git a/apps/keycloak/themes/mancala/login/resources/css/mancala.css b/apps/keycloak/themes/mancala/login/resources/css/mancala.css new file mode 100644 index 0000000..86d0b7d --- /dev/null +++ b/apps/keycloak/themes/mancala/login/resources/css/mancala.css @@ -0,0 +1,294 @@ +/* + * Mancala look for keycloak.v2's login pages. Loaded after keycloak.v2's styles.css. + * Colors come from LightTheme and DarkTheme in packages/core/src/theme; text colors are darker (or lighter) than + * the game's where the game's own would not be readable on the board color. + * keycloak.v2 adds pf-v5-theme-dark to when the system prefers a dark color scheme. + */ + +:root { + --mancala-bg: #bbbbbb; + --mancala-bar: #7b7b7b; + --mancala-bar-text: #ebebeb; + --mancala-board: #9b9b9b; + --mancala-pit: #c4c4c4; + --mancala-pit-readonly: #b0b0b0; + --mancala-pit-edge: #4d4d4d; + --mancala-pit-shadow: rgba(0, 0, 0, 0.22); + --mancala-stone: #5b5b5b; + --mancala-stone-hover: #474747; + --mancala-stone-text: #f2f2f2; + --mancala-ink: #262626; + --mancala-ink-soft: #333333; + --mancala-ink-strong: #000000; + --mancala-hover: rgba(0, 0, 0, 0.08); + --mancala-divider: #6b6b6b; + --mancala-danger: #6b0900; + --mancala-board-image: url("../img/board.svg"); + --mancala-bar-height: 57px; + --mancala-font: serif; + + --pf-v5-global--FontFamily--text: var(--mancala-font); + --pf-v5-global--FontFamily--heading: var(--mancala-font); + --pf-v5-global--Color--100: var(--mancala-ink); + --pf-v5-global--Color--200: var(--mancala-ink-soft); + --pf-v5-global--link--Color: var(--mancala-ink); + --pf-v5-global--link--Color--hover: var(--mancala-ink-strong); + --pf-v5-global--danger-color--100: var(--mancala-danger); +} + +:root.pf-v5-theme-dark { + --mancala-bg: #414141; + --mancala-bar: #313131; + --mancala-bar-text: #aaaaaa; + --mancala-board: #313131; + --mancala-pit: #606060; + --mancala-pit-readonly: #4b4b4b; + --mancala-pit-edge: #8a8a8a; + --mancala-pit-shadow: rgba(0, 0, 0, 0.45); + --mancala-stone: #aaaaaa; + --mancala-stone-hover: #bdbdbd; + --mancala-stone-text: #1e1e1e; + --mancala-ink: #e0e0e0; + --mancala-ink-soft: #bbbbbb; + --mancala-ink-strong: #ffffff; + --mancala-hover: rgba(255, 255, 255, 0.08); + --mancala-divider: #555555; + --mancala-danger: #ff8a80; + --mancala-board-image: url("../img/board-dark.svg"); +} + +.login-pf body { + background: var(--mancala-bg); + font-family: var(--mancala-font); +} + +/* Header: the game's app bar with its logo and title */ + +#kc-header { + position: fixed; + inset: 0 0 auto 0; + z-index: 10; + height: var(--mancala-bar-height); + margin: 0; + padding: 0 4vw; + display: flex; + align-items: center; + background: var(--mancala-bar); +} + +#kc-header-wrapper { + display: flex; + align-items: center; + gap: 10px; + margin: 0; + padding: 0; + font-family: var(--mancala-font); + font-size: 2em; + font-weight: bold; + line-height: 1; + letter-spacing: normal; + text-transform: none; + text-align: start; + color: var(--mancala-bar-text) !important; +} + +#kc-header-wrapper::before { + content: ""; + flex: none; + width: 30px; + height: 30px; + background: url("../img/logo.svg") center / contain no-repeat; +} + +.pf-v5-c-login { + min-height: 100vh; + padding-block-start: calc(var(--mancala-bar-height) + 2rem); + padding-block-end: 2rem; +} + +.pf-v5-c-login__container { + max-width: calc(30rem + 32px); + padding-inline: 16px; + grid-template-columns: minmax(0, 30rem); +} + +/* Card: the board, with a small board at the start of a game on top */ + +.pf-v5-c-login__main { + --pf-v5-c-login__main--BackgroundColor: var(--mancala-board); + --pf-v5-c-login__main--BoxShadow: 0 8px 24px rgba(0, 0, 0, 0.18); + --pf-v5-c-login__main-footer-band--BorderTopColor: var(--mancala-divider); + padding-block-start: 2rem; + border-radius: 24px; + color: var(--mancala-ink); +} + +.pf-v5-c-login__main::before { + content: ""; + display: block; + width: min(calc(100% - 4rem), 260px); + aspect-ratio: 312 / 76; + margin: 0 auto; + background: var(--mancala-board-image) center / contain no-repeat; +} + +.pf-v5-c-login__main-header { + --pf-v5-c-login__main-header--PaddingTop: 1.5rem; + border-top: none; +} + +#kc-page-title { + color: var(--mancala-ink); + font-family: var(--mancala-font); + font-size: 1.5rem; + font-weight: bold; +} + +.pf-v5-c-login__main hr { + border-color: var(--mancala-divider); +} + +.pf-v5-c-login__main a:not(.pf-v5-c-button) { + color: var(--mancala-ink); + text-decoration: underline; +} + +.pf-v5-c-login__main a:not(.pf-v5-c-button):hover { + color: var(--mancala-ink-strong); +} + +/* Inputs: pits */ + +.pf-v5-c-form-control { + --pf-v5-c-form-control--BackgroundColor: var(--mancala-pit); + --pf-v5-c-form-control--Color: var(--mancala-ink); + --pf-v5-c-form-control--m-placeholder--Color: var(--mancala-ink-soft); + --pf-v5-c-form-control--m-readonly--BackgroundColor: var(--mancala-pit-readonly); + --pf-v5-c-form-control__toggle-icon--Color: var(--mancala-ink); + --pf-v5-c-form-control--PaddingLeft: 1rem; + --pf-v5-c-form-control--PaddingRight: 1rem; + border: 1px solid var(--mancala-pit-edge); + border-radius: 999px; + box-shadow: inset 0 2px 5px var(--mancala-pit-shadow); + font-family: var(--mancala-font); +} + +.pf-v5-c-form-control:has(textarea) { + border-radius: 16px; +} + +.pf-v5-c-form-control::before, +.pf-v5-c-form-control::after { + content: none; +} + +.pf-v5-c-form-control > :is(input, select, textarea) { + font-family: inherit; + background-color: transparent; + border-radius: inherit; +} + +.pf-v5-c-form-control > select { + padding-inline-start: 1rem; +} + +.pf-v5-c-form-control > :is(input, select, textarea):focus { + outline: none; +} + +.pf-v5-c-form-control:hover { + border-color: var(--mancala-ink); +} + +.pf-v5-c-form-control:focus-within { + border-color: var(--mancala-ink); + outline: 2px solid var(--mancala-ink); + outline-offset: 2px; +} + +.pf-v5-c-form-control.pf-m-error { + border-color: var(--mancala-danger); + box-shadow: inset 0 2px 5px var(--mancala-pit-shadow), 0 0 0 1px var(--mancala-danger); +} + +.pf-v5-c-login__main .pf-v5-c-helper-text__item.pf-m-error, +.pf-v5-c-login__main .pf-v5-c-helper-text__item.pf-m-error .pf-v5-c-helper-text__item-text, +.pf-v5-c-login__main .pf-v5-c-helper-text__item-text.pf-m-error { + color: var(--mancala-danger); +} + +/* Password field and its show button form one pill */ + +.pf-v5-c-input-group > .pf-v5-c-input-group__item:first-child:not(:last-child) .pf-v5-c-form-control { + border-start-end-radius: 0; + border-end-end-radius: 0; +} + +.pf-v5-c-input-group > .pf-v5-c-input-group__item:last-child:not(:first-child) .pf-v5-c-button { + border-radius: 0 999px 999px 0; +} + +/* Buttons: pill shaped like the game's */ + +.pf-v5-c-button { + --pf-v5-c-button--BorderRadius: 999px; + --pf-v5-c-button--after--BorderRadius: 999px; + + --pf-v5-c-button--m-primary--BackgroundColor: var(--mancala-stone); + --pf-v5-c-button--m-primary--hover--BackgroundColor: var(--mancala-stone-hover); + --pf-v5-c-button--m-primary--focus--BackgroundColor: var(--mancala-stone-hover); + --pf-v5-c-button--m-primary--active--BackgroundColor: var(--mancala-stone-hover); + --pf-v5-c-button--m-primary--Color: var(--mancala-stone-text); + --pf-v5-c-button--m-primary--hover--Color: var(--mancala-stone-text); + --pf-v5-c-button--m-primary--focus--Color: var(--mancala-stone-text); + --pf-v5-c-button--m-primary--active--Color: var(--mancala-stone-text); + + --pf-v5-c-button--m-secondary--Color: var(--mancala-ink); + --pf-v5-c-button--m-secondary--hover--Color: var(--mancala-ink); + --pf-v5-c-button--m-secondary--focus--Color: var(--mancala-ink); + --pf-v5-c-button--m-secondary--active--Color: var(--mancala-ink); + --pf-v5-c-button--m-secondary--after--BorderColor: var(--mancala-ink); + --pf-v5-c-button--m-secondary--hover--after--BorderColor: var(--mancala-ink); + --pf-v5-c-button--m-secondary--focus--after--BorderColor: var(--mancala-ink); + --pf-v5-c-button--m-secondary--active--after--BorderColor: var(--mancala-ink); + --pf-v5-c-button--m-secondary--hover--BackgroundColor: var(--mancala-hover); + --pf-v5-c-button--m-secondary--focus--BackgroundColor: var(--mancala-hover); + --pf-v5-c-button--m-secondary--active--BackgroundColor: var(--mancala-hover); + + --pf-v5-c-button--m-link--Color: var(--mancala-ink); + --pf-v5-c-button--m-link--hover--Color: var(--mancala-ink-strong); + --pf-v5-c-button--m-link--focus--Color: var(--mancala-ink-strong); + --pf-v5-c-button--m-link--active--Color: var(--mancala-ink-strong); + + --pf-v5-c-button--m-control--BackgroundColor: var(--mancala-pit); + --pf-v5-c-button--m-control--hover--BackgroundColor: var(--mancala-pit); + --pf-v5-c-button--m-control--focus--BackgroundColor: var(--mancala-pit); + --pf-v5-c-button--m-control--active--BackgroundColor: var(--mancala-pit); + --pf-v5-c-button--m-control--Color: var(--mancala-ink); + --pf-v5-c-button--m-control--hover--Color: var(--mancala-ink-strong); + --pf-v5-c-button--m-control--focus--Color: var(--mancala-ink-strong); + --pf-v5-c-button--m-control--active--Color: var(--mancala-ink-strong); + font-family: var(--mancala-font); +} + +.pf-v5-c-button.pf-m-primary { + font-weight: bold; +} + +.pf-v5-c-button.pf-m-control::after { + border: 1px solid var(--mancala-pit-edge); + border-radius: inherit; +} + +.pf-v5-c-button.pf-m-control:hover::after { + border-color: var(--mancala-ink); +} + +.pf-v5-c-button:focus-visible { + outline: 2px solid var(--mancala-ink); + outline-offset: 2px; +} + +.pf-v5-c-check__input { + accent-color: var(--mancala-stone); +} diff --git a/apps/keycloak/themes/mancala/login/resources/img/board-dark.svg b/apps/keycloak/themes/mancala/login/resources/img/board-dark.svg new file mode 100644 index 0000000..c1edcbd --- /dev/null +++ b/apps/keycloak/themes/mancala/login/resources/img/board-dark.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/keycloak/themes/mancala/login/resources/img/board.svg b/apps/keycloak/themes/mancala/login/resources/img/board.svg new file mode 100644 index 0000000..2fad955 --- /dev/null +++ b/apps/keycloak/themes/mancala/login/resources/img/board.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/keycloak/themes/mancala/login/resources/img/favicon.ico b/apps/keycloak/themes/mancala/login/resources/img/favicon.ico new file mode 100644 index 0000000..df145b3 Binary files /dev/null and b/apps/keycloak/themes/mancala/login/resources/img/favicon.ico differ diff --git a/apps/keycloak/themes/mancala/login/resources/img/logo.svg b/apps/keycloak/themes/mancala/login/resources/img/logo.svg new file mode 100644 index 0000000..cf65dfd --- /dev/null +++ b/apps/keycloak/themes/mancala/login/resources/img/logo.svg @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + diff --git a/apps/keycloak/themes/mancala/login/theme.properties b/apps/keycloak/themes/mancala/login/theme.properties new file mode 100644 index 0000000..ec25cc5 --- /dev/null +++ b/apps/keycloak/themes/mancala/login/theme.properties @@ -0,0 +1,6 @@ +# Login pages in the look of the game. Keeps keycloak.v2's templates and only adds a stylesheet, +# so Keycloak upgrades do not need template changes. +parent=keycloak.v2 +import=common/keycloak + +styles=css/styles.css css/mancala.css