diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3851214..c2e1acd 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -13,9 +13,19 @@ jobs: steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + cache: maven - uses: bahmutov/npm-install@v1 - run: npm run check-translations + - run: npm run lint + - run: npm run typecheck - run: npm run build-keycloak-theme + - name: Build Keycloak SPIs + working-directory: keycloak-extensions + run: mvn -B -DskipTests package check_if_version_upgraded: name: Check if version upgrade @@ -41,8 +51,24 @@ jobs: steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + cache: maven - uses: bahmutov/npm-install@v1 - run: npm run build-keycloak-theme + - name: Build Keycloak SPIs + working-directory: keycloak-extensions + run: mvn -B -DskipTests package + - name: Collect release artifacts + run: | + mkdir -p release-artifacts + cp dist_keycloak/keycloak-theme-*.jar release-artifacts/ + cp keycloak-extensions/turnstile-authenticator/target/helpwave-turnstile-authenticator-*.jar release-artifacts/ + cp keycloak-extensions/privacy-acceptance/target/helpwave-privacy-acceptance-*.jar release-artifacts/ + cp keycloak-extensions/profile-picture/target/helpwave-profile-picture-*.jar release-artifacts/ + rm -f release-artifacts/original-*.jar - uses: softprops/action-gh-release@v2 with: name: Release v${{ needs.check_if_version_upgraded.outputs.to_version }} @@ -51,6 +77,6 @@ jobs: generate_release_notes: true draft: false prerelease: ${{ needs.check_if_version_upgraded.outputs.is_pre_release == 'true' }} - files: dist_keycloak/keycloak-theme-*.jar + files: release-artifacts/*.jar env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index e185cde..3192460 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,7 @@ jspm_packages # build output of `jsx-email` /.rendered .cursor + +# Maven build output for keycloak-extensions +keycloak-extensions/**/target/ +keycloak-extensions/**/dependency-reduced-pom.xml diff --git a/README.md b/README.md index f417725..f6417b0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # [id.helpwave.de](https://id.helpwave.de) -Keycloak login theme using helpwave hightide components. +Keycloak login theme using helpwave hightide components, plus the Keycloak SPI +extensions that power the registration flow (Cloudflare Turnstile + privacy +acceptance) and the profile picture upload. ## Quick start @@ -25,12 +27,30 @@ npm run build-keycloak-theme This will generate the theme jar files in `dist_keycloak/`. -Note: You need [Maven](https://maven.apache.org/) installed to build the theme (Maven >= 3.1.1, Java >= 7). +Note: You need [Maven](https://maven.apache.org/) installed to build the theme (Maven >= 3.1.1, Java >= 17). - On macOS: `brew install maven` - On Debian/Ubuntu: `sudo apt-get install maven` - On Windows: `choco install openjdk` and `choco install maven` +### Building the Keycloak SPIs + +The Java extensions live under `keycloak-extensions/` and are built with Maven: + +```bash +cd keycloak-extensions +mvn -DskipTests package +``` + +This produces three jars: + +- `turnstile-authenticator/target/helpwave-turnstile-authenticator-.jar` +- `privacy-acceptance/target/helpwave-privacy-acceptance-.jar` +- `profile-picture/target/helpwave-profile-picture-.jar` (shaded with AWS SDK + Thumbnailator) + +Drop all three (alongside the theme jar) into Keycloak's `providers/` directory and run +`kc.sh build`. + ## Local development with Docker Start keycloak and postgres services: @@ -40,12 +60,14 @@ docker compose up ``` This will: + - Start postgres database - Start keycloak on port 8080 - Import realms from `keycloak/import/` - Mount the theme jar from `dist_keycloak/` Default admin credentials: + - Username: `admin` - Password: `admin` @@ -67,3 +89,117 @@ For nixos users, see [docs/nixos.md](docs/nixos.md) for nix-shell setup instruct - Realm indicator chip with deterministic color mapping - Custom login, register, and forgot password pages - Field-level validation matching hightide patterns +- **Cloudflare Turnstile** CAPTCHA on signup (`helpwave-turnstile` FormAction SPI) +- **Privacy policy** checkbox on signup with acceptance metadata stored on the user + (`helpwave-privacy-acceptance` FormAction SPI) +- **Profile picture upload** with server-side scaling to multiple sizes and storage in any + S3-compatible bucket (`helpwave-picture` Realm Resource SPI) + +--- + +## Deployment + +The release workflow publishes the following jars on every version bump in `package.json`: + +| Jar | Purpose | +|--------------------------------------------------|--------------------------------------------------| +| `keycloak-theme-for-kc-26.2-and-above.jar` | The login/account theme | +| `helpwave-turnstile-authenticator-.jar` | Cloudflare Turnstile registration form action | +| `helpwave-privacy-acceptance-.jar` | Privacy acceptance form action + attribute store | +| `helpwave-profile-picture-.jar` | Profile picture REST endpoint + R2/S3 upload | + +Copy all jars into Keycloak's `providers/` directory (or mount them into the container) +and run `kc.sh build` to rebuild the runtime, then start Keycloak normally. + +### 1. Enable the Cloudflare Turnstile and Privacy form actions + +1. Open the Keycloak admin console. +2. Go to **Authentication** → **Flows** and duplicate the built-in **registration** flow. +3. In your new copy, add two executions to the *registration form*: + - `Cloudflare Turnstile (helpwave)` — set to **Required** + - `Privacy Policy Acceptance (helpwave)` — set to **Required** +4. Click the gear on each execution to configure it: + - **Turnstile**: set the `Turnstile site key` (public) and `Turnstile secret` (private). + Get these from . + - **Privacy**: set the `Privacy policy URL` (defaults to `https://helpwave.de/privacy`) + and an optional `Privacy policy version` string. Both are persisted on the user as + `privacy_policy_accepted_at` and `privacy_policy_version` attributes. +5. Set this flow as the realm's **Registration flow** binding. + +### 2. Configure the profile picture storage + +The profile picture SPI accepts standard AWS S3 or Cloudflare R2 (any S3-compatible +backend). It exposes itself at: + +``` +/realms/{realm}/helpwave-picture +``` + +`POST` the raw image bytes (`Content-Type: image/jpeg|png|webp`, or `multipart/form-data` +from a ``) with a Bearer access token. The endpoint scales the image to +512/256/128/64 px JPEGs and writes them to the bucket. The primary URL is stored on the +user as the standard OpenID Connect `picture` attribute; thumbnail URLs as +`picture_thumb_64|128|256`. `DELETE` removes both the bucket objects and the attributes. + +Configuration is read from Keycloak SPI settings (preferred — see +[docs/deployment-nixos.md](docs/deployment-nixos.md) for `_secret` integration) or +environment variables as a fallback: + +| SPI key (`keycloak.conf` / NixOS `services.keycloak.settings`) | Env var fallback | Required | +|---------------------------------------------------------------------------|----------------------------------------|----------| +| `spi-realm-restapi-extension-helpwave-picture-endpoint` | `HELPWAVE_PICTURE_ENDPOINT` | R2 only | +| `spi-realm-restapi-extension-helpwave-picture-region` (def `auto`) | `HELPWAVE_PICTURE_REGION` | no | +| `spi-realm-restapi-extension-helpwave-picture-bucket` | `HELPWAVE_PICTURE_BUCKET` | yes | +| `spi-realm-restapi-extension-helpwave-picture-access-key` | `HELPWAVE_PICTURE_ACCESS_KEY` | yes | +| `spi-realm-restapi-extension-helpwave-picture-secret-key` | `HELPWAVE_PICTURE_SECRET_KEY` | yes | +| `spi-realm-restapi-extension-helpwave-picture-public-base-url` | `HELPWAVE_PICTURE_PUBLIC_BASE_URL` | yes | +| `spi-realm-restapi-extension-helpwave-picture-max-bytes` (def 5 MiB) | `HELPWAVE_PICTURE_MAX_BYTES` | no | + +#### Example: Cloudflare R2 (raw env vars) + +```bash +KC_SPI_REALM_RESTAPI_EXTENSION_HELPWAVE_PICTURE_ENDPOINT=https://.r2.cloudflarestorage.com +KC_SPI_REALM_RESTAPI_EXTENSION_HELPWAVE_PICTURE_REGION=auto +KC_SPI_REALM_RESTAPI_EXTENSION_HELPWAVE_PICTURE_BUCKET=helpwave-id-avatars +KC_SPI_REALM_RESTAPI_EXTENSION_HELPWAVE_PICTURE_ACCESS_KEY=... +KC_SPI_REALM_RESTAPI_EXTENSION_HELPWAVE_PICTURE_SECRET_KEY=... +KC_SPI_REALM_RESTAPI_EXTENSION_HELPWAVE_PICTURE_PUBLIC_BASE_URL=https://cdn.helpwave.de/avatars +``` + +#### Example: AWS S3 (raw env vars) + +```bash +KC_SPI_REALM_RESTAPI_EXTENSION_HELPWAVE_PICTURE_REGION=eu-central-1 +KC_SPI_REALM_RESTAPI_EXTENSION_HELPWAVE_PICTURE_BUCKET=helpwave-id-avatars +KC_SPI_REALM_RESTAPI_EXTENSION_HELPWAVE_PICTURE_ACCESS_KEY=... +KC_SPI_REALM_RESTAPI_EXTENSION_HELPWAVE_PICTURE_SECRET_KEY=... +KC_SPI_REALM_RESTAPI_EXTENSION_HELPWAVE_PICTURE_PUBLIC_BASE_URL=https://avatars.helpwave.de +``` + +### 3. Wire the theme to the SPIs + +Two Keycloakify env vars expose the SPI to the theme at render time: + +| Env var | Purpose | +|--------------------------|-----------------------------------------------------------------------------| +| `TURNSTILE_SITE_KEY` | Optional fallback when the Turnstile authenticator config is not yet bound | +| `PROFILE_PICTURE_API_URL`| Full URL to `/realms//helpwave-picture` | + +Set them in the Keycloak container, e.g.: + +```bash +KC_TURNSTILE_SITE_KEY=0x4AAAAAAA... +KC_PROFILE_PICTURE_API_URL=https://id.helpwave.de/realms/customer/helpwave-picture +``` + +(Keycloakify reads `KC_` and exposes it as `kcContext.properties.`.) + +### 4. NixOS deployment + +A complete `services.keycloak` example with `_secret` file handling and the matching +admin-console steps lives in [docs/deployment-nixos.md](docs/deployment-nixos.md). + +### 5. Releases + +Bump `version` in `package.json` on `main`. The CI workflow builds the theme + SPIs and +publishes a GitHub release with all four jars attached. diff --git a/docs/deployment-nixos.md b/docs/deployment-nixos.md new file mode 100644 index 0000000..3276d16 --- /dev/null +++ b/docs/deployment-nixos.md @@ -0,0 +1,246 @@ +# NixOS deployment + +This guide shows how to deploy `id.helpwave.de` on a NixOS host using +`services.keycloak`, fetching the theme and SPI jars from a GitHub release and wiring +credentials via the standard `_secret` pattern. + +## 1. Jars published per release + +Every release of this repository attaches the following artifacts to its GitHub release +(see CI workflow `.github/workflows/ci.yaml`): + +| File | Source module | Purpose | +|---------------------------------------------------|-------------------------------|---------------------------------------------------------------| +| `keycloak-theme-for-kc-26.2-and-above.jar` | Keycloakify build | Login + account theme (`helpwave-id`). | +| `helpwave-turnstile-authenticator-.jar` | `turnstile-authenticator` | `FormAction` SPI: Cloudflare Turnstile CAPTCHA on signup. | +| `helpwave-privacy-acceptance-.jar` | `privacy-acceptance` | `FormAction` SPI: privacy checkbox + acceptance attributes. | +| `helpwave-profile-picture-.jar` | `profile-picture` | `RealmResourceProvider` SPI: avatar upload to S3 / R2. | + +`` is the SPI Maven version (`keycloak-extensions/pom.xml`, currently `0.1.0`) — it +is independent from the npm/theme version in `package.json`. + +All four jars are dropped into Keycloak's `providers/` directory. The +[`services.keycloak.plugins`][nixopts] option does exactly that for you. + +[nixopts]: https://search.nixos.org/options?channel=25.11&query=services.keycloak.plugins + +## 2. Full NixOS module example + +```nix +{ pkgs, config, ... }: +let + domain = "id.helpwave.de"; + + themeVersion = "0.2.0"; # package.json version → release tag v0.2.0 + spiVersion = "0.1.0"; # keycloak-extensions/pom.xml version + + release = ver: file: sha: + pkgs.fetchurl { + name = file; + url = "https://github.com/helpwave/id.helpwave.de/releases/download/v${ver}/${file}"; + sha256 = sha; + }; + + themePlugin = release themeVersion "keycloak-theme-for-kc-26.2-and-above.jar" + "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; + + turnstileSPI = release themeVersion "helpwave-turnstile-authenticator-${spiVersion}.jar" + "sha256-BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="; + + privacySPI = release themeVersion "helpwave-privacy-acceptance-${spiVersion}.jar" + "sha256-CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC="; + + pictureSPI = release themeVersion "helpwave-profile-picture-${spiVersion}.jar" + "sha256-DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD="; +in +{ + services.keycloak = { + enable = true; + database.type = "postgresql"; + # ... database, hostname, tls etc. as you already have it + + plugins = [ + themePlugin + turnstileSPI + privacySPI + pictureSPI + ]; + + settings = { + hostname = domain; + + # ---- Profile picture SPI (Cloudflare R2 example) ----------------------- + # + # The SPI name for RealmResourceProvider is "realm-restapi-extension". + # Provider id is "helpwave-picture" (matches RealmResourceProviderFactory.getId()). + # Hence the long key prefix below. + + "spi-realm-restapi-extension-helpwave-picture-endpoint" = + "https://.r2.cloudflarestorage.com"; + "spi-realm-restapi-extension-helpwave-picture-region" = "auto"; + "spi-realm-restapi-extension-helpwave-picture-bucket" = "helpwave-id-avatars"; + "spi-realm-restapi-extension-helpwave-picture-public-base-url" = + "https://cdn.helpwave.de/avatars"; + + # Secrets — file content is read at activation time, NOT committed to the Nix store + "spi-realm-restapi-extension-helpwave-picture-access-key" = { + _secret = "/run/keys/helpwave-r2-access-key"; + }; + "spi-realm-restapi-extension-helpwave-picture-secret-key" = { + _secret = "/run/keys/helpwave-r2-secret-key"; + }; + }; + }; + + # ---- Theme env vars (rendered into the React page via kcContext.properties) ---- + # services.keycloak.settings can only produce keys for keycloak.conf, so the + # KC_= variables must be supplied via the systemd unit: + systemd.services.keycloak.serviceConfig = { + Environment = [ + "KC_TURNSTILE_SITE_KEY=0x4AAAAAAAxxxxxxxxxxxxxxxx" + "KC_PROFILE_PICTURE_API_URL=https://${domain}/realms/customer/helpwave-picture" + ]; + # Cloudflare Turnstile secret + R2 credentials are loaded via the secret + # files referenced above; nothing further needed here. + }; + + # ---- Secrets provisioning (example using NixOS systemd tmpfiles) ---------- + # In production use agenix / sops-nix / deploy-rs vaults. The keycloak.service + # only reads these at start; rotate by writing new content + systemctl restart. + environment.etc."keycloak-secrets/.keep".text = ""; +} +``` + +> **Computing the sha256 placeholders** +> +> ```sh +> nix-prefetch-url \ +> --type sha256 \ +> "https://github.com/helpwave/id.helpwave.de/releases/download/v0.2.0/keycloak-theme-for-kc-26.2-and-above.jar" +> ``` +> +> Or, easier, run `nix build` once with the placeholder and copy the `got:` line from the +> error message into the expression. + +## 3. Bootstrapping the authentication flow + +Two of the SPIs (`Cloudflare Turnstile (helpwave)` and `Privacy Policy Acceptance +(helpwave)`) plug into the **registration flow** as `FormAction`s. Their config (Turnstile +site key + secret, privacy policy URL + version) is **not** read from `keycloak.conf` — +it is set per execution in the admin console so different realms can have different +keys. Two options: + +### 3a. One-time admin console setup + +1. Open `https:///admin`. +2. Pick your realm → **Authentication** → **Flows** → duplicate `registration`. +3. In the *registration form* sub-flow add two new executions and set both to + **Required**: + - `Cloudflare Turnstile (helpwave)` + - `Privacy Policy Acceptance (helpwave)` +4. Click the gear ⚙️ on each, enter: + - Turnstile: site key (public) + secret (private) from + . + - Privacy: URL (defaults to `https://helpwave.de/privacy`), version string + (e.g. `2024-01`), both stored on every new user as + `privacy_policy_accepted_at` + `privacy_policy_version` user attributes. +5. **Action** menu on the flow → *Bind* → *Registration flow*. + +### 3b. Declarative realm export (preferred for NixOS) + +Add `services.keycloak.realmFiles = [ ./helpwave-id-realm.json ];` and ship the +configured flow as part of the JSON export. Snippet of the relevant part of the export: + +```json +{ + "authenticationFlows": [ + { + "alias": "registration-helpwave", + "providerId": "basic-flow", + "topLevel": true, + "authenticationExecutions": [ + { + "authenticator": "registration-page-form", + "requirement": "REQUIRED", + "flowAlias": "registration form helpwave", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "alias": "registration form helpwave", + "providerId": "form-flow", + "topLevel": false, + "authenticationExecutions": [ + { "authenticator": "registration-user-creation", "requirement": "REQUIRED" }, + { "authenticator": "registration-password-action", "requirement": "REQUIRED" }, + { "authenticator": "helpwave-turnstile", "requirement": "REQUIRED", + "authenticatorConfig": "turnstile-config" }, + { "authenticator": "helpwave-privacy-acceptance", "requirement": "REQUIRED", + "authenticatorConfig": "privacy-config" } + ] + } + ], + "authenticatorConfig": [ + { + "alias": "turnstile-config", + "config": { + "turnstile.site.key": "0x4AAAAAAAxxxxxxxxxxxxxxxx", + "turnstile.secret": "$${env.TURNSTILE_SECRET}" + } + }, + { + "alias": "privacy-config", + "config": { + "privacy.policy.url": "https://helpwave.de/privacy", + "privacy.policy.version": "2024-01" + } + } + ], + "registrationFlow": "registration-helpwave" +} +``` + +Keycloak resolves `$${env.VAR}` placeholders at import time, so the Turnstile *secret* +can be injected through the systemd unit: + +```nix +systemd.services.keycloak.serviceConfig.EnvironmentFile = + "/run/keys/helpwave-turnstile-env"; # file containing TURNSTILE_SECRET=... +``` + +Use `agenix` / `sops-nix` to render that file with mode `0400` owned by `keycloak`. + +## 4. CORS / cookie notes for the profile picture endpoint + +The Account Console talks to `/realms//helpwave-picture` from +`https:///realms//account`. Same origin → no CORS or extra cookie config +needed. If you host the account console under a different origin, add the SPI's path to +your reverse proxy CORS allow-list (`POST`, `DELETE`, `Authorization` header, +`credentials: include`). + +## 5. Updating + +When a new release lands: + +1. Bump `themeVersion` (and `spiVersion` if it changed — check the release notes). +2. Replace the four `sha256-…` placeholders with the new digests. +3. `nixos-rebuild switch` — Keycloak will be restarted automatically because + `services.keycloak.plugins` changed. +4. If the SPI's config keys changed, update `services.keycloak.settings` accordingly. + +## 6. Smoke test after deployment + +```sh +# Theme served? +curl -sf "https://${DOMAIN}/realms/customer/login-actions/registration" | grep -q "helpwave id" + +# Turnstile widget rendered? +curl -sf "https://${DOMAIN}/realms/customer/login-actions/registration" | grep -q "cf-turnstile" + +# Picture endpoint mounted? (expect 401 unauthenticated) +curl -sw '%{http_code}\n' -o /dev/null -X POST \ + "https://${DOMAIN}/realms/customer/helpwave-picture" +# → 401 +``` diff --git a/keycloak-extensions/pom.xml b/keycloak-extensions/pom.xml new file mode 100644 index 0000000..f03f7b2 --- /dev/null +++ b/keycloak-extensions/pom.xml @@ -0,0 +1,92 @@ + + + 4.0.0 + + de.helpwave.keycloak + helpwave-keycloak-extensions + 0.1.0 + pom + helpwave Keycloak Extensions + Keycloak SPI extensions used by id.helpwave.de + + + turnstile-authenticator + privacy-acceptance + profile-picture + + + + UTF-8 + 17 + 17 + 26.6.0 + 2.29.9 + 0.4.20 + 2.18.2 + + + + + + org.keycloak + keycloak-server-spi + ${keycloak.version} + provided + + + org.keycloak + keycloak-server-spi-private + ${keycloak.version} + provided + + + org.keycloak + keycloak-services + ${keycloak.version} + provided + + + org.keycloak + keycloak-core + ${keycloak.version} + provided + + + jakarta.ws.rs + jakarta.ws.rs-api + 4.0.0 + provided + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + provided + + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.0 + + + org.apache.maven.plugins + maven-jar-plugin + 3.4.2 + + + + + diff --git a/keycloak-extensions/privacy-acceptance/pom.xml b/keycloak-extensions/privacy-acceptance/pom.xml new file mode 100644 index 0000000..de7a95b --- /dev/null +++ b/keycloak-extensions/privacy-acceptance/pom.xml @@ -0,0 +1,39 @@ + + + 4.0.0 + + + de.helpwave.keycloak + helpwave-keycloak-extensions + 0.1.0 + + + helpwave-privacy-acceptance + jar + helpwave Privacy Acceptance Form Action + + + + org.keycloak + keycloak-server-spi + + + org.keycloak + keycloak-server-spi-private + + + org.keycloak + keycloak-services + + + org.keycloak + keycloak-core + + + jakarta.ws.rs + jakarta.ws.rs-api + + + diff --git a/keycloak-extensions/privacy-acceptance/src/main/java/de/helpwave/keycloak/privacy/PrivacyAcceptanceFormAction.java b/keycloak-extensions/privacy-acceptance/src/main/java/de/helpwave/keycloak/privacy/PrivacyAcceptanceFormAction.java new file mode 100644 index 0000000..09f8c6b --- /dev/null +++ b/keycloak-extensions/privacy-acceptance/src/main/java/de/helpwave/keycloak/privacy/PrivacyAcceptanceFormAction.java @@ -0,0 +1,89 @@ +package de.helpwave.keycloak.privacy; + +import jakarta.ws.rs.core.MultivaluedMap; +import org.keycloak.authentication.FormAction; +import org.keycloak.authentication.FormContext; +import org.keycloak.authentication.ValidationContext; +import org.keycloak.events.Errors; +import org.keycloak.models.AuthenticatorConfigModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.utils.FormMessage; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +/** + * Enforces acceptance of the helpwave privacy policy during registration and stores the + * acceptance metadata as user attributes: + *
    + *
  • {@code privacy_policy_accepted} = "true"
  • + *
  • {@code privacy_policy_accepted_at} = ISO-8601 timestamp
  • + *
  • {@code privacy_policy_version} = configured policy version (e.g. "2024-01")
  • + *
+ */ +public class PrivacyAcceptanceFormAction implements FormAction { + + private static final String FORM_FIELD = "privacy-accepted"; + + static final String CFG_VERSION = "privacy.policy.version"; + static final String CFG_URL = "privacy.policy.url"; + + public static final String ATTR_ACCEPTED = "privacy_policy_accepted"; + public static final String ATTR_ACCEPTED_AT = "privacy_policy_accepted_at"; + public static final String ATTR_VERSION = "privacy_policy_version"; + + @Override + public void buildPage(FormContext context, org.keycloak.forms.login.LoginFormsProvider form) { + String url = getConfig(context, CFG_URL); + if (url != null && !url.isBlank()) { + form.setAttribute("privacyPolicyUrl", url); + } + } + + @Override + public void validate(ValidationContext context) { + MultivaluedMap form = context.getHttpRequest().getDecodedFormParameters(); + String accepted = form.getFirst(FORM_FIELD); + if (!"true".equalsIgnoreCase(accepted) && !"on".equalsIgnoreCase(accepted)) { + context.getEvent().error(Errors.INVALID_REGISTRATION); + List errors = new ArrayList<>(); + errors.add(new FormMessage(FORM_FIELD, "privacyRequired")); + context.validationError(form, errors); + return; + } + context.success(); + } + + @Override + public void success(FormContext context) { + UserModel user = context.getUser(); + if (user == null) return; + user.setSingleAttribute(ATTR_ACCEPTED, "true"); + user.setSingleAttribute(ATTR_ACCEPTED_AT, Instant.now().toString()); + String version = getConfig(context, CFG_VERSION); + if (version != null && !version.isBlank()) { + user.setSingleAttribute(ATTR_VERSION, version); + } + } + + private static String getConfig(FormContext ctx, String key) { + AuthenticatorConfigModel cfg = ctx.getAuthenticatorConfig(); + if (cfg == null) return null; + return cfg.getConfig().get(key); + } + + @Override + public boolean requiresUser() { return false; } + + @Override + public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { return true; } + + @Override + public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) { } + + @Override + public void close() { } +} diff --git a/keycloak-extensions/privacy-acceptance/src/main/java/de/helpwave/keycloak/privacy/PrivacyAcceptanceFormActionFactory.java b/keycloak-extensions/privacy-acceptance/src/main/java/de/helpwave/keycloak/privacy/PrivacyAcceptanceFormActionFactory.java new file mode 100644 index 0000000..88800e8 --- /dev/null +++ b/keycloak-extensions/privacy-acceptance/src/main/java/de/helpwave/keycloak/privacy/PrivacyAcceptanceFormActionFactory.java @@ -0,0 +1,77 @@ +package de.helpwave.keycloak.privacy; + +import org.keycloak.Config; +import org.keycloak.authentication.FormAction; +import org.keycloak.authentication.FormActionFactory; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; + +import java.util.List; + +public class PrivacyAcceptanceFormActionFactory implements FormActionFactory { + + public static final String PROVIDER_ID = "helpwave-privacy-acceptance"; + + private static final AuthenticationExecutionModel.Requirement[] REQUIREMENTS = { + AuthenticationExecutionModel.Requirement.REQUIRED, + AuthenticationExecutionModel.Requirement.DISABLED + }; + + private static final List CONFIG; + static { + ProviderConfigProperty url = new ProviderConfigProperty(); + url.setName(PrivacyAcceptanceFormAction.CFG_URL); + url.setLabel("Privacy policy URL"); + url.setType(ProviderConfigProperty.STRING_TYPE); + url.setDefaultValue("https://helpwave.de/privacy"); + url.setHelpText("URL of the privacy policy that the user accepts."); + + ProviderConfigProperty version = new ProviderConfigProperty(); + version.setName(PrivacyAcceptanceFormAction.CFG_VERSION); + version.setLabel("Privacy policy version"); + version.setType(ProviderConfigProperty.STRING_TYPE); + version.setHelpText("Optional version identifier stored on the user account, e.g. '2024-01'."); + + CONFIG = List.of(url, version); + } + + @Override + public String getDisplayType() { return "Privacy Policy Acceptance (helpwave)"; } + + @Override + public String getReferenceCategory() { return "terms"; } + + @Override + public boolean isConfigurable() { return true; } + + @Override + public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { return REQUIREMENTS; } + + @Override + public boolean isUserSetupAllowed() { return false; } + + @Override + public String getHelpText() { + return "Requires the user to accept the privacy policy and stores acceptance metadata on the user account."; + } + + @Override + public List getConfigProperties() { return CONFIG; } + + @Override + public FormAction create(KeycloakSession session) { return new PrivacyAcceptanceFormAction(); } + + @Override + public void init(Config.Scope config) { } + + @Override + public void postInit(KeycloakSessionFactory factory) { } + + @Override + public void close() { } + + @Override + public String getId() { return PROVIDER_ID; } +} diff --git a/keycloak-extensions/privacy-acceptance/src/main/resources/META-INF/services/org.keycloak.authentication.FormActionFactory b/keycloak-extensions/privacy-acceptance/src/main/resources/META-INF/services/org.keycloak.authentication.FormActionFactory new file mode 100644 index 0000000..009c5b7 --- /dev/null +++ b/keycloak-extensions/privacy-acceptance/src/main/resources/META-INF/services/org.keycloak.authentication.FormActionFactory @@ -0,0 +1 @@ +de.helpwave.keycloak.privacy.PrivacyAcceptanceFormActionFactory diff --git a/keycloak-extensions/profile-picture/pom.xml b/keycloak-extensions/profile-picture/pom.xml new file mode 100644 index 0000000..ddf33bc --- /dev/null +++ b/keycloak-extensions/profile-picture/pom.xml @@ -0,0 +1,95 @@ + + + 4.0.0 + + + de.helpwave.keycloak + helpwave-keycloak-extensions + 0.1.0 + + + helpwave-profile-picture + jar + helpwave Profile Picture SPI + + + + org.keycloak + keycloak-server-spi + + + org.keycloak + keycloak-server-spi-private + + + org.keycloak + keycloak-services + + + org.keycloak + keycloak-core + + + jakarta.ws.rs + jakarta.ws.rs-api + + + com.fasterxml.jackson.core + jackson-databind + + + + + software.amazon.awssdk + s3 + ${aws.sdk.version} + + + net.coobird + thumbnailator + ${thumbnailator.version} + + + + + + + org.apache.maven.plugins + maven-shade-plugin + + + package + shade + + false + false + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + module-info.class + + + + + + org.keycloak:* + jakarta.ws.rs:* + com.fasterxml.jackson.core:* + + + + + + + + + + + + diff --git a/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/ImageProcessor.java b/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/ImageProcessor.java new file mode 100644 index 0000000..841aa3e --- /dev/null +++ b/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/ImageProcessor.java @@ -0,0 +1,51 @@ +package de.helpwave.keycloak.picture; + +import net.coobird.thumbnailator.Thumbnails; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Decodes uploaded images, strips metadata (re-encoding to PNG/JPEG via Thumbnailator) and + * produces a fixed set of square thumbnails for the avatar use case. + */ +public final class ImageProcessor { + + /** Output sizes (pixels). Keys are used as filename suffixes. */ + static final Map SIZES = new LinkedHashMap<>(); + static { + SIZES.put("original", 512); + SIZES.put("256", 256); + SIZES.put("128", 128); + SIZES.put("64", 64); + } + + public static final String OUTPUT_CONTENT_TYPE = "image/jpeg"; + private static final String OUTPUT_FORMAT = "jpg"; + + private ImageProcessor() {} + + /** Validates that the bytes decode as an image and returns a sanitized image. */ + public static BufferedImage decode(byte[] bytes) throws IOException { + BufferedImage img = ImageIO.read(new ByteArrayInputStream(bytes)); + if (img == null) throw new IOException("not a valid image"); + return img; + } + + /** Returns the encoded JPEG bytes for the given square size. */ + public static byte[] toSquareJpeg(BufferedImage source, int size) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + Thumbnails.of(source) + .size(size, size) + .crop(net.coobird.thumbnailator.geometry.Positions.CENTER) + .outputFormat(OUTPUT_FORMAT) + .outputQuality(0.88f) + .toOutputStream(out); + return out.toByteArray(); + } +} diff --git a/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/MultipartParser.java b/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/MultipartParser.java new file mode 100644 index 0000000..4b76d57 --- /dev/null +++ b/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/MultipartParser.java @@ -0,0 +1,72 @@ +package de.helpwave.keycloak.picture; + +import java.nio.charset.StandardCharsets; + +/** + * Minimal RFC 2046 multipart/form-data parser used to extract the first file part out of + * the request body. We do not depend on RESTEasy's MultipartFormDataInput because the type + * is not on Keycloak's classpath in 26.x. + */ +final class MultipartParser { + + private MultipartParser() {} + + static byte[] extractFirstFile(byte[] body, String contentType) { + String boundary = boundaryOf(contentType); + if (boundary == null) return null; + byte[] sep = ("--" + boundary).getBytes(StandardCharsets.US_ASCII); + byte[] crlfCrlf = {0x0D, 0x0A, 0x0D, 0x0A}; + + int idx = indexOf(body, sep, 0); + while (idx >= 0) { + int partStart = idx + sep.length; + // Skip CRLF after boundary or "--" end marker + if (partStart + 2 <= body.length && body[partStart] == '-' && body[partStart + 1] == '-') break; + if (partStart + 2 <= body.length && body[partStart] == 0x0D && body[partStart + 1] == 0x0A) { + partStart += 2; + } + int headersEnd = indexOf(body, crlfCrlf, partStart); + if (headersEnd < 0) return null; + int contentStart = headersEnd + crlfCrlf.length; + int nextBoundary = indexOf(body, sep, contentStart); + if (nextBoundary < 0) return null; + // Trim trailing CRLF before boundary + int contentEnd = nextBoundary; + if (contentEnd >= 2 && body[contentEnd - 2] == 0x0D && body[contentEnd - 1] == 0x0A) { + contentEnd -= 2; + } + String headers = new String(body, partStart, headersEnd - partStart, StandardCharsets.UTF_8); + if (headers.toLowerCase().contains("filename=")) { + byte[] out = new byte[contentEnd - contentStart]; + System.arraycopy(body, contentStart, out, 0, out.length); + return out; + } + idx = nextBoundary; + } + return null; + } + + private static String boundaryOf(String contentType) { + if (contentType == null) return null; + for (String part : contentType.split(";")) { + String p = part.trim(); + if (p.toLowerCase().startsWith("boundary=")) { + String v = p.substring("boundary=".length()).trim(); + if (v.startsWith("\"") && v.endsWith("\"")) v = v.substring(1, v.length() - 1); + return v; + } + } + return null; + } + + private static int indexOf(byte[] hay, byte[] needle, int from) { + outer: + for (int i = from; i <= hay.length - needle.length; i++) { + for (int j = 0; j < needle.length; j++) { + if (hay[i + j] != needle[j]) continue outer; + } + return i; + } + return -1; + } +} diff --git a/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/PictureConfig.java b/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/PictureConfig.java new file mode 100644 index 0000000..e126f3e --- /dev/null +++ b/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/PictureConfig.java @@ -0,0 +1,57 @@ +package de.helpwave.keycloak.picture; + +/** + * Configuration for the profile-picture storage backend. All values are read from + * Keycloak's SPI configuration ({@code spi-helpwave-picture-default-*}) or environment + * variables, whichever is set first. + * + *

Example for Cloudflare R2: + *

+ * KC_SPI_HELPWAVE_PICTURE_DEFAULT_ENDPOINT=https://<account>.r2.cloudflarestorage.com
+ * KC_SPI_HELPWAVE_PICTURE_DEFAULT_REGION=auto
+ * KC_SPI_HELPWAVE_PICTURE_DEFAULT_BUCKET=helpwave-id-avatars
+ * KC_SPI_HELPWAVE_PICTURE_DEFAULT_ACCESS_KEY=...
+ * KC_SPI_HELPWAVE_PICTURE_DEFAULT_SECRET_KEY=...
+ * KC_SPI_HELPWAVE_PICTURE_DEFAULT_PUBLIC_BASE_URL=https://cdn.helpwave.de/avatars
+ * 
+ */ +public record PictureConfig( + String endpoint, + String region, + String bucket, + String accessKey, + String secretKey, + String publicBaseUrl, + int maxBytes +) { + public static PictureConfig fromEnv(org.keycloak.Config.Scope scope) { + return new PictureConfig( + read(scope, "endpoint", "HELPWAVE_PICTURE_ENDPOINT"), + orDefault(read(scope, "region", "HELPWAVE_PICTURE_REGION"), "auto"), + read(scope, "bucket", "HELPWAVE_PICTURE_BUCKET"), + read(scope, "accessKey", "HELPWAVE_PICTURE_ACCESS_KEY"), + read(scope, "secretKey", "HELPWAVE_PICTURE_SECRET_KEY"), + orDefault(read(scope, "publicBaseUrl", "HELPWAVE_PICTURE_PUBLIC_BASE_URL"), ""), + Integer.parseInt(orDefault(read(scope, "maxBytes", "HELPWAVE_PICTURE_MAX_BYTES"), "5242880")) + ); + } + + public boolean isValid() { + return bucket != null && !bucket.isBlank() + && accessKey != null && !accessKey.isBlank() + && secretKey != null && !secretKey.isBlank() + && publicBaseUrl != null && !publicBaseUrl.isBlank(); + } + + private static String read(org.keycloak.Config.Scope scope, String key, String env) { + if (scope != null) { + String v = scope.get(key); + if (v != null && !v.isBlank()) return v; + } + return System.getenv(env); + } + + private static String orDefault(String v, String def) { + return (v == null || v.isBlank()) ? def : v; + } +} diff --git a/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/ProfilePictureResource.java b/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/ProfilePictureResource.java new file mode 100644 index 0000000..8cdbd2f --- /dev/null +++ b/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/ProfilePictureResource.java @@ -0,0 +1,166 @@ +package de.helpwave.keycloak.picture; + +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.HeaderParam; +import jakarta.ws.rs.OPTIONS; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.jboss.logging.Logger; +import org.keycloak.http.HttpRequest; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.UserModel; +import org.keycloak.services.managers.AppAuthManager; +import org.keycloak.services.managers.AuthenticationManager.AuthResult; + +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.io.InputStream; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +/** + * REST endpoint exposed at {@code /realms/{realm}/helpwave-picture}. The browser sends the + * raw image bytes as the request body with the corresponding {@code Content-Type} header + * (no multipart wrapper needed). Authentication is the standard Keycloak bearer token. + */ +@Path("/") +public class ProfilePictureResource { + + private static final Logger log = Logger.getLogger(ProfilePictureResource.class); + public static final String ATTR_PICTURE_URL = "picture"; + + private static final Set ALLOWED_TYPES = Set.of("image/jpeg", "image/png", "image/webp"); + + private final KeycloakSession session; + private final PictureConfig config; + private final S3Storage storage; + + public ProfilePictureResource(KeycloakSession session, PictureConfig config, S3Storage storage) { + this.session = session; + this.config = config; + this.storage = storage; + } + + @OPTIONS + public Response preflight() { + return Response.ok().build(); + } + + @POST + @Consumes({"image/jpeg", "image/png", "image/webp", MediaType.APPLICATION_OCTET_STREAM, MediaType.MULTIPART_FORM_DATA}) + @Produces(MediaType.APPLICATION_JSON) + public Response upload(@HeaderParam(HttpHeaders.CONTENT_TYPE) String contentType, InputStream body) { + if (storage == null) { + return Response.status(Response.Status.SERVICE_UNAVAILABLE) + .entity(Map.of("error", "storage not configured")).build(); + } + UserModel user = authenticate(); + if (user == null) return unauthorized(); + + byte[] bytes; + try { + bytes = body.readAllBytes(); + } catch (IOException e) { + return Response.status(Response.Status.BAD_REQUEST).entity(Map.of("error", "read failed")).build(); + } + + // If sent as multipart, extract the first file part. + if (contentType != null && contentType.toLowerCase().startsWith("multipart/")) { + byte[] extracted = MultipartParser.extractFirstFile(bytes, contentType); + if (extracted != null) bytes = extracted; + } + + if (bytes.length > config.maxBytes()) { + return Response.status(Response.Status.REQUEST_ENTITY_TOO_LARGE) + .entity(Map.of("error", "file too large")).build(); + } + + BufferedImage source; + try { + source = ImageProcessor.decode(bytes); + } catch (Exception e) { + return Response.status(Response.Status.BAD_REQUEST).entity(Map.of("error", "invalid image")).build(); + } + + String userId = user.getId(); + String version = UUID.randomUUID().toString().substring(0, 8); + String primaryUrl = null; + Map generated = new HashMap<>(); + try { + for (Map.Entry entry : ImageProcessor.SIZES.entrySet()) { + String label = entry.getKey(); + byte[] scaled = ImageProcessor.toSquareJpeg(source, entry.getValue()); + String key = "users/" + userId + "/" + version + "/" + label + ".jpg"; + String url = storage.put(key, scaled, ImageProcessor.OUTPUT_CONTENT_TYPE); + generated.put(label, url); + if ("original".equals(label)) primaryUrl = url; + } + } catch (Exception e) { + log.error("Profile picture upload failed", e); + return Response.serverError().entity(Map.of("error", "upload failed")).build(); + } + + String previous = user.getFirstAttribute(ATTR_PICTURE_URL); + user.setSingleAttribute(ATTR_PICTURE_URL, primaryUrl); + user.setSingleAttribute("picture_thumb_64", generated.get("64")); + user.setSingleAttribute("picture_thumb_128", generated.get("128")); + user.setSingleAttribute("picture_thumb_256", generated.get("256")); + + if (previous != null) tryDeletePrevious(previous); + + return Response.ok(Map.of("url", primaryUrl, "variants", generated)).build(); + } + + @DELETE + @Produces(MediaType.APPLICATION_JSON) + public Response delete() { + UserModel user = authenticate(); + if (user == null) return unauthorized(); + + String previous = user.getFirstAttribute(ATTR_PICTURE_URL); + user.removeAttribute(ATTR_PICTURE_URL); + user.removeAttribute("picture_thumb_64"); + user.removeAttribute("picture_thumb_128"); + user.removeAttribute("picture_thumb_256"); + if (previous != null) tryDeletePrevious(previous); + return Response.ok(Map.of("status", "removed")).build(); + } + + private void tryDeletePrevious(String url) { + try { + String prefix = config.publicBaseUrl().replaceAll("/+$", "") + "/"; + if (!url.startsWith(prefix)) return; + String relative = url.substring(prefix.length()); + int folder = relative.lastIndexOf('/'); + if (folder < 0) return; + String base = relative.substring(0, folder); + for (String label : ImageProcessor.SIZES.keySet()) { + storage.delete(base + "/" + label + ".jpg"); + } + } catch (Exception e) { + log.debugf("Failed to delete previous picture: %s", e.getMessage()); + } + } + + private UserModel authenticate() { + HttpRequest req = session.getContext().getHttpRequest(); + AuthResult auth = new AppAuthManager.BearerTokenAuthenticator(session) + .setRealm(session.getContext().getRealm()) + .setUriInfo(session.getContext().getUri()) + .setConnection(session.getContext().getConnection()) + .setHeaders(req.getHttpHeaders()) + .authenticate(); + return auth == null ? null : auth.getUser(); + } + + private Response unauthorized() { + return Response.status(Response.Status.UNAUTHORIZED).entity(Map.of("error", "unauthorized")).build(); + } +} diff --git a/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/ProfilePictureResourceProvider.java b/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/ProfilePictureResourceProvider.java new file mode 100644 index 0000000..b6f96b4 --- /dev/null +++ b/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/ProfilePictureResourceProvider.java @@ -0,0 +1,25 @@ +package de.helpwave.keycloak.picture; + +import org.keycloak.models.KeycloakSession; +import org.keycloak.services.resource.RealmResourceProvider; + +public class ProfilePictureResourceProvider implements RealmResourceProvider { + + private final KeycloakSession session; + private final PictureConfig config; + private final S3Storage storage; + + public ProfilePictureResourceProvider(KeycloakSession session, PictureConfig config, S3Storage storage) { + this.session = session; + this.config = config; + this.storage = storage; + } + + @Override + public Object getResource() { + return new ProfilePictureResource(session, config, storage); + } + + @Override + public void close() { } +} diff --git a/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/ProfilePictureResourceProviderFactory.java b/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/ProfilePictureResourceProviderFactory.java new file mode 100644 index 0000000..6ce4d81 --- /dev/null +++ b/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/ProfilePictureResourceProviderFactory.java @@ -0,0 +1,44 @@ +package de.helpwave.keycloak.picture; + +import org.jboss.logging.Logger; +import org.keycloak.Config; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.services.resource.RealmResourceProvider; +import org.keycloak.services.resource.RealmResourceProviderFactory; + +public class ProfilePictureResourceProviderFactory implements RealmResourceProviderFactory { + + public static final String ID = "helpwave-picture"; + private static final Logger log = Logger.getLogger(ProfilePictureResourceProviderFactory.class); + + private PictureConfig config; + private S3Storage storage; + + @Override + public RealmResourceProvider create(KeycloakSession session) { + return new ProfilePictureResourceProvider(session, config, storage); + } + + @Override + public void init(Config.Scope scope) { + this.config = PictureConfig.fromEnv(scope); + if (!config.isValid()) { + log.warn("helpwave-picture: storage config is incomplete; uploads will return 503"); + return; + } + this.storage = new S3Storage(config); + log.infof("helpwave-picture initialized (bucket=%s, region=%s)", config.bucket(), config.region()); + } + + @Override + public void postInit(KeycloakSessionFactory factory) { } + + @Override + public void close() { + if (storage != null) storage.close(); + } + + @Override + public String getId() { return ID; } +} diff --git a/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/S3Storage.java b/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/S3Storage.java new file mode 100644 index 0000000..d17a212 --- /dev/null +++ b/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/S3Storage.java @@ -0,0 +1,57 @@ +package de.helpwave.keycloak.picture; + +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3ClientBuilder; +import software.amazon.awssdk.services.s3.S3Configuration; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import java.net.URI; + +/** Tiny wrapper around the AWS S3 client that works with Cloudflare R2 via a custom endpoint. */ +public final class S3Storage { + + private final S3Client client; + private final String bucket; + private final String publicBaseUrl; + + public S3Storage(PictureConfig config) { + this.bucket = config.bucket(); + this.publicBaseUrl = config.publicBaseUrl().replaceAll("/+$", ""); + + S3ClientBuilder builder = S3Client.builder() + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(config.accessKey(), config.secretKey()))) + .region(Region.of(config.region())) + // R2 only supports path-style addressing. + .serviceConfiguration(S3Configuration.builder().pathStyleAccessEnabled(true).build()); + + if (config.endpoint() != null && !config.endpoint().isBlank()) { + builder = builder.endpointOverride(URI.create(config.endpoint())); + } + this.client = builder.build(); + } + + public String put(String key, byte[] data, String contentType) { + client.putObject(PutObjectRequest.builder() + .bucket(bucket) + .key(key) + .contentType(contentType) + .cacheControl("public, max-age=31536000, immutable") + .build(), RequestBody.fromBytes(data)); + return publicBaseUrl + "/" + key; + } + + public void delete(String key) { + client.deleteObject(DeleteObjectRequest.builder() + .bucket(bucket) + .key(key) + .build()); + } + + public void close() { client.close(); } +} diff --git a/keycloak-extensions/profile-picture/src/main/resources/META-INF/services/org.keycloak.services.resource.RealmResourceProviderFactory b/keycloak-extensions/profile-picture/src/main/resources/META-INF/services/org.keycloak.services.resource.RealmResourceProviderFactory new file mode 100644 index 0000000..21f461f --- /dev/null +++ b/keycloak-extensions/profile-picture/src/main/resources/META-INF/services/org.keycloak.services.resource.RealmResourceProviderFactory @@ -0,0 +1 @@ +de.helpwave.keycloak.picture.ProfilePictureResourceProviderFactory diff --git a/keycloak-extensions/turnstile-authenticator/pom.xml b/keycloak-extensions/turnstile-authenticator/pom.xml new file mode 100644 index 0000000..9c7286d --- /dev/null +++ b/keycloak-extensions/turnstile-authenticator/pom.xml @@ -0,0 +1,43 @@ + + + 4.0.0 + + + de.helpwave.keycloak + helpwave-keycloak-extensions + 0.1.0 + + + helpwave-turnstile-authenticator + jar + helpwave Cloudflare Turnstile Authenticator + + + + org.keycloak + keycloak-server-spi + + + org.keycloak + keycloak-server-spi-private + + + org.keycloak + keycloak-services + + + org.keycloak + keycloak-core + + + jakarta.ws.rs + jakarta.ws.rs-api + + + com.fasterxml.jackson.core + jackson-databind + + + diff --git a/keycloak-extensions/turnstile-authenticator/src/main/java/de/helpwave/keycloak/turnstile/TurnstileFormAction.java b/keycloak-extensions/turnstile-authenticator/src/main/java/de/helpwave/keycloak/turnstile/TurnstileFormAction.java new file mode 100644 index 0000000..fc38683 --- /dev/null +++ b/keycloak-extensions/turnstile-authenticator/src/main/java/de/helpwave/keycloak/turnstile/TurnstileFormAction.java @@ -0,0 +1,130 @@ +package de.helpwave.keycloak.turnstile; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.Response; +import org.jboss.logging.Logger; +import org.keycloak.authentication.FormAction; +import org.keycloak.authentication.FormContext; +import org.keycloak.authentication.ValidationContext; +import org.keycloak.events.Errors; +import org.keycloak.models.AuthenticatorConfigModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.utils.FormMessage; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +/** + * Server-side Cloudflare Turnstile verification. Runs as part of the registration form flow. + * Reads the token from form field {@code cf-turnstile-response} and validates it against + * Cloudflare's siteverify endpoint using the configured secret. + */ +public class TurnstileFormAction implements FormAction { + + private static final Logger log = Logger.getLogger(TurnstileFormAction.class); + private static final String FORM_FIELD = "cf-turnstile-response"; + private static final String VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"; + + static final String CFG_SITE_KEY = "turnstile.site.key"; + static final String CFG_SECRET = "turnstile.secret"; + + private static final HttpClient HTTP = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .build(); + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Override + public void buildPage(FormContext context, org.keycloak.forms.login.LoginFormsProvider form) { + String siteKey = getConfig(context, CFG_SITE_KEY); + if (siteKey != null && !siteKey.isBlank()) { + form.setAttribute("turnstileSiteKey", siteKey); + } + } + + @Override + public void validate(ValidationContext context) { + String secret = getConfig(context, CFG_SECRET); + if (secret == null || secret.isBlank()) { + // Misconfigured. Fail closed in production; here we log and skip to avoid lockouts. + log.warn("Turnstile secret not configured; skipping verification"); + context.success(); + return; + } + + MultivaluedMap form = context.getHttpRequest().getDecodedFormParameters(); + String token = form.getFirst(FORM_FIELD); + if (token == null || token.isBlank()) { + failed(context, "captchaFailed"); + return; + } + + try { + String remoteIp = context.getConnection() != null ? context.getConnection().getRemoteAddr() : null; + String body = "secret=" + url(secret) + + "&response=" + url(token) + + (remoteIp != null ? "&remoteip=" + url(remoteIp) : ""); + HttpRequest req = HttpRequest.newBuilder() + .uri(URI.create(VERIFY_URL)) + .timeout(Duration.ofSeconds(5)) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + HttpResponse res = HTTP.send(req, HttpResponse.BodyHandlers.ofString()); + JsonNode root = MAPPER.readTree(res.body()); + if (root.path("success").asBoolean(false)) { + context.success(); + } else { + log.debugf("Turnstile verification failed: %s", res.body()); + failed(context, "captchaFailed"); + } + } catch (Exception e) { + log.warn("Turnstile verification call failed", e); + failed(context, "captchaFailed"); + } + } + + private static String url(String v) { + return java.net.URLEncoder.encode(v, StandardCharsets.UTF_8); + } + + private void failed(ValidationContext context, String messageKey) { + context.getEvent().error(Errors.INVALID_REGISTRATION); + List errors = new ArrayList<>(); + errors.add(new FormMessage(FORM_FIELD, messageKey)); + MultivaluedMap formData = context.getHttpRequest().getDecodedFormParameters(); + context.validationError(formData, errors); + } + + private static String getConfig(FormContext ctx, String key) { + AuthenticatorConfigModel cfg = ctx.getAuthenticatorConfig(); + if (cfg == null) return null; + return cfg.getConfig().get(key); + } + + @Override + public void success(FormContext context) { + // nothing to do + } + + @Override + public boolean requiresUser() { return false; } + + @Override + public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { return true; } + + @Override + public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) { } + + @Override + public void close() { } +} diff --git a/keycloak-extensions/turnstile-authenticator/src/main/java/de/helpwave/keycloak/turnstile/TurnstileFormActionFactory.java b/keycloak-extensions/turnstile-authenticator/src/main/java/de/helpwave/keycloak/turnstile/TurnstileFormActionFactory.java new file mode 100644 index 0000000..b62e965 --- /dev/null +++ b/keycloak-extensions/turnstile-authenticator/src/main/java/de/helpwave/keycloak/turnstile/TurnstileFormActionFactory.java @@ -0,0 +1,76 @@ +package de.helpwave.keycloak.turnstile; + +import org.keycloak.Config; +import org.keycloak.authentication.FormAction; +import org.keycloak.authentication.FormActionFactory; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; + +import java.util.List; + +public class TurnstileFormActionFactory implements FormActionFactory { + + public static final String PROVIDER_ID = "helpwave-turnstile"; + + private static final AuthenticationExecutionModel.Requirement[] REQUIREMENTS = { + AuthenticationExecutionModel.Requirement.REQUIRED, + AuthenticationExecutionModel.Requirement.DISABLED + }; + + private static final List CONFIG; + static { + ProviderConfigProperty siteKey = new ProviderConfigProperty(); + siteKey.setName(TurnstileFormAction.CFG_SITE_KEY); + siteKey.setLabel("Turnstile site key"); + siteKey.setType(ProviderConfigProperty.STRING_TYPE); + siteKey.setHelpText("Public Cloudflare Turnstile site key, rendered in the registration form."); + + ProviderConfigProperty secret = new ProviderConfigProperty(); + secret.setName(TurnstileFormAction.CFG_SECRET); + secret.setLabel("Turnstile secret"); + secret.setType(ProviderConfigProperty.PASSWORD); + secret.setHelpText("Private Cloudflare Turnstile secret used for server-side verification."); + + CONFIG = List.of(siteKey, secret); + } + + @Override + public String getDisplayType() { return "Cloudflare Turnstile (helpwave)"; } + + @Override + public String getReferenceCategory() { return "captcha"; } + + @Override + public boolean isConfigurable() { return true; } + + @Override + public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { return REQUIREMENTS; } + + @Override + public boolean isUserSetupAllowed() { return false; } + + @Override + public String getHelpText() { + return "Validates Cloudflare Turnstile CAPTCHA during registration."; + } + + @Override + public List getConfigProperties() { return CONFIG; } + + @Override + public FormAction create(KeycloakSession session) { return new TurnstileFormAction(); } + + @Override + public void init(Config.Scope config) { } + + @Override + public void postInit(KeycloakSessionFactory factory) { } + + @Override + public void close() { } + + @Override + public String getId() { return PROVIDER_ID; } +} diff --git a/keycloak-extensions/turnstile-authenticator/src/main/resources/META-INF/services/org.keycloak.authentication.FormActionFactory b/keycloak-extensions/turnstile-authenticator/src/main/resources/META-INF/services/org.keycloak.authentication.FormActionFactory new file mode 100644 index 0000000..4a5bbc6 --- /dev/null +++ b/keycloak-extensions/turnstile-authenticator/src/main/resources/META-INF/services/org.keycloak.authentication.FormActionFactory @@ -0,0 +1 @@ +de.helpwave.keycloak.turnstile.TurnstileFormActionFactory diff --git a/locales/de-DE.arb b/locales/de-DE.arb index 5fbc501..7bc8cfb 100644 --- a/locales/de-DE.arb +++ b/locales/de-DE.arb @@ -72,6 +72,50 @@ "@acceptTerms": { "description": "Text für AGB-Akzeptanz Checkbox" }, + "acceptPrivacy": "Ich habe die", + "@acceptPrivacy": { + "description": "Datenschutz-Akzeptanz Prefix" + }, + "privacyPolicy": "Datenschutzerklärung gelesen und akzeptiere sie", + "@privacyPolicy": { + "description": "Link-Text Datenschutzerklärung" + }, + "privacyRequired": "Bitte akzeptieren Sie die Datenschutzerklärung.", + "@privacyRequired": { + "description": "Fehler wenn Datenschutz nicht akzeptiert" + }, + "captchaFailed": "Captcha-Prüfung fehlgeschlagen. Bitte versuchen Sie es erneut.", + "@captchaFailed": { + "description": "Fehler bei Captcha-Prüfung" + }, + "captchaLoading": "Captcha wird geladen…", + "@captchaLoading": { + "description": "Captcha Ladezustand" + }, + "realmBannerTeam": "Team-Arbeitsbereich", + "@realmBannerTeam": { + "description": "Banner-Überschrift für das Team-Realm" + }, + "realmBannerAdmin": "Administrator-Portal", + "@realmBannerAdmin": { + "description": "Banner-Überschrift für das Admin/Master-Realm" + }, + "realmBannerOther": "Internes Realm", + "@realmBannerOther": { + "description": "Banner-Überschrift für ein Realm, das kein Kundenrealm ist" + }, + "realmBannerSubtitleTeam": "Sie melden sich in einem privilegierten Team-Bereich an.", + "@realmBannerSubtitleTeam": { + "description": "Untertitel unter dem Team-Banner" + }, + "realmBannerSubtitleAdmin": "Administrativer Zugriff – jede Aktion wird protokolliert.", + "@realmBannerSubtitleAdmin": { + "description": "Untertitel unter dem Admin-Banner" + }, + "realmBannerSubtitleOther": "Dies ist nicht der reguläre Kunden-Login.", + "@realmBannerSubtitleOther": { + "description": "Untertitel unter dem Fallback-Banner" + }, "passwordNew": "Neues Passwort", "@passwordNew": { "description": "Label für neues Passwort Eingabefeld" @@ -136,6 +180,34 @@ "@profilePictureComingSoon": { "description": "Platzhalter für Profilbild-Upload" }, + "uploadProfilePicture": "Bild hochladen", + "@uploadProfilePicture": { + "description": "Button zum Hochladen des Profilbilds" + }, + "removeProfilePicture": "Bild entfernen", + "@removeProfilePicture": { + "description": "Button zum Entfernen des Profilbilds" + }, + "profilePictureUploading": "Wird hochgeladen…", + "@profilePictureUploading": { + "description": "Status während des Uploads" + }, + "profilePictureUploadFailed": "Upload fehlgeschlagen. Bitte erneut versuchen.", + "@profilePictureUploadFailed": { + "description": "Fehler beim Upload des Profilbilds" + }, + "profilePictureTooLarge": "Die gewählte Datei ist größer als 5 MB.", + "@profilePictureTooLarge": { + "description": "Fehler wenn Bild zu groß ist" + }, + "profilePictureWrongType": "Nur JPEG, PNG oder WebP werden unterstützt.", + "@profilePictureWrongType": { + "description": "Fehler bei nicht unterstütztem Bildformat" + }, + "profilePictureHelp": "JPEG, PNG oder WebP, max. 5 MB. Das Bild wird skaliert und sicher gespeichert.", + "@profilePictureHelp": { + "description": "Hinweis unter Upload-Button" + }, "accountSectionProfile": "Profil", "@accountSectionProfile": { "description": "Profil-Abschnittsüberschrift" diff --git a/locales/en-US.arb b/locales/en-US.arb index d560ec7..a773aff 100644 --- a/locales/en-US.arb +++ b/locales/en-US.arb @@ -72,6 +72,50 @@ "@acceptTerms": { "description": "Text for terms acceptance checkbox" }, + "acceptPrivacy": "I have read and accept the", + "@acceptPrivacy": { + "description": "Privacy policy acceptance prefix" + }, + "privacyPolicy": "privacy policy", + "@privacyPolicy": { + "description": "Privacy policy link text" + }, + "privacyRequired": "You must accept the privacy policy to continue.", + "@privacyRequired": { + "description": "Error when privacy not accepted" + }, + "captchaFailed": "Captcha verification failed. Please try again.", + "@captchaFailed": { + "description": "Error when captcha verification fails" + }, + "captchaLoading": "Loading captcha…", + "@captchaLoading": { + "description": "Captcha loading placeholder" + }, + "realmBannerTeam": "Team workspace", + "@realmBannerTeam": { + "description": "Banner headline shown on every page of the team realm" + }, + "realmBannerAdmin": "Administrator portal", + "@realmBannerAdmin": { + "description": "Banner headline shown on every page of the admin/master realm" + }, + "realmBannerOther": "Internal realm", + "@realmBannerOther": { + "description": "Banner headline shown on every page of a non-customer realm" + }, + "realmBannerSubtitleTeam": "You are signing in to a privileged team area.", + "@realmBannerSubtitleTeam": { + "description": "Subtitle below the team realm banner" + }, + "realmBannerSubtitleAdmin": "Administrative access — every action is audited.", + "@realmBannerSubtitleAdmin": { + "description": "Subtitle below the admin realm banner" + }, + "realmBannerSubtitleOther": "This is not the regular customer login.", + "@realmBannerSubtitleOther": { + "description": "Subtitle below the fallback realm banner" + }, "passwordNew": "New Password", "@passwordNew": { "description": "Label for new password input field" @@ -136,6 +180,34 @@ "@profilePictureComingSoon": { "description": "Placeholder for profile picture upload" }, + "uploadProfilePicture": "Upload picture", + "@uploadProfilePicture": { + "description": "Button to upload profile picture" + }, + "removeProfilePicture": "Remove picture", + "@removeProfilePicture": { + "description": "Button to remove profile picture" + }, + "profilePictureUploading": "Uploading…", + "@profilePictureUploading": { + "description": "Status while profile picture is being uploaded" + }, + "profilePictureUploadFailed": "Upload failed. Please try again.", + "@profilePictureUploadFailed": { + "description": "Error when profile picture upload fails" + }, + "profilePictureTooLarge": "The selected file is larger than 5 MB.", + "@profilePictureTooLarge": { + "description": "Error when picture exceeds size limit" + }, + "profilePictureWrongType": "Only JPEG, PNG or WebP images are supported.", + "@profilePictureWrongType": { + "description": "Error when picture has unsupported type" + }, + "profilePictureHelp": "JPEG, PNG or WebP, max. 5 MB. The image is scaled and stored securely.", + "@profilePictureHelp": { + "description": "Helper text below upload button" + }, "accountSectionProfile": "Profile", "@accountSectionProfile": { "description": "Account profile section title" diff --git a/package.json b/package.json index 88ee475..f7ca6e0 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "id.helpwave.de", - "version": "0.1.14", + "version": "0.3.0", "repository": { "type": "git", "url": "git://github.com/helpwave/id.helpwave.de.git" diff --git a/src/account/KcContext.ts b/src/account/KcContext.ts index 25d257d..0865273 100644 --- a/src/account/KcContext.ts +++ b/src/account/KcContext.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-empty-object-type */ import type { ExtendKcContext } from 'keycloakify/account' import type { KcEnvName, ThemeName } from '../kc.gen' @@ -7,6 +6,11 @@ export type KcContextExtension = { properties: Record & {}, } -export type KcContextExtensionPerPage = {} +export type KcContextExtensionPerPage = { + 'account.ftl': { + profilePictureApiUrl?: string, + profilePictureUrl?: string, + }, +} export type KcContext = ExtendKcContext diff --git a/src/account/KcPageStory.tsx b/src/account/KcPageStory.tsx index 9a2c8c8..52442ab 100644 --- a/src/account/KcPageStory.tsx +++ b/src/account/KcPageStory.tsx @@ -11,7 +11,12 @@ const kcContextExtension: KcContextExtension = { ...kcEnvDefaults } } -const kcContextExtensionPerPage: KcContextExtensionPerPage = {} +const kcContextExtensionPerPage: KcContextExtensionPerPage = { + 'account.ftl': { + profilePictureApiUrl: '', + profilePictureUrl: undefined, + } +} export const { getKcContextMock } = createGetKcContextMock({ kcContextExtension, diff --git a/src/account/components/AccountPageLayout.tsx b/src/account/components/AccountPageLayout.tsx index ad538ed..e9bc68a 100644 --- a/src/account/components/AccountPageLayout.tsx +++ b/src/account/components/AccountPageLayout.tsx @@ -2,7 +2,7 @@ import { useEffect } from 'react' import type { ReactNode } from 'react' import type { KcContext } from '../KcContext' import { Branding } from '../../login/components/Branding' -import { RealmChip } from '../../login/components/RealmChip' +import { RealmBanner } from '../../login/components/RealmBanner' import { ThemeSwitcher } from '../../login/components/ThemeSwitcher' import { LanguageSwitcher } from '../../login/components/LanguageSwitcher' import { Footer } from '../../login/components/Footer' @@ -27,6 +27,7 @@ export function AccountPageLayout({ kcContext, children }: AccountPageLayoutProp return ( <> +
@@ -46,9 +47,6 @@ export function AccountPageLayout({ kcContext, children }: AccountPageLayoutProp
-
- -
{children} diff --git a/src/account/pages/AccountSettings.tsx b/src/account/pages/AccountSettings.tsx index fb9c73c..eb72f0f 100644 --- a/src/account/pages/AccountSettings.tsx +++ b/src/account/pages/AccountSettings.tsx @@ -1,11 +1,14 @@ -import { useState } from 'react' -import { Key, Save, Trash2 } from 'lucide-react' +import { useRef, useState } from 'react' +import { Key, Save, Trash2, Upload } from 'lucide-react' import { Avatar, Button, Chip, ConfirmDialog, DialogRoot, Input, FormFieldLayout } from '@helpwave/hightide' import type { KcContext } from '../KcContext' import { useTranslation } from '../../i18n/useTranslation' import { useTranslatedFieldError } from '../../login/utils/translateFieldError' import { AlertBox } from '../../login/components/AlertBox' +const MAX_PICTURE_BYTES = 5 * 1024 * 1024 +const ACCEPTED_PICTURE_TYPES = ['image/jpeg', 'image/png', 'image/webp'] + type AccountSettingsProps = { kcContext: Extract, } @@ -42,8 +45,67 @@ export default function AccountSettings({ kcContext }: AccountSettingsProps) { const [lastName, setLastName] = useState(account.lastName ?? '') const [deleteAccountDialogOpen, setDeleteAccountDialogOpen] = useState(false) + const profilePictureApiUrl = kcContext.profilePictureApiUrl ?? kcContext.properties?.PROFILE_PICTURE_API_URL ?? '' + const [pictureUrl, setPictureUrl] = useState(kcContext.profilePictureUrl) + const [pictureUploading, setPictureUploading] = useState(false) + const [pictureError, setPictureError] = useState() + const fileInputRef = useRef(null) + const displayName = getDisplayName(kcContext) - const avatarImage = undefined + const avatarImage = pictureUrl ? { avatarUrl: pictureUrl, alt: displayName || 'Profile picture' } : undefined + + const handlePictureSelect = async (file: File | undefined) => { + if (!file) return + setPictureError(undefined) + if (!ACCEPTED_PICTURE_TYPES.includes(file.type)) { + setPictureError(t('profilePictureWrongType')) + return + } + if (file.size > MAX_PICTURE_BYTES) { + setPictureError(t('profilePictureTooLarge')) + return + } + if (!profilePictureApiUrl) { + setPictureError(t('profilePictureUploadFailed')) + return + } + setPictureUploading(true) + try { + const body = new FormData() + body.append('file', file) + const res = await fetch(profilePictureApiUrl, { + method: 'POST', + body, + credentials: 'include', + }) + if (!res.ok) throw new Error(`upload failed: ${res.status}`) + const data = await res.json() as { url?: string } + if (data.url) setPictureUrl(data.url) + } catch { + setPictureError(t('profilePictureUploadFailed')) + } finally { + setPictureUploading(false) + if (fileInputRef.current) fileInputRef.current.value = '' + } + } + + const handlePictureRemove = async () => { + if (!profilePictureApiUrl) return + setPictureError(undefined) + setPictureUploading(true) + try { + const res = await fetch(profilePictureApiUrl, { + method: 'DELETE', + credentials: 'include', + }) + if (!res.ok) throw new Error(`remove failed: ${res.status}`) + setPictureUrl(undefined) + } catch { + setPictureError(t('profilePictureUploadFailed')) + } finally { + setPictureUploading(false) + } + } const getFieldError = (fieldName: string) => messagesPerField.existsError(fieldName) ? messagesPerField.get(fieldName) : undefined @@ -233,13 +295,62 @@ export default function AccountSettings({ kcContext }: AccountSettingsProps) {
-
+

{t('profilePicture')}

-

- {t('profilePictureComingSoon')} -

+ {profilePictureApiUrl ? ( +
+
+ +
+ handlePictureSelect(e.target.files?.[0])} + /> +
+ + {pictureUrl && ( + + )} +
+

+ {t('profilePictureHelp')} +

+ {pictureError && ( +

+ {pictureError} +

+ )} +
+
+
+ ) : ( +

+ {t('profilePictureComingSoon')} +

+ )}
) diff --git a/src/i18n/translations.ts b/src/i18n/translations.ts index b9216e9..3120bcf 100644 --- a/src/i18n/translations.ts +++ b/src/i18n/translations.ts @@ -10,12 +10,15 @@ export const helpwaveIdTranslationLocales = ['de-DE', 'en-US'] as const export type HelpwaveIdTranslationLocales = typeof helpwaveIdTranslationLocales[number] export type HelpwaveIdTranslationEntries = { + 'acceptPrivacy': string, 'acceptTerms': string, 'accountSectionProfile': string, 'accountStatusActive': string, 'backToAccount': string, 'backToApplication': string, 'backToLogin': string, + 'captchaFailed': string, + 'captchaLoading': string, 'dangerZoneTitle': string, 'deleteAccountConfirm': string, 'deleteCredentialConfirm': string, @@ -138,12 +141,26 @@ export type HelpwaveIdTranslationEntries = { 'passwordSectionTitle': string, 'personalInfoTitle': string, 'privacy': string, + 'privacyPolicy': string, + 'privacyRequired': string, 'profilePicture': string, 'profilePictureComingSoon': string, + 'profilePictureHelp': string, + 'profilePictureTooLarge': string, + 'profilePictureUploadFailed': string, + 'profilePictureUploading': string, + 'profilePictureWrongType': string, + 'realmBannerAdmin': string, + 'realmBannerOther': string, + 'realmBannerSubtitleAdmin': string, + 'realmBannerSubtitleOther': string, + 'realmBannerSubtitleTeam': string, + 'realmBannerTeam': string, 'recoveryAuthnCodeConfigMessage': string, 'recoveryCode': string, 'register': string, 'rememberMe': string, + 'removeProfilePicture': string, 'resetOtpMessage': string, 'samlPostFormMessage': string, 'securitySectionTitle': string, @@ -157,6 +174,7 @@ export type HelpwaveIdTranslationEntries = { 'successPasswordUpdated': string, 'termsText': string, 'updatePassword': string, + 'uploadProfilePicture': string, 'userCode': string, 'username': string, 'usernameOrEmail': string, @@ -169,12 +187,15 @@ export type HelpwaveIdTranslationEntries = { export const helpwaveIdTranslation: Translation> = { 'de-DE': { + 'acceptPrivacy': `Ich habe die`, 'acceptTerms': `Ich akzeptiere die Allgemeinen Geschäftsbedingungen`, 'accountSectionProfile': `Profil`, 'accountStatusActive': `Aktiv`, 'backToAccount': `Zurück zum Konto`, 'backToApplication': `Zurück zur Anwendung`, 'backToLogin': `Zurück zur Anmeldung`, + 'captchaFailed': `Captcha-Prüfung fehlgeschlagen. Bitte versuchen Sie es erneut.`, + 'captchaLoading': `Captcha wird geladen…`, 'dangerZoneTitle': `Gefahrenzone`, 'deleteAccountConfirm': `Möchten Sie Ihr Konto wirklich löschen?`, 'deleteCredentialConfirm': `Möchten Sie diese Anmeldedaten wirklich löschen?`, @@ -302,12 +323,26 @@ export const helpwaveIdTranslation: Translation = {}; +export const kcEnvDefaults: Record = { + TURNSTILE_SITE_KEY: "", + PROFILE_PICTURE_API_URL: "" +}; /** * NOTE: Do not import this type except maybe in your entrypoint. diff --git a/src/login/KcContext.ts b/src/login/KcContext.ts index 6c8d05a..bd5b8d3 100644 --- a/src/login/KcContext.ts +++ b/src/login/KcContext.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-empty-object-type */ import type { ExtendKcContext } from 'keycloakify/login' import type { KcEnvName, ThemeName } from '../kc.gen' @@ -9,6 +8,10 @@ export type KcContextExtension = { // See: https://docs.keycloakify.dev/faq-and-help/some-values-you-need-are-missing-from-in-kccontext }; -export type KcContextExtensionPerPage = {}; +export type KcContextExtensionPerPage = { + 'register.ftl': { + turnstileSiteKey?: string, + }, +}; export type KcContext = ExtendKcContext; diff --git a/src/login/KcPageStory.tsx b/src/login/KcPageStory.tsx index 968da66..a693609 100644 --- a/src/login/KcPageStory.tsx +++ b/src/login/KcPageStory.tsx @@ -11,7 +11,11 @@ const kcContextExtension: KcContextExtension = { ...kcEnvDefaults } } -const kcContextExtensionPerPage: KcContextExtensionPerPage = {} +const kcContextExtensionPerPage: KcContextExtensionPerPage = { + 'register.ftl': { + turnstileSiteKey: '', + } +} export const { getKcContextMock } = createGetKcContextMock({ kcContextExtension, diff --git a/src/login/components/PageLayout.tsx b/src/login/components/PageLayout.tsx index ef27e12..f206a42 100644 --- a/src/login/components/PageLayout.tsx +++ b/src/login/components/PageLayout.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from 'react' import type { KcContext } from '../KcContext' import { Branding } from './Branding' -import { RealmChip } from './RealmChip' +import { RealmBanner } from './RealmBanner' import { ThemeSwitcher } from './ThemeSwitcher' import { LanguageSwitcher } from './LanguageSwitcher' import { Footer } from './Footer' @@ -16,6 +16,7 @@ export function PageLayout({ kcContext, children }: PageLayoutProps) { return ( <> +
@@ -26,9 +27,6 @@ export function PageLayout({ kcContext, children }: PageLayoutProps) {
-
- -
{children} diff --git a/src/login/components/RealmBanner.tsx b/src/login/components/RealmBanner.tsx new file mode 100644 index 0000000..7331b70 --- /dev/null +++ b/src/login/components/RealmBanner.tsx @@ -0,0 +1,98 @@ +import { useEffect } from 'react' +import { ShieldAlert, Users, Tag } from 'lucide-react' +import { getRealmTheme, type RealmKind } from '../utils/realmColor' +import { useTranslation } from '../../i18n/useTranslation' +import type { HelpwaveIdTranslationEntries } from '../../i18n/translations' + +type RealmBannerProps = { + kcContext: { realm: Record }, +} + +function extractRealmName(realm: Record): string { + const r = realm as { name?: string, realm?: string } + return r.realm ?? r.name ?? 'unknown' +} + +const HEADLINE: Record, keyof HelpwaveIdTranslationEntries> = { + team: 'realmBannerTeam', + admin: 'realmBannerAdmin', + other: 'realmBannerOther', +} + +const SUBTITLE: Record, keyof HelpwaveIdTranslationEntries> = { + team: 'realmBannerSubtitleTeam', + admin: 'realmBannerSubtitleAdmin', + other: 'realmBannerSubtitleOther', +} + +function KindIcon({ kind, className }: { kind: Exclude, className?: string }) { + if (kind === 'admin') return
)} +
+
+ { + setPrivacyAccepted(v) + if (v) setPrivacyError(false) + }} + onEditComplete={() => {}} + size="md" + /> + + +
+ {privacyError && ( +
+ {t('privacyRequired')} +
+ )} +
+ + {captchaEnabled && ( +
+
+ {captchaError && !captchaToken && ( +
+ {t('captchaFailed')} +
+ )} +
+ )} +