Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3

Merged
jhalitaksoy merged 88 commits from develop into main 2026-10-17 03:23:38 +03:00
12 changed files with 473 additions and 3 deletions
Showing only changes of commit d52960fa0a - Show all commits

View File

@ -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) |
@ -96,6 +96,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.

View File

@ -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

View File

@ -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"]

View File

@ -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

View File

@ -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<String> 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();

View File

@ -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": [

View File

@ -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 <html> 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);
}

View File

@ -0,0 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 312 76">
<!-- A board at the start of a game: two stores and 2 x 6 pits with four stones each. Colors from DarkTheme. -->
<defs>
<g id="pit">
<circle r="16" fill="#606060"/>
<circle cx="-7.5" r="2.2" fill="#252525"/>
<circle cx="-2.5" r="2.2" fill="#252525"/>
<circle cx="2.5" r="2.2" fill="#252525"/>
<circle cx="7.5" r="2.2" fill="#252525"/>
</g>
</defs>
<rect x="0" y="0" width="28" height="76" rx="14" fill="#606060"/>
<rect x="284" y="0" width="28" height="76" rx="14" fill="#606060"/>
<use xlink:href="#pit" x="56" y="18"/>
<use xlink:href="#pit" x="96" y="18"/>
<use xlink:href="#pit" x="136" y="18"/>
<use xlink:href="#pit" x="176" y="18"/>
<use xlink:href="#pit" x="216" y="18"/>
<use xlink:href="#pit" x="256" y="18"/>
<use xlink:href="#pit" x="56" y="58"/>
<use xlink:href="#pit" x="96" y="58"/>
<use xlink:href="#pit" x="136" y="58"/>
<use xlink:href="#pit" x="176" y="58"/>
<use xlink:href="#pit" x="216" y="58"/>
<use xlink:href="#pit" x="256" y="58"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 312 76">
<!-- A board at the start of a game: two stores and 2 x 6 pits with four stones each. Colors from LightTheme. -->
<defs>
<g id="pit">
<circle r="16" fill="#B8B8B8"/>
<circle cx="-7.5" r="2.2" fill="#5B5B5B"/>
<circle cx="-2.5" r="2.2" fill="#5B5B5B"/>
<circle cx="2.5" r="2.2" fill="#5B5B5B"/>
<circle cx="7.5" r="2.2" fill="#5B5B5B"/>
</g>
</defs>
<rect x="0" y="0" width="28" height="76" rx="14" fill="#B8B8B8"/>
<rect x="284" y="0" width="28" height="76" rx="14" fill="#B8B8B8"/>
<use xlink:href="#pit" x="56" y="18"/>
<use xlink:href="#pit" x="96" y="18"/>
<use xlink:href="#pit" x="136" y="18"/>
<use xlink:href="#pit" x="176" y="18"/>
<use xlink:href="#pit" x="216" y="18"/>
<use xlink:href="#pit" x="256" y="18"/>
<use xlink:href="#pit" x="56" y="58"/>
<use xlink:href="#pit" x="96" y="58"/>
<use xlink:href="#pit" x="136" y="58"/>
<use xlink:href="#pit" x="176" y="58"/>
<use xlink:href="#pit" x="216" y="58"/>
<use xlink:href="#pit" x="256" y="58"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@ -0,0 +1,76 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="100.00002mm"
height="100.00001mm"
viewBox="0 0 100.00002 100.00001"
version="1.1"
id="svg316"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview318"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="false" />
<defs
id="defs313" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-230.50606,-73.754324)">
<rect
style="fill:#b3b3b3;stroke-width:1.03151"
id="rect489-5-7-8"
width="100"
height="100"
x="230.50607"
y="73.754326"
ry="27.328773"
inkscape:export-filename="mancala.svg"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96" />
<rect
style="fill:#666666;fill-opacity:1;stroke-width:0.747434"
id="rect642-6-2-0-7"
width="35"
height="35"
x="281.50607"
y="87.754311"
ry="17.5" />
<rect
style="fill:#b3b3b3;stroke-width:0.747434"
id="rect642-6-7-9-9-9"
width="35"
height="35"
x="244.50607"
y="87.754311"
ry="17.5" />
<rect
style="fill:#666666;fill-opacity:1;stroke:none;stroke-width:0.747434;stroke-opacity:1"
id="rect642-6-2-1-3-2"
width="35"
height="35"
x="281.50607"
y="124.75433"
ry="17.5" />
<rect
style="fill:#666666;fill-opacity:1;stroke-width:1.05044"
id="rect1550-0"
width="35"
height="70"
x="244.50607"
y="87.754311"
ry="17.5" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -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