Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b69beadd76 | ||
|
|
62d61af5dd | ||
|
|
4e8ffc2ec3 | ||
|
|
eac21b17e2 | ||
|
|
73bbb1a0d7 | ||
|
|
f92307d9e1 | ||
|
|
34b12881b9 | ||
|
|
b7b26d83b2 | ||
|
|
24b7946df1 | ||
|
|
16534ddcee | ||
|
|
e6d9487f0c | ||
|
|
64ffd035c5 | ||
|
|
87d111398c | ||
|
|
24a141a377 | ||
|
|
a1d6a5c8a6 | ||
|
|
d1f053079a | ||
|
|
8b07b9ba0e | ||
|
|
aaf08d4f5c | ||
|
|
b0c0872775 | ||
|
|
f128b03ae5 | ||
|
|
9f9e4e4f50 | ||
|
|
4b26028d01 | ||
|
|
655e84be8d | ||
|
|
4cb8bfd27b | ||
|
|
bdea2a177d | ||
|
|
d8b06e2a89 | ||
|
|
1405695da5 | ||
|
|
ea2862ae04 | ||
|
|
317950cb75 | ||
|
|
8423d723a1 | ||
|
|
cb93f24365 | ||
|
|
f181d0bbe3 | ||
|
|
f57c3adc58 | ||
|
|
56a457085e | ||
|
|
6dcbb5b8e4 | ||
|
|
ace189f5e2 | ||
|
|
d356fb2a2c | ||
|
|
0fb139192d | ||
|
|
40823683be | ||
|
|
1f43987db5 | ||
|
|
e88c28899f | ||
|
|
d5ab64aa61 | ||
|
|
01c5e1efe5 | ||
|
|
0819ed8261 | ||
|
|
338befaffa |
No files matched your search
@@ -24,7 +24,8 @@ Repo rules: `CONTRIBUTING.md`. Gitea: https://git.mariuszpacek.pl/mariusz.pacek/
|
||||
then `git push origin master` and delete the branch locally and on origin. Confirm CI is
|
||||
green on master. Gitea does not close an AGit PR merged by hand: ask the user to close it.
|
||||
6. Release and update only with the user's explicit approval for this one:
|
||||
`scripts/release.sh vMAJOR.MINOR.PATCH` (patch for fixes, minor for features) tags master;
|
||||
`scripts/release.sh vMAJOR.MINOR.PATCH` (patch for fixes, minor for features) waits for green
|
||||
CI on master's head (no local check run), then tags master;
|
||||
wait until the Release run under `…/actions` is green (it publishes the images). Then on the
|
||||
Pi (`ssh <host>`, an alias while D7 is open): `cd /home/pi/home-pump && ./update.sh <version>`,
|
||||
watch `journalctl -t home-pump-gateway -f` and Grafana for one polling cycle (300 s) and
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# One job: check.sh builds the three parts in parallel itself. The runner runs one job at a time
|
||||
# and its actions cache server is unreachable from job containers (ETIMEDOUT), so separate jobs
|
||||
# or setup-java/setup-node caches would only add waiting.
|
||||
name: CI
|
||||
|
||||
on:
|
||||
|
||||
@@ -13,8 +13,20 @@ env:
|
||||
|
||||
jobs:
|
||||
images:
|
||||
name: Images
|
||||
name: Image (${{ matrix.part }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# The Pi is arm64. Each Dockerfile builds on the runner's platform and only copies into
|
||||
# the final stage, so the arm64 image needs no emulation.
|
||||
- part: gateway
|
||||
platforms: linux/arm64,linux/amd64
|
||||
- part: backend
|
||||
platforms: linux/amd64
|
||||
- part: frontend
|
||||
platforms: linux/amd64
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -31,29 +43,14 @@ jobs:
|
||||
username: ${{ gitea.repository_owner }}
|
||||
# The owner's token with package write access (the secret homelab-upgrade uses too).
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
# The Pi is arm64. Each Dockerfile builds on the runner's platform and only copies into the
|
||||
# final stage, so the arm64 image needs no emulation.
|
||||
- name: Gateway
|
||||
# The registry cache keeps the Maven and npm layers between releases.
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: gateway
|
||||
context: ${{ matrix.part }}
|
||||
build-args: VERSION=${{ gitea.ref_name }}
|
||||
platforms: linux/arm64,linux/amd64
|
||||
platforms: ${{ matrix.platforms }}
|
||||
push: true
|
||||
tags: ${{ env.IMAGES }}-gateway:${{ gitea.ref_name }},${{ env.IMAGES }}-gateway:latest
|
||||
- name: Backend
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: backend
|
||||
build-args: VERSION=${{ gitea.ref_name }}
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ env.IMAGES }}-backend:${{ gitea.ref_name }},${{ env.IMAGES }}-backend:latest
|
||||
- name: Frontend
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: frontend
|
||||
build-args: VERSION=${{ gitea.ref_name }}
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ env.IMAGES }}-frontend:${{ gitea.ref_name }},${{ env.IMAGES }}-frontend:latest
|
||||
tags: ${{ env.IMAGES }}-${{ matrix.part }}:${{ gitea.ref_name }},${{ env.IMAGES }}-${{ matrix.part }}:latest
|
||||
cache-from: type=registry,ref=${{ env.IMAGES }}-${{ matrix.part }}:buildcache
|
||||
cache-to: type=registry,ref=${{ env.IMAGES }}-${{ matrix.part }}:buildcache,mode=max
|
||||
+3
-3
@@ -15,8 +15,8 @@ USER 10001:10001
|
||||
WORKDIR /app
|
||||
COPY --from=build /src/target/home-pump-backend-1.0.0.jar app.jar
|
||||
EXPOSE 8080
|
||||
# /api/health answers 200 even while the gateway is unreachable (it reports that in the body),
|
||||
# so this only tells whether the backend itself serves requests. busybox wget ships with alpine.
|
||||
# /api/live needs no session and does not look at the gateway, so it only tells whether the backend
|
||||
# itself serves requests. busybox wget ships with alpine.
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
|
||||
CMD wget -q -O /dev/null "http://127.0.0.1:${PORT:-8080}/api/health" || exit 1
|
||||
CMD wget -q -O /dev/null "http://127.0.0.1:${PORT:-8080}/api/live" || exit 1
|
||||
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||
+68
-7
@@ -23,7 +23,7 @@ The settings come from the gateway's slower `deye_settings` reading and are stal
|
||||
true when the gateway answers its `/battery/reserve`, i.e. it has battery commands on and `GATEWAY_TOKEN` is right; else `reserve` is null
|
||||
and `editable` false. `POST /api/battery/schedule` (examples `battery-schedule-request.json`, `battery-failed.json`) takes
|
||||
`{"enabled"?, "slots": [{"slot": 1-6, "from"?: "HH:MM", "socPct"?, "gridCharge"?}]}` and `POST /api/battery/reserve` takes `{"pct": 60}`.
|
||||
Same Origin, header and JSON rules as the other writes; `Remote-User` goes on as the requester. The gateway's verdict comes back as
|
||||
Same Origin, header and JSON rules as the other writes; the signed-in user goes on as the requester. The gateway's verdict comes back as
|
||||
`{status, message, read}`: 200 applied, 422 rejected, 409 settings unknown or changed meanwhile, 429 too soon, 502 failed (`read` is what the
|
||||
inverter holds), 400 bad body, 503 gateway unreachable or without battery commands (ADR-013).
|
||||
|
||||
@@ -31,7 +31,7 @@ inverter holds), 400 bad body, 503 gateway unreachable or without battery comman
|
||||
`src/test/resources/api/audit.json`: the gateway's audit rows of the heat pump (`Dom_Pompa.setpoint_audit`) and the battery
|
||||
(`electricity.deye_settings_audit`), read only, as `entries` newest first: `time`, `source` (`heat_pump`, `battery`), `what` (Polish label;
|
||||
the Deye registers are named in `DeyeSettingLabels`), `oldValue`/`newValue` (whole numbers, null when not recorded), `unit` (`°C`, `%`,
|
||||
`HHMM`, `0/1`), `who` (`Remote-User` of the request; a restore is "automatycznie (przywrócenie)") and `status` (the gateway's: APPLIED, FAILED, REJECTED, CONFLICT, INVALID).
|
||||
`HHMM`, `0/1`), `who` (the signed-in user; a restore is "automatycznie (przywrócenie)") and `status` (the gateway's: APPLIED, FAILED, REJECTED, CONFLICT, INVALID).
|
||||
|
||||
`GET /api/weather` (example `src/test/resources/api/weather.json`) is the forecast of Open-Meteo for the house and the IMGW warnings that name
|
||||
Krosno (TERYT 1862 or the powiat 1807): `today`, `tomorrow`, `dayAfter` (`date`, `weatherCode` WMO, Polish `description`, `tMax`, `tMin`,
|
||||
@@ -43,7 +43,7 @@ when a refresh fails the older answer is served with `stale` true. 503 only when
|
||||
|
||||
Settings are environment variables (`.env` next to `compose.yaml`); the backend refuses to start
|
||||
without the two URLs.
|
||||
Setpoint changes go only to the gateway; the user name from Traefik's `Remote-User` header (else `phone`) is sent as `X-Requested-By`.
|
||||
Setpoint changes go only to the gateway; the signed-in user is sent as `X-Requested-By`.
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
|---|---|---|
|
||||
@@ -55,15 +55,72 @@ Setpoint changes go only to the gateway; the user name from Traefik's `Remote-Us
|
||||
| `GATEWAY_TOKEN` | unset | shared token for the gateway's `/commands`; without it the backend runs but `/api/setpoints` and `/api/presets/{id}` answer 503 |
|
||||
| `WEATHER_LAT`, `WEATHER_LON` | `49.684`, `21.770` | where the house is (Krosno, ul. Szczepańcowa), for the forecast |
|
||||
| `WEATHER_TERYT` | `1862,1807` | comma-separated TERYT powiat codes whose IMGW warnings count |
|
||||
| `USERS_FILE` | unset | local accounts, see Sign-in; outside the image like `PRESETS_FILE` |
|
||||
| `OIDC_ISSUER` | unset | Keycloak realm URL, e.g. `https://login.mariuszpacek.pl/realms/home`; unset turns OIDC off |
|
||||
| `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_REDIRECT_URI` | required with `OIDC_ISSUER` | the confidential client; redirect `https://pompa.mariuszpacek.pl/api/auth/oidc/callback` |
|
||||
| `OIDC_REQUIRED_ROLE` | unset | a role the user must have: realm role (`realm_access.roles`) or role of this client (`resource_access.<client>.roles`) in the ID token |
|
||||
| `SESSION_SECRET` | required with `USERS_FILE` or `OIDC_ISSUER` | at least 32 bytes signing the cookies, e.g. `openssl rand -base64 48`; changing it signs everyone out |
|
||||
| `SESSION_DAYS` | `30` | how long a session lasts |
|
||||
| `TRUSTED_PROXY_HOPS` | `1` | proxies that append to `X-Forwarded-For` before the backend (Traefik: 1), for the login throttle |
|
||||
| `AUTH_DISABLED` | unset | `true` runs without sign-in (every request is `phone`), for development; refused together with `USERS_FILE` or `OIDC_ISSUER` |
|
||||
| `PRESETS_FILE` | built-in | JSON file replacing the presets: `[{"id", "name", "changes": [{"setpoint", "value", "hours"}]}]`, `hours` null means until changed; a bad file stops the start. A preset may also have `"until": "chosen"` (then no `hours`): the user picks the end, see below |
|
||||
|
||||
```bash
|
||||
mvn -f backend/pom.xml clean package
|
||||
GATEWAY_URL=http://10.0.0.2:8080 INFLUX_URL=https://influxdb.example \
|
||||
GATEWAY_URL=http://10.0.0.2:8080 INFLUX_URL=https://influxdb.example AUTH_DISABLED=true \
|
||||
java -jar backend/target/home-pump-backend-1.0.0.jar # API at http://localhost:8080/api/
|
||||
docker compose -f backend/compose.yaml up --build # with backend/.env; network home-pump
|
||||
```
|
||||
|
||||
## Sign-in (ADR-014)
|
||||
|
||||
Without `USERS_FILE` or `OIDC_ISSUER` the backend refuses to start unless `AUTH_DISABLED=true`. The two ways in are
|
||||
independent: local accounts keep working when Keycloak is down, and the backend never waits for Keycloak at start
|
||||
(discovery and keys are fetched at the first sign-in and kept an hour). Every endpoint but those below answers
|
||||
`401 {"error":"unauthenticated"}` without a valid session; the writes keep their Origin, header and JSON rules.
|
||||
|
||||
| Endpoint | Answer |
|
||||
|---|---|
|
||||
| `GET /api/auth/config` | `{"local", "oidc", "setup"}` booleans (`auth-config.json`); `setup` is true while `USERS_FILE` has no account |
|
||||
| `POST /api/auth/setup` `{"username","password"}` | first account, once: 200 `{"user","method":"local"}` and the cookie; 409 `{"error":"setup_done"}` once any account exists; 400 `weak_password` (under 10 characters) or `invalid_username`; 404 without `USERS_FILE` |
|
||||
| `GET /api/auth/users` | session needed: `[{"username"}]` |
|
||||
| `POST /api/auth/users` `{"username","password"}` | session needed: adds a local account; 200 `{"username"}`, 409 `exists`, 400 as setup |
|
||||
| `POST /api/auth/password` `{"current","new"}` | session needed, local sessions only (403 `local_only` otherwise): 204; 401 `invalid` (counts toward the throttle), 429, 400 `weak_password` |
|
||||
| `POST /api/auth/users/{name}/delete` | session needed: 204 (removing yourself also clears your cookie); 404 `unknown_user`; 409 `last_user` |
|
||||
| `POST /api/auth/login` `{"username","password"}` | 200 `{"user","method":"local"}` and the cookie; 401 `{"error":"invalid"}`; 429 `{"error":"throttled","retryAfterSeconds"}` with `Retry-After`; 404 without `USERS_FILE` |
|
||||
| `POST /api/auth/logout` | 204, cookie cleared |
|
||||
| `GET /api/auth/me` | 200 `{"user","method"}` (`auth-me.json`; `method` is `local`, `oidc`, or `none` with `AUTH_DISABLED`) or 401 |
|
||||
| `GET /api/auth/oidc/start` | 302 to Keycloak; 404 without OIDC |
|
||||
| `GET /api/auth/oidc/callback` | 302 to `/`, or to `/#login?error=oidc_unavailable` (Keycloak down), `oidc_denied` (refused or role missing), `oidc_failed` (bad state, token or code) |
|
||||
| `GET /api/live` | 200 `ok`, for the container healthcheck and external monitors: `/api/health` needs a session now |
|
||||
|
||||
The session is a cookie `hp_session` (HttpOnly, Secure, SameSite=Lax, Path=/) signed with HMAC-SHA256 and holding user, method,
|
||||
issue time and expiry; there is no server state, so a stolen cookie lives until it expires or the secret changes; a `local` session also ends when its account
|
||||
is removed (it is checked on every request).
|
||||
The OIDC flow is authorization code with PKCE S256, state and nonce, in a 10 minute signed cookie `hp_oidc`; the ID token is
|
||||
verified here (RS256 against the JWKS key by `kid`, `iss`, `aud` and `azp`, `exp`/`iat` with 60 s skew, `nonce`).
|
||||
The user name is `preferred_username`, else `email`, else `sub`.
|
||||
|
||||
**Local accounts**: `USERS_FILE` is JSON `[{"username": "tata", "passwordHash": "pbkdf2-sha256$600000$<salt>$<hash>"}]`, a username of
|
||||
1 to 64 of `A-Za-z0-9_.@+-`, a bad file stops the start. The app edits the file: it rewrites it atomically (temp file in the same directory, fsync, rename, mode 600), so mount a **directory** (`./auth:/config/auth`, `USERS_FILE=/config/auth/users.json`) that uid 10001 owns: `chown 10001:10001 auth`. A missing file or one with no accounts is setup mode: the first visitor to the app creates the first account in the app (accepted risk, ADR-014: Traefik admits only the two home networks). Add, remove accounts and change a password in the app; each change is logged (who, what, never a password). A hash can still be made by hand, without echoing the password (asked twice on a terminal;
|
||||
a pipe reads one line):
|
||||
|
||||
```bash
|
||||
docker run --rm -it git.mariuszpacek.pl/mariusz.pacek/home-pump-backend:latest hash-password
|
||||
java -jar backend/target/home-pump-backend-1.0.0.jar hash-password
|
||||
```
|
||||
|
||||
Five login attempts in 15 minutes block that username and, separately, that client address (429, `Retry-After`; an attempt is
|
||||
counted before the password is checked, so parallel guesses cannot outrun it, and a success clears the username); a blocked
|
||||
username is refused even with the right password. At most 4 passwords are hashed at once, more get 429 at once. An unknown username costs one password hash like a wrong password.
|
||||
The address is the entry `TRUSTED_PROXY_HOPS` from the right of `X-Forwarded-For` (Traefik appends the address it saw;
|
||||
what a client put in front is ignored); without it the socket's address.
|
||||
|
||||
**Keycloak client** in the realm of `OIDC_ISSUER`: Client authentication on (confidential), only Standard flow, PKCE method
|
||||
`S256`, valid redirect URI `https://pompa.mariuszpacek.pl/api/auth/oidc/callback`, web origin `https://pompa.mariuszpacek.pl`.
|
||||
Its secret is `OIDC_CLIENT_SECRET`. Without `OIDC_REQUIRED_ROLE`, every user of the realm can sign in; the backend logs a warning at start. For `OIDC_REQUIRED_ROLE`, the client scope `roles` must put the roles into the ID token
|
||||
(its realm and client role mappers: "Add to ID token" on).
|
||||
|
||||
`POST /api/presets/{id}` takes no body for an ordinary preset. A preset with `"until": "chosen"` (the built-in "Wyjazd")
|
||||
needs `{"until": "2026-11-02T18:00:00+01:00"}` (ISO-8601 with offset, example `src/test/resources/api/preset-away-request.json`):
|
||||
400 when it is missing, invalid, or sent to a preset that has none. Any preset also takes optional `"values"`
|
||||
@@ -72,15 +129,19 @@ the others keep it. 400 for a setpoint the preset does not change or a value out
|
||||
(from `/commands`), checked before anything is sent; a held value that is not below the current one is 422. The gateway holds those changes until then without
|
||||
renewals (ADR-012) and rejects an end that is past or over 30 days away, or a change that does not lower the value (422).
|
||||
|
||||
`POST /api/refresh` (same Origin, header and JSON rules; send `{}`) is the "Odśwież" button: it asks the gateway for one
|
||||
extra poll of every device and passes its answer through, example `src/test/resources/api/refresh.json`
|
||||
(`requested`, and `skipped` with `retryInSeconds` for devices polled within the last minute). Read-only; 503 when
|
||||
`GATEWAY_TOKEN` is not set or the gateway is unreachable.
|
||||
|
||||
`POST /api/setpoints/{name}/restore` (same Origin, header and JSON rules as the other writes; send `{}`) ends the
|
||||
pending change of that setpoint now and answers with the gateway's verdict, example
|
||||
`src/test/resources/api/setpoint-restored.json`: 200 restored, 404 nothing pending, 409 the value was changed
|
||||
meanwhile (left alone, the restore is dropped), 429 too soon after the last write, 502 failed (the restore stays
|
||||
pending), 503 gateway unreachable.
|
||||
|
||||
Publish no host port in production: only the frontend, behind Traefik, may reach the backend. It
|
||||
trusts `Remote-User` and has no login of its own, so a direct port would bypass Traefik's access
|
||||
rules (ADR-009).
|
||||
Publish no host port in production: only the frontend, behind Traefik, may reach the backend, so Traefik's
|
||||
access rules (ADR-009) stay in front of the sign-in.
|
||||
|
||||
`/api/health` also carries `backendVersion` (the `VERSION` the image was built with, `dev` locally) and
|
||||
`gatewayVersion` (from the gateway's `/health`; null when it is unreachable or too old to report it).
|
||||
@@ -9,6 +9,13 @@ services:
|
||||
env_file: .env
|
||||
environment:
|
||||
TZ: Europe/Warsaw
|
||||
# Sign-in (README): the accounts directory, writable by uid 10001 (chown 10001:10001 auth); the app rewrites users.json in it. In .env, for example:
|
||||
# USERS_FILE=/config/auth/users.json SESSION_SECRET=<openssl rand -base64 48>
|
||||
# OIDC_ISSUER=https://login.mariuszpacek.pl/realms/home OIDC_CLIENT_ID=home-pump
|
||||
# OIDC_CLIENT_SECRET=<from Keycloak> OIDC_REQUIRED_ROLE=family
|
||||
# OIDC_REDIRECT_URI=https://pompa.mariuszpacek.pl/api/auth/oidc/callback
|
||||
volumes:
|
||||
- ./auth:/config/auth
|
||||
networks: [home-pump]
|
||||
|
||||
networks:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package home.backend;
|
||||
|
||||
import home.backend.adapters.auth.Authentication;
|
||||
import home.backend.adapters.config.PresetFile;
|
||||
import home.backend.adapters.gateway.GatewayHttpClient;
|
||||
import home.backend.adapters.gateway.GatewayHttpCommands;
|
||||
@@ -34,6 +35,8 @@ import java.util.Objects;
|
||||
final class Backend {
|
||||
|
||||
private static final Duration WEATHER_TIMEOUT = Duration.ofSeconds(10);
|
||||
/** Warnings are optional, so a slow IMGW must not hold the forecast. */
|
||||
private static final Duration WARNINGS_TIMEOUT = Duration.ofSeconds(3);
|
||||
|
||||
private Backend() {
|
||||
}
|
||||
@@ -41,7 +44,7 @@ final class Backend {
|
||||
/**
|
||||
* Binds the port but does not start serving.
|
||||
*
|
||||
* @throws IllegalArgumentException when PRESETS_FILE is unusable
|
||||
* @throws IllegalArgumentException when PRESETS_FILE or USERS_FILE is unusable
|
||||
*/
|
||||
static ApiServer assemble(Settings settings, Clock clock) throws IOException {
|
||||
var gateway = new GatewayHttpClient(settings.gatewayUrl(), settings.gatewayTimeout());
|
||||
@@ -57,14 +60,15 @@ final class Backend {
|
||||
new HistoryService(influx, clock), setpoints, new PiSensorService(gateway, clock),
|
||||
new BatteryService(gateway, commands, clock),
|
||||
new AuditService(new InfluxAuditRepository(influxSettings), clock), new StatsService(influx, clock),
|
||||
weather(influxSettings, clock));
|
||||
weather(influxSettings, clock), commands,
|
||||
Authentication.create(settings.auth(), clock));
|
||||
}
|
||||
|
||||
private static WeatherService weather(InfluxSettings influx, Clock clock) {
|
||||
var place = WeatherSettings.fromEnvironment(System.getenv());
|
||||
return new WeatherService(
|
||||
new OpenMeteoClient(URI.create("https://api.open-meteo.com"), place, WEATHER_TIMEOUT),
|
||||
new ImgwClient(URI.create("https://danepubliczne.imgw.pl"), place, WEATHER_TIMEOUT),
|
||||
new ImgwClient(URI.create("https://danepubliczne.imgw.pl"), place, WARNINGS_TIMEOUT),
|
||||
new InfluxPvEnergy(influx), clock);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ package home.backend;
|
||||
|
||||
import home.backend.adapters.http.ApiServer;
|
||||
|
||||
import home.backend.adapters.auth.PasswordHashCli;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
|
||||
@@ -11,6 +13,9 @@ public final class Main {
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws IOException {
|
||||
if (args.length == 1 && args[0].equals("hash-password")) {
|
||||
System.exit(PasswordHashCli.run(System.console(), System.in, System.out, System.err));
|
||||
}
|
||||
Settings settings;
|
||||
ApiServer server;
|
||||
try {
|
||||
@@ -21,6 +26,7 @@ public final class Main {
|
||||
System.exit(2);
|
||||
return;
|
||||
}
|
||||
settings.auth().warnings().forEach(w -> System.err.println("home-pump-backend: WARNING: " + w));
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(server::stop));
|
||||
if (settings.gatewayToken() == null) {
|
||||
System.err.println("home-pump-backend: GATEWAY_TOKEN is not set, setpoint changes are disabled");
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package home.backend;
|
||||
|
||||
import home.backend.adapters.auth.AuthSettings;
|
||||
import home.backend.adapters.auth.OidcSettings;
|
||||
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
@@ -8,7 +12,7 @@ import java.util.Optional;
|
||||
|
||||
/** Twelve-factor settings: everything comes from environment variables. */
|
||||
public record Settings(URI gatewayUrl, URI influxUrl, int port, Duration gatewayTimeout, Duration influxTimeout,
|
||||
String gatewayToken, Path presetsFile) {
|
||||
String gatewayToken, Path presetsFile, AuthSettings auth) {
|
||||
|
||||
public static Settings fromEnvironment(Map<String, String> env) {
|
||||
return new Settings(
|
||||
@@ -18,7 +22,44 @@ public record Settings(URI gatewayUrl, URI influxUrl, int port, Duration gateway
|
||||
Duration.ofSeconds(number(env, "GATEWAY_TIMEOUT", 5, 1, 300)),
|
||||
Duration.ofSeconds(number(env, "INFLUX_TIMEOUT", 10, 1, 300)),
|
||||
optional(env, "GATEWAY_TOKEN"),
|
||||
Optional.ofNullable(optional(env, "PRESETS_FILE")).map(Path::of).orElse(null));
|
||||
Optional.ofNullable(optional(env, "PRESETS_FILE")).map(Path::of).orElse(null),
|
||||
auth(env));
|
||||
}
|
||||
|
||||
private static final int MIN_SECRET_BYTES = 32;
|
||||
|
||||
/** Refuses to start open by accident: sign-in is configured, or AUTH_DISABLED=true says it is meant. */
|
||||
private static AuthSettings auth(Map<String, String> env) {
|
||||
Path usersFile = Optional.ofNullable(optional(env, "USERS_FILE")).map(Path::of).orElse(null);
|
||||
OidcSettings oidc = optional(env, "OIDC_ISSUER") == null ? null : oidc(env);
|
||||
String secret = optional(env, "SESSION_SECRET");
|
||||
boolean disabled = "true".equals(optional(env, "AUTH_DISABLED"));
|
||||
int days = (int) number(env, "SESSION_DAYS", 30, 1, 365);
|
||||
int hops = (int) number(env, "TRUSTED_PROXY_HOPS", 1, 1, 10);
|
||||
boolean configured = usersFile != null || oidc != null;
|
||||
if (disabled && configured) {
|
||||
throw new IllegalArgumentException("AUTH_DISABLED=true contradicts USERS_FILE and OIDC_ISSUER: unset one");
|
||||
}
|
||||
if (!disabled && !configured) {
|
||||
throw new IllegalArgumentException("no sign-in configured: set USERS_FILE and/or OIDC_ISSUER, "
|
||||
+ "or AUTH_DISABLED=true for development only");
|
||||
}
|
||||
if (configured && (secret == null || secret.getBytes(StandardCharsets.UTF_8).length < MIN_SECRET_BYTES)) {
|
||||
throw new IllegalArgumentException("SESSION_SECRET is required and must be at least " + MIN_SECRET_BYTES
|
||||
+ " bytes, e.g. from: openssl rand -base64 48");
|
||||
}
|
||||
return new AuthSettings(usersFile, oidc, secret, days, disabled, hops);
|
||||
}
|
||||
|
||||
private static OidcSettings oidc(Map<String, String> env) {
|
||||
for (String name : new String[] {"OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "OIDC_REDIRECT_URI"}) {
|
||||
if (optional(env, name) == null) {
|
||||
throw new IllegalArgumentException(name + " is required when OIDC_ISSUER is set");
|
||||
}
|
||||
}
|
||||
return new OidcSettings(URI.create(optional(env, "OIDC_ISSUER")), optional(env, "OIDC_CLIENT_ID"),
|
||||
optional(env, "OIDC_CLIENT_SECRET"), URI.create(optional(env, "OIDC_REDIRECT_URI")),
|
||||
optional(env, "OIDC_REQUIRED_ROLE"));
|
||||
}
|
||||
|
||||
/** Null when unset or blank. */
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* How users sign in. At least one of usersFile and oidc, or disabled; the secret signs the cookies.
|
||||
* proxyHops is how many proxies append to X-Forwarded-For before the backend (Traefik: 1).
|
||||
*/
|
||||
public record AuthSettings(Path usersFile, OidcSettings oidc, String sessionSecret, int sessionDays,
|
||||
boolean disabled, int proxyHops) {
|
||||
|
||||
/** Things worth saying at startup. */
|
||||
public List<String> warnings() {
|
||||
if (oidc != null && oidc.requiredRole() == null) {
|
||||
return List.of("OIDC_REQUIRED_ROLE is not set: every user of the Keycloak realm can sign in");
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.Semaphore;
|
||||
|
||||
/** Who may use the API: local accounts and OIDC, independent of each other, both ending in a session cookie. */
|
||||
public final class Authentication {
|
||||
|
||||
public static final String LOCAL = "local";
|
||||
|
||||
public enum Status { OK, INVALID, THROTTLED }
|
||||
|
||||
public record Login(Status status, long retryAfterSeconds) {
|
||||
}
|
||||
|
||||
private static final int MAX_FAILURES = 5;
|
||||
private static final Duration THROTTLE_WINDOW = Duration.ofMinutes(15);
|
||||
/** Password hashing is slow on purpose; a flood must not queue up behind it. */
|
||||
private static final int PARALLEL_HASHES = 4;
|
||||
private static final int MIN_PASSWORD = 10;
|
||||
private static final long BUSY_RETRY_SECONDS = 1;
|
||||
|
||||
private final Sessions sessions;
|
||||
private final UserStore users;
|
||||
private final LoginThrottle throttle;
|
||||
private final OidcFlow oidc;
|
||||
private final int proxyHops;
|
||||
private final Semaphore hashing = new Semaphore(PARALLEL_HASHES);
|
||||
|
||||
private Authentication(Sessions sessions, UserStore users, LoginThrottle throttle, OidcFlow oidc, int proxyHops) {
|
||||
this.sessions = sessions;
|
||||
this.users = users;
|
||||
this.throttle = throttle;
|
||||
this.oidc = oidc;
|
||||
this.proxyHops = proxyHops;
|
||||
}
|
||||
|
||||
/** @throws IllegalArgumentException when USERS_FILE is unusable */
|
||||
public static Authentication create(AuthSettings settings, Clock clock) {
|
||||
if (settings.disabled()) {
|
||||
return new Authentication(null, null, null, null, settings.proxyHops());
|
||||
}
|
||||
var tokens = new SignedTokens(settings.sessionSecret().getBytes(StandardCharsets.UTF_8), clock);
|
||||
return new Authentication(new Sessions(tokens, clock, Duration.ofDays(settings.sessionDays())),
|
||||
settings.usersFile() == null ? null : UserStore.load(settings.usersFile()),
|
||||
new LoginThrottle(clock, MAX_FAILURES, THROTTLE_WINDOW),
|
||||
settings.oidc() == null ? null : new OidcFlow(settings.oidc(), tokens, clock),
|
||||
settings.proxyHops());
|
||||
}
|
||||
|
||||
/** For development and tests: every request is the user "phone". */
|
||||
public static Authentication disabled() {
|
||||
return new Authentication(null, null, null, null, 1);
|
||||
}
|
||||
|
||||
public boolean localEnabled() {
|
||||
return users != null;
|
||||
}
|
||||
|
||||
public boolean oidcEnabled() {
|
||||
return oidc != null;
|
||||
}
|
||||
|
||||
public OidcFlow oidc() {
|
||||
return oidc;
|
||||
}
|
||||
|
||||
public int proxyHops() {
|
||||
return proxyHops;
|
||||
}
|
||||
|
||||
public Optional<Session> session(String cookieHeader) {
|
||||
if (sessions == null) {
|
||||
return Optional.of(Session.OPEN);
|
||||
}
|
||||
// A removed account loses its sessions at once; OIDC sessions end with their expiry.
|
||||
return sessions.read(cookieHeader).filter(s -> !LOCAL.equals(s.method()) || users != null && users.exists(s.user()));
|
||||
}
|
||||
|
||||
/** Checks the password unless the user or the address is blocked; a blocked one learns nothing about it. */
|
||||
public Login login(String username, char[] password, String clientIp) {
|
||||
String userKey = "user:" + username.toLowerCase(Locale.ROOT);
|
||||
String ipKey = "ip:" + clientIp;
|
||||
long wait = throttle.tryAcquire(userKey, ipKey);
|
||||
if (wait > 0) {
|
||||
return new Login(Status.THROTTLED, wait);
|
||||
}
|
||||
if (!hashing.tryAcquire()) {
|
||||
throttle.release(userKey, ipKey);
|
||||
return new Login(Status.THROTTLED, BUSY_RETRY_SECONDS);
|
||||
}
|
||||
try {
|
||||
if (users.authenticate(username, password)) {
|
||||
throttle.cleared(userKey);
|
||||
throttle.release(ipKey);
|
||||
return new Login(Status.OK, 0);
|
||||
}
|
||||
return new Login(Status.INVALID, 0);
|
||||
} finally {
|
||||
hashing.release();
|
||||
}
|
||||
}
|
||||
|
||||
public boolean setupNeeded() {
|
||||
return users != null && users.isEmpty();
|
||||
}
|
||||
|
||||
public static boolean strongEnough(String password) {
|
||||
return password.length() >= MIN_PASSWORD;
|
||||
}
|
||||
|
||||
/** Creates the first account, once: OK for the winner, INVALID (setup is done) for everyone after. */
|
||||
public Login setup(String username, char[] password, String clientIp) {
|
||||
String ipKey = "ip:" + clientIp;
|
||||
long wait = throttle.tryAcquire(ipKey);
|
||||
if (wait > 0) {
|
||||
return new Login(Status.THROTTLED, wait);
|
||||
}
|
||||
if (!users.addFirst(username, PasswordHash.create(password))) {
|
||||
return new Login(Status.INVALID, 0);
|
||||
}
|
||||
System.err.println("home-pump-backend: setup created the first account " + username);
|
||||
throttle.release(ipKey);
|
||||
return new Login(Status.OK, 0);
|
||||
}
|
||||
|
||||
public java.util.List<String> usernames() {
|
||||
return users.usernames();
|
||||
}
|
||||
|
||||
/** @return false when the name is taken */
|
||||
public boolean addUser(String by, String username, char[] password) {
|
||||
boolean added = users.add(username, PasswordHash.create(password));
|
||||
System.err.println("home-pump-backend: " + by + " added account " + username + (added ? "" : " (refused, exists)"));
|
||||
return added;
|
||||
}
|
||||
|
||||
/** Checks the current password like a login does (throttled), then sets the new one. */
|
||||
public Login changePassword(String username, char[] current, char[] next, String clientIp) {
|
||||
Login login = login(username, current, clientIp);
|
||||
if (login.status() == Status.OK) {
|
||||
users.changePassword(username, PasswordHash.create(next));
|
||||
System.err.println("home-pump-backend: " + username + " changed their password");
|
||||
}
|
||||
return login;
|
||||
}
|
||||
|
||||
public UserStore.Removal removeUser(String by, String username) {
|
||||
var removal = users.remove(username);
|
||||
System.err.println("home-pump-backend: " + by + " removed account " + username + ": " + removal);
|
||||
return removal;
|
||||
}
|
||||
|
||||
/** The Set-Cookie header value that signs the user in. */
|
||||
public String sessionCookie(String user, String method) {
|
||||
return sessions.setCookie(user, method);
|
||||
}
|
||||
|
||||
public String clearedSessionCookie() {
|
||||
return sessions == null ? "" : sessions.clearCookie();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import java.util.Base64;
|
||||
|
||||
/** Unpadded base64url, as tokens and PKCE use it. */
|
||||
final class Base64Url {
|
||||
|
||||
private Base64Url() {
|
||||
}
|
||||
|
||||
static String encode(byte[] bytes) {
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||
}
|
||||
|
||||
static byte[] decode(String text) {
|
||||
return Base64.getUrlDecoder().decode(text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Reads the Cookie header and builds Set-Cookie headers with the flags every cookie here carries. */
|
||||
final class Cookies {
|
||||
|
||||
private Cookies() {
|
||||
}
|
||||
|
||||
static Optional<String> read(String header, String name) {
|
||||
if (header == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
for (String pair : header.split(";")) {
|
||||
String[] kv = pair.trim().split("=", 2);
|
||||
if (kv.length == 2 && kv[0].equals(name)) {
|
||||
return Optional.of(kv[1]);
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
static String set(String name, String value, Duration maxAge, String path) {
|
||||
return name + "=" + value + "; Max-Age=" + maxAge.toSeconds() + attributes(path);
|
||||
}
|
||||
|
||||
static String clear(String name, String path) {
|
||||
return name + "=; Max-Age=0" + attributes(path);
|
||||
}
|
||||
|
||||
private static String attributes(String path) {
|
||||
return "; Path=" + path + "; HttpOnly; Secure; SameSite=Lax";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.PublicKey;
|
||||
import java.security.Signature;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static home.backend.adapters.auth.OidcException.Reason.DENIED;
|
||||
import static home.backend.adapters.auth.OidcException.Reason.FAILED;
|
||||
|
||||
/** Checks an ID token with the JDK alone: RS256 signature, issuer, audience, times, nonce and the required role. */
|
||||
final class IdTokenVerifier {
|
||||
|
||||
/** The signing keys of the identity provider. */
|
||||
interface Keys {
|
||||
PublicKey key(String kid) throws OidcException;
|
||||
}
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
private static final Duration SKEW = Duration.ofSeconds(60);
|
||||
|
||||
private final OidcSettings settings;
|
||||
private final Clock clock;
|
||||
|
||||
IdTokenVerifier(OidcSettings settings, Clock clock) {
|
||||
this.settings = settings;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
/** @return the user name of the token */
|
||||
String verify(String idToken, String nonce, Keys keys) throws OidcException {
|
||||
String[] parts = idToken.split("\\.", -1);
|
||||
if (parts.length != 3) {
|
||||
throw new OidcException(FAILED, "the ID token is not a JWT");
|
||||
}
|
||||
JsonNode header = json(parts[0]);
|
||||
if (!"RS256".equals(header.path("alg").asText())) {
|
||||
throw new OidcException(FAILED, "the ID token is not signed with RS256");
|
||||
}
|
||||
verifySignature(parts[0] + "." + parts[1], parts[2], keys.key(header.path("kid").asText()));
|
||||
JsonNode claims = json(parts[1]);
|
||||
checkClaims(claims, nonce);
|
||||
checkRole(claims);
|
||||
return userName(claims);
|
||||
}
|
||||
|
||||
private void checkClaims(JsonNode claims, String nonce) throws OidcException {
|
||||
long now = clock.instant().getEpochSecond();
|
||||
if (!settings.issuerBase().equals(claims.path("iss").asText("").replaceAll("/+$", ""))) {
|
||||
throw new OidcException(FAILED, "wrong issuer");
|
||||
}
|
||||
if (!audiences(claims).contains(settings.clientId())) {
|
||||
throw new OidcException(FAILED, "the token is for another client");
|
||||
}
|
||||
if (audiences(claims).size() > 1 && !settings.clientId().equals(claims.path("azp").asText())) {
|
||||
throw new OidcException(FAILED, "wrong authorized party");
|
||||
}
|
||||
if (!claims.path("exp").canConvertToLong() || claims.get("exp").asLong() + SKEW.toSeconds() <= now) {
|
||||
throw new OidcException(FAILED, "the token has expired");
|
||||
}
|
||||
if (!claims.path("iat").canConvertToLong() || claims.get("iat").asLong() - SKEW.toSeconds() > now) {
|
||||
throw new OidcException(FAILED, "the token is issued in the future");
|
||||
}
|
||||
boolean sameNonce = MessageDigest.isEqual(nonce.getBytes(StandardCharsets.UTF_8),
|
||||
claims.path("nonce").asText("").getBytes(StandardCharsets.UTF_8));
|
||||
if (!sameNonce) {
|
||||
throw new OidcException(FAILED, "wrong nonce");
|
||||
}
|
||||
}
|
||||
|
||||
/** A realm role or a role of this client, as Keycloak puts them in {@code realm_access} and {@code resource_access}. */
|
||||
private void checkRole(JsonNode claims) throws OidcException {
|
||||
String role = settings.requiredRole();
|
||||
if (role == null) {
|
||||
return;
|
||||
}
|
||||
boolean has = contains(claims.path("realm_access").path("roles"), role)
|
||||
|| contains(claims.path("resource_access").path(settings.clientId()).path("roles"), role);
|
||||
if (!has) {
|
||||
throw new OidcException(DENIED, "the user lacks the role " + role);
|
||||
}
|
||||
}
|
||||
|
||||
private String userName(JsonNode claims) throws OidcException {
|
||||
for (String claim : new String[] {"preferred_username", "email", "sub"}) {
|
||||
String name = claims.path(claim).asText("");
|
||||
if (Session.isValidUser(name)) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
throw new OidcException(FAILED, "the token has no usable user name");
|
||||
}
|
||||
|
||||
private static List<String> audiences(JsonNode claims) {
|
||||
JsonNode aud = claims.path("aud");
|
||||
var all = new ArrayList<String>();
|
||||
if (aud.isArray()) {
|
||||
aud.forEach(a -> all.add(a.asText()));
|
||||
} else if (aud.isTextual()) {
|
||||
all.add(aud.asText());
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
private static boolean contains(JsonNode array, String value) {
|
||||
for (JsonNode item : array) {
|
||||
if (value.equals(item.asText())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void verifySignature(String signedPart, String signature, PublicKey key) throws OidcException {
|
||||
try {
|
||||
Signature rsa = Signature.getInstance("SHA256withRSA");
|
||||
rsa.initVerify(key);
|
||||
rsa.update(signedPart.getBytes(StandardCharsets.US_ASCII));
|
||||
if (!rsa.verify(Base64Url.decode(signature))) {
|
||||
throw new OidcException(FAILED, "the ID token signature is wrong");
|
||||
}
|
||||
} catch (GeneralSecurityException | IllegalArgumentException e) {
|
||||
throw new OidcException(FAILED, "the ID token signature cannot be checked", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonNode json(String part) throws OidcException {
|
||||
try {
|
||||
return JSON.readTree(Base64Url.decode(part));
|
||||
} catch (IOException | IllegalArgumentException e) {
|
||||
throw new OidcException(FAILED, "the ID token is malformed", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/** Blocks a key (a username or a client address) after too many failures within a window. */
|
||||
public final class LoginThrottle {
|
||||
|
||||
private final Clock clock;
|
||||
private final int maxFailures;
|
||||
private final Duration window;
|
||||
/** The newest failures of each key, at most {@code maxFailures}, oldest first. */
|
||||
private final Map<String, ArrayDeque<Instant>> failures = new HashMap<>();
|
||||
|
||||
public LoginThrottle(Clock clock, int maxFailures, Duration window) {
|
||||
this.clock = clock;
|
||||
this.maxFailures = maxFailures;
|
||||
this.window = window;
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts an attempt against every key before the password is checked, so parallel guesses cannot
|
||||
* outrun the limit; {@link #release} takes it back.
|
||||
*
|
||||
* @return 0 when the attempt may go on, else the seconds until the most blocked key may try again
|
||||
*/
|
||||
public synchronized long tryAcquire(String... keys) {
|
||||
Instant now = clock.instant();
|
||||
long wait = 0;
|
||||
for (String key : keys) {
|
||||
var times = failures.get(key);
|
||||
if (times != null && times.size() >= maxFailures && times.peekFirst().plus(window).isAfter(now)) {
|
||||
wait = Math.max(wait, Duration.between(now, times.peekFirst().plus(window)).toSeconds() + 1);
|
||||
}
|
||||
}
|
||||
if (wait > 0) {
|
||||
return wait;
|
||||
}
|
||||
failures.values().removeIf(times -> !times.peekLast().plus(window).isAfter(now));
|
||||
for (String key : keys) {
|
||||
var times = failures.computeIfAbsent(key, k -> new ArrayDeque<>());
|
||||
times.addLast(now);
|
||||
if (times.size() > maxFailures) {
|
||||
times.removeFirst();
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Takes back the newest attempt of each key. */
|
||||
public synchronized void release(String... keys) {
|
||||
for (String key : keys) {
|
||||
var times = failures.get(key);
|
||||
if (times != null) {
|
||||
times.pollLast();
|
||||
if (times.isEmpty()) {
|
||||
failures.remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void cleared(String key) {
|
||||
failures.remove(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
/** Why an OIDC sign-in did not happen; the code is what the login screen is told. */
|
||||
public final class OidcException extends Exception {
|
||||
|
||||
public enum Reason {
|
||||
/** Keycloak did not answer. */
|
||||
UNAVAILABLE("oidc_unavailable"),
|
||||
/** The user said no, or lacks the required role. */
|
||||
DENIED("oidc_denied"),
|
||||
/** Anything else: bad state, a token that does not verify, a refused code. */
|
||||
FAILED("oidc_failed");
|
||||
|
||||
private final String code;
|
||||
|
||||
Reason(String code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public String code() {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
private final Reason reason;
|
||||
|
||||
public OidcException(Reason reason, String message) {
|
||||
super(message);
|
||||
this.reason = reason;
|
||||
}
|
||||
|
||||
public OidcException(Reason reason, String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
this.reason = reason;
|
||||
}
|
||||
|
||||
public Reason reason() {
|
||||
return reason;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static home.backend.adapters.auth.OidcException.Reason.DENIED;
|
||||
import static home.backend.adapters.auth.OidcException.Reason.FAILED;
|
||||
|
||||
/**
|
||||
* Authorization code flow with PKCE (S256), state and nonce. What must survive the trip to the identity
|
||||
* provider travels in a short-lived signed cookie, so the backend keeps no state.
|
||||
*/
|
||||
public final class OidcFlow {
|
||||
|
||||
/** Where to send the browser, and the cookie to set on that response. */
|
||||
public record Start(URI location, String setCookie) {
|
||||
}
|
||||
|
||||
private static final String COOKIE = "hp_oidc";
|
||||
private static final String PURPOSE = "oidc-login";
|
||||
private static final String PATH = "/api/auth/oidc";
|
||||
private static final Duration LOGIN_TIME = Duration.ofMinutes(10);
|
||||
private static final Duration TIMEOUT = Duration.ofSeconds(5);
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
|
||||
private final OidcSettings settings;
|
||||
private final OidcProvider provider;
|
||||
private final IdTokenVerifier verifier;
|
||||
private final SignedTokens tokens;
|
||||
|
||||
OidcFlow(OidcSettings settings, SignedTokens tokens, Clock clock) {
|
||||
this.settings = settings;
|
||||
this.provider = new OidcProvider(settings, TIMEOUT, clock);
|
||||
this.verifier = new IdTokenVerifier(settings, clock);
|
||||
this.tokens = tokens;
|
||||
}
|
||||
|
||||
/** @throws OidcException UNAVAILABLE when the identity provider does not answer */
|
||||
public Start start() throws OidcException {
|
||||
String state = random();
|
||||
String nonce = random();
|
||||
String verifierText = random();
|
||||
Map<String, String> query = new LinkedHashMap<>();
|
||||
query.put("response_type", "code");
|
||||
query.put("client_id", settings.clientId());
|
||||
query.put("redirect_uri", settings.redirectUri().toString());
|
||||
query.put("scope", "openid profile");
|
||||
query.put("state", state);
|
||||
query.put("nonce", nonce);
|
||||
query.put("code_challenge", challenge(verifierText));
|
||||
query.put("code_challenge_method", "S256");
|
||||
URI endpoint = provider.endpoints().authorization();
|
||||
String separator = endpoint.getRawQuery() == null ? "?" : "&";
|
||||
String cookie = Cookies.set(COOKIE, tokens.issue(PURPOSE, LOGIN_TIME,
|
||||
Map.of("state", state, "nonce", nonce, "verifier", verifierText)), LOGIN_TIME, PATH);
|
||||
return new Start(URI.create(endpoint + separator + query.entrySet().stream()
|
||||
.map(e -> e.getKey() + "=" + URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8))
|
||||
.collect(Collectors.joining("&"))), cookie);
|
||||
}
|
||||
|
||||
/** @return the signed-in user's name */
|
||||
public String callback(Map<String, String> query, String cookieHeader) throws OidcException {
|
||||
Map<String, String> login = Cookies.read(cookieHeader, COOKIE).flatMap(t -> tokens.read(PURPOSE, t))
|
||||
.orElseThrow(() -> new OidcException(FAILED, "no login in progress, or it took too long"));
|
||||
boolean sameState = MessageDigest.isEqual(login.get("state").getBytes(StandardCharsets.UTF_8),
|
||||
query.getOrDefault("state", "").getBytes(StandardCharsets.UTF_8));
|
||||
if (!sameState) {
|
||||
throw new OidcException(FAILED, "wrong state");
|
||||
}
|
||||
if ("access_denied".equals(query.get("error"))) {
|
||||
throw new OidcException(DENIED, "the user denied access");
|
||||
}
|
||||
String code = query.get("code");
|
||||
if (code == null || code.isEmpty()) {
|
||||
throw new OidcException(FAILED, "the identity provider answered with " + printable(query.get("error")));
|
||||
}
|
||||
return verifier.verify(provider.exchange(code, login.get("verifier")), login.get("nonce"), provider);
|
||||
}
|
||||
|
||||
public String clearCookie() {
|
||||
return Cookies.clear(COOKIE, PATH);
|
||||
}
|
||||
|
||||
/** The provider's text goes into the log: letters, digits and a few separators only, and short. */
|
||||
static String printable(String text) {
|
||||
String safe = text == null ? "nothing" : text.replaceAll("[^A-Za-z0-9_. -]", "?");
|
||||
return safe.length() > 40 ? safe.substring(0, 40) + "..." : safe;
|
||||
}
|
||||
|
||||
private static String random() {
|
||||
byte[] bytes = new byte[32];
|
||||
RANDOM.nextBytes(bytes);
|
||||
return Base64Url.encode(bytes);
|
||||
}
|
||||
|
||||
private static String challenge(String verifierText) {
|
||||
try {
|
||||
return Base64Url.encode(MessageDigest.getInstance("SHA-256")
|
||||
.digest(verifierText.getBytes(StandardCharsets.US_ASCII)));
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("SHA-256 is part of every JDK", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.PublicKey;
|
||||
import java.security.spec.RSAPublicKeySpec;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static home.backend.adapters.auth.OidcException.Reason.FAILED;
|
||||
import static home.backend.adapters.auth.OidcException.Reason.UNAVAILABLE;
|
||||
|
||||
/**
|
||||
* The identity provider over HTTP: discovery, signing keys and the code exchange. Everything is fetched on first
|
||||
* use and kept, so an identity provider that is down never delays the start and never affects local login.
|
||||
*/
|
||||
final class OidcProvider implements IdTokenVerifier.Keys {
|
||||
|
||||
record Endpoints(URI authorization, URI token, URI jwks) {
|
||||
}
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
private static final Duration CACHE = Duration.ofHours(1);
|
||||
/** An unknown key id may mean a rotation; look again, but not on every forged token. */
|
||||
private static final Duration REFETCH_AFTER = Duration.ofMinutes(1);
|
||||
|
||||
private final OidcSettings settings;
|
||||
private final HttpClient http;
|
||||
private final Duration timeout;
|
||||
private final Clock clock;
|
||||
private Endpoints endpoints;
|
||||
private Instant endpointsAt = Instant.MIN;
|
||||
private Map<String, PublicKey> keys = Map.of();
|
||||
private Instant keysAt = Instant.MIN;
|
||||
|
||||
OidcProvider(OidcSettings settings, Duration timeout, Clock clock) {
|
||||
this.settings = settings;
|
||||
this.timeout = timeout;
|
||||
this.clock = clock;
|
||||
this.http = HttpClient.newBuilder().connectTimeout(timeout).build();
|
||||
}
|
||||
|
||||
/** The network calls run outside the lock, so a hanging identity provider holds up only its own callers. */
|
||||
Endpoints endpoints() throws OidcException {
|
||||
synchronized (this) {
|
||||
if (endpoints != null && !endpointsAt.plus(CACHE).isBefore(clock.instant())) {
|
||||
return endpoints;
|
||||
}
|
||||
}
|
||||
String base = settings.issuerBase();
|
||||
JsonNode doc = getJson(URI.create(base + "/.well-known/openid-configuration"));
|
||||
if (!base.equals(doc.path("issuer").asText("").replaceAll("/+$", ""))) {
|
||||
throw new OidcException(FAILED, "the discovery document names another issuer");
|
||||
}
|
||||
var fresh = new Endpoints(uri(doc, "authorization_endpoint"), uri(doc, "token_endpoint"), uri(doc, "jwks_uri"));
|
||||
synchronized (this) {
|
||||
endpoints = fresh;
|
||||
endpointsAt = clock.instant();
|
||||
}
|
||||
return fresh;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PublicKey key(String kid) throws OidcException {
|
||||
synchronized (this) {
|
||||
boolean stale = keysAt.plus(CACHE).isBefore(clock.instant());
|
||||
boolean unknownAndRefetchable = !keys.containsKey(kid)
|
||||
&& keysAt.plus(REFETCH_AFTER).isBefore(clock.instant());
|
||||
if (!stale && !unknownAndRefetchable) {
|
||||
return known(kid);
|
||||
}
|
||||
}
|
||||
Map<String, PublicKey> fresh = parseKeys(getJson(endpoints().jwks()));
|
||||
synchronized (this) {
|
||||
keys = fresh;
|
||||
keysAt = clock.instant();
|
||||
return known(kid);
|
||||
}
|
||||
}
|
||||
|
||||
private PublicKey known(String kid) throws OidcException {
|
||||
PublicKey key = keys.get(kid);
|
||||
if (key == null) {
|
||||
throw new OidcException(FAILED, "no signing key " + kid);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
/** @return the ID token the identity provider issued for the code */
|
||||
String exchange(String code, String verifier) throws OidcException {
|
||||
var form = Map.of("grant_type", "authorization_code", "code", code,
|
||||
"redirect_uri", settings.redirectUri().toString(), "code_verifier", verifier);
|
||||
String credentials = encode(settings.clientId()) + ":" + encode(settings.clientSecret());
|
||||
var request = HttpRequest.newBuilder(endpoints().token()).timeout(timeout)
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.header("Authorization", "Basic " + Base64.getEncoder()
|
||||
.encodeToString(credentials.getBytes(StandardCharsets.UTF_8)))
|
||||
.POST(HttpRequest.BodyPublishers.ofString(form.entrySet().stream()
|
||||
.map(e -> e.getKey() + "=" + encode(e.getValue())).collect(Collectors.joining("&"))))
|
||||
.build();
|
||||
JsonNode answer = send(request, "the token endpoint");
|
||||
String idToken = answer.path("id_token").asText("");
|
||||
if (idToken.isEmpty()) {
|
||||
throw new OidcException(FAILED, "the token endpoint sent no id_token");
|
||||
}
|
||||
return idToken;
|
||||
}
|
||||
|
||||
private JsonNode getJson(URI uri) throws OidcException {
|
||||
return send(HttpRequest.newBuilder(uri).timeout(timeout).GET().build(), uri.getPath());
|
||||
}
|
||||
|
||||
private JsonNode send(HttpRequest request, String what) throws OidcException {
|
||||
try {
|
||||
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() >= 500) {
|
||||
throw new OidcException(UNAVAILABLE, what + " answered " + response.statusCode());
|
||||
}
|
||||
if (response.statusCode() != 200) {
|
||||
throw new OidcException(FAILED, what + " answered " + response.statusCode());
|
||||
}
|
||||
return JSON.readTree(response.body());
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new OidcException(UNAVAILABLE, what + " was interrupted", e);
|
||||
} catch (IOException e) {
|
||||
throw new OidcException(UNAVAILABLE, what + " is unreachable: " + e, e);
|
||||
}
|
||||
}
|
||||
|
||||
/** A key that cannot be read is skipped, so one odd entry does not lock everyone out. */
|
||||
private static Map<String, PublicKey> parseKeys(JsonNode jwks) {
|
||||
Map<String, PublicKey> keys = new HashMap<>();
|
||||
for (JsonNode jwk : jwks.path("keys")) {
|
||||
boolean signingRsa = "RSA".equals(jwk.path("kty").asText()) && !"enc".equals(jwk.path("use").asText());
|
||||
if (signingRsa && jwk.hasNonNull("kid")) {
|
||||
try {
|
||||
keys.put(jwk.get("kid").asText(), KeyFactory.getInstance("RSA").generatePublic(
|
||||
new RSAPublicKeySpec(integer(jwk, "n"), integer(jwk, "e"))));
|
||||
} catch (GeneralSecurityException | IllegalArgumentException e) {
|
||||
System.err.println("home-pump-backend: skipping unusable signing key " + jwk.get("kid").asText());
|
||||
}
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
private static BigInteger integer(JsonNode jwk, String field) {
|
||||
return new BigInteger(1, Base64Url.decode(jwk.path(field).asText()));
|
||||
}
|
||||
|
||||
private static URI uri(JsonNode doc, String field) throws OidcException {
|
||||
String value = doc.path(field).asText("");
|
||||
if (value.isEmpty()) {
|
||||
throw new OidcException(FAILED, "the discovery document has no " + field);
|
||||
}
|
||||
return URI.create(value);
|
||||
}
|
||||
|
||||
private static String encode(String value) {
|
||||
return URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import java.net.URI;
|
||||
|
||||
/** requiredRole is null when any user of the realm may sign in. */
|
||||
public record OidcSettings(URI issuer, String clientId, String clientSecret, URI redirectUri, String requiredRole) {
|
||||
|
||||
/** The issuer without a trailing slash, the form tokens and discovery are compared in. */
|
||||
String issuerBase() {
|
||||
return issuer.toString().replaceAll("/+$", "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import javax.crypto.SecretKeyFactory;
|
||||
import javax.crypto.spec.PBEKeySpec;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
|
||||
/** Password hashes as {@code pbkdf2-sha256$<iterations>$<salt b64>$<hash b64>}: the cost travels with the hash. */
|
||||
public final class PasswordHash {
|
||||
|
||||
private static final String SCHEME = "pbkdf2-sha256";
|
||||
private static final int ITERATIONS = 600_000;
|
||||
/** A file with a cheaper hash is refused, so a weak one cannot slip in. */
|
||||
private static final int MIN_ITERATIONS = 310_000;
|
||||
private static final int SALT_BYTES = 16;
|
||||
private static final int HASH_BITS = 256;
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
|
||||
private record Parsed(int iterations, byte[] salt, byte[] hash) {
|
||||
}
|
||||
|
||||
private PasswordHash() {
|
||||
}
|
||||
|
||||
public static String create(char[] password) {
|
||||
byte[] salt = new byte[SALT_BYTES];
|
||||
RANDOM.nextBytes(salt);
|
||||
return String.join("$", SCHEME, String.valueOf(ITERATIONS), Base64.getEncoder().encodeToString(salt),
|
||||
Base64.getEncoder().encodeToString(derive(password, salt, ITERATIONS, HASH_BITS)));
|
||||
}
|
||||
|
||||
/** Compares in constant time. */
|
||||
public static boolean matches(String stored, char[] password) {
|
||||
Parsed parsed = parse(stored);
|
||||
byte[] actual = derive(password, parsed.salt(), parsed.iterations(), parsed.hash().length * 8);
|
||||
return MessageDigest.isEqual(parsed.hash(), actual);
|
||||
}
|
||||
|
||||
/** @throws IllegalArgumentException when the text is not a hash this class made or accepts */
|
||||
public static void requireValid(String stored) {
|
||||
parse(stored);
|
||||
}
|
||||
|
||||
private static Parsed parse(String stored) {
|
||||
String[] parts = stored == null ? new String[0] : stored.split("\\$");
|
||||
if (parts.length != 4 || !SCHEME.equals(parts[0])) {
|
||||
throw new IllegalArgumentException("a password hash must look like " + SCHEME
|
||||
+ "$<iterations>$<salt>$<hash>; create one with: hash-password");
|
||||
}
|
||||
try {
|
||||
int iterations = Integer.parseInt(parts[1]);
|
||||
byte[] salt = Base64.getDecoder().decode(parts[2]);
|
||||
byte[] hash = Base64.getDecoder().decode(parts[3]);
|
||||
if (iterations < MIN_ITERATIONS || salt.length < SALT_BYTES || hash.length < HASH_BITS / 8) {
|
||||
throw new IllegalArgumentException("password hash is too weak: at least " + MIN_ITERATIONS
|
||||
+ " iterations, 16 bytes of salt, a 32 byte hash");
|
||||
}
|
||||
return new Parsed(iterations, salt, hash);
|
||||
} catch (IllegalArgumentException e) {
|
||||
// NumberFormatException and a bad base64 are both IllegalArgumentException
|
||||
throw new IllegalArgumentException("password hash is malformed or too weak (at least " + MIN_ITERATIONS
|
||||
+ " iterations, 16 bytes of salt, a 32 byte hash)", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] derive(char[] password, byte[] salt, int iterations, int bits) {
|
||||
var spec = new PBEKeySpec(password, salt, iterations, bits);
|
||||
try {
|
||||
return SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).getEncoded();
|
||||
} catch (GeneralSecurityException e) {
|
||||
throw new IllegalStateException("PBKDF2WithHmacSHA256 is part of every JDK", e);
|
||||
} finally {
|
||||
spec.clearPassword();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.Console;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.PrintStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
|
||||
/** {@code hash-password}: prints the hash for USERS_FILE; on a terminal the password is asked twice, unechoed. */
|
||||
public final class PasswordHashCli {
|
||||
|
||||
private PasswordHashCli() {
|
||||
}
|
||||
|
||||
/** @return the exit code */
|
||||
public static int run(Console console, InputStream stdin, PrintStream out, PrintStream err) throws IOException {
|
||||
char[] password;
|
||||
if (console != null) {
|
||||
password = console.readPassword("Password: ");
|
||||
char[] again = console.readPassword("Again: ");
|
||||
if (password == null || !Arrays.equals(password, again)) {
|
||||
err.println("The passwords differ.");
|
||||
return 1;
|
||||
}
|
||||
} else {
|
||||
String line = new BufferedReader(new InputStreamReader(stdin, StandardCharsets.UTF_8)).readLine();
|
||||
password = line == null ? null : line.toCharArray();
|
||||
}
|
||||
if (password == null || password.length == 0) {
|
||||
err.println("No password given.");
|
||||
return 1;
|
||||
}
|
||||
out.println(PasswordHash.create(password));
|
||||
Arrays.fill(password, '\0');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** Who is signed in and how. {@code method} is {@code local}, {@code oidc} or {@code none} (AUTH_DISABLED). */
|
||||
public record Session(String user, String method, Instant issuedAt) {
|
||||
|
||||
/** The name goes to the gateway's audit log as a header, so only plain characters are allowed. */
|
||||
public static final String USER_PATTERN = "[\\w.@+-]{1,64}";
|
||||
private static final Pattern USER = Pattern.compile(USER_PATTERN);
|
||||
|
||||
/** Every request is this one when authentication is switched off for development. */
|
||||
static final Session OPEN = new Session("phone", "none", Instant.EPOCH);
|
||||
|
||||
public static boolean isValidUser(String name) {
|
||||
return USER.matcher(name).matches();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/** The session cookie: a signed token that holds the user, the method and when it was issued. */
|
||||
final class Sessions {
|
||||
|
||||
static final String COOKIE = "hp_session";
|
||||
private static final String PURPOSE = "session";
|
||||
|
||||
private final SignedTokens tokens;
|
||||
private final Clock clock;
|
||||
private final Duration lifetime;
|
||||
|
||||
Sessions(SignedTokens tokens, Clock clock, Duration lifetime) {
|
||||
this.tokens = tokens;
|
||||
this.clock = clock;
|
||||
this.lifetime = lifetime;
|
||||
}
|
||||
|
||||
String setCookie(String user, String method) {
|
||||
String token = tokens.issue(PURPOSE, lifetime, Map.of("user", user, "method", method,
|
||||
"iat", String.valueOf(clock.instant().getEpochSecond())));
|
||||
return Cookies.set(COOKIE, token, lifetime, "/");
|
||||
}
|
||||
|
||||
String clearCookie() {
|
||||
return Cookies.clear(COOKIE, "/");
|
||||
}
|
||||
|
||||
Optional<Session> read(String cookieHeader) {
|
||||
return Cookies.read(cookieHeader, COOKIE).flatMap(token -> tokens.read(PURPOSE, token)).map(claims ->
|
||||
new Session(claims.get("user"), claims.get("method"),
|
||||
Instant.ofEpochSecond(Long.parseLong(claims.get("iat")))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Tamper-proof, expiring text for cookies: {@code base64url(json).base64url(HMAC-SHA256)}. */
|
||||
final class SignedTokens {
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
private static final TypeReference<Map<String, String>> CLAIMS = new TypeReference<>() {
|
||||
};
|
||||
private static final String HMAC = "HmacSHA256";
|
||||
|
||||
private final SecretKeySpec key;
|
||||
private final Clock clock;
|
||||
|
||||
SignedTokens(byte[] secret, Clock clock) {
|
||||
this.key = new SecretKeySpec(secret, HMAC);
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
/** The purpose keeps a token made for one use (a session) from passing as another (a login in progress). */
|
||||
String issue(String purpose, Duration lifetime, Map<String, String> claims) {
|
||||
var all = new HashMap<>(claims);
|
||||
all.put("purpose", purpose);
|
||||
all.put("exp", String.valueOf(clock.instant().plus(lifetime).getEpochSecond()));
|
||||
try {
|
||||
String payload = Base64Url.encode(JSON.writeValueAsBytes(all));
|
||||
return payload + "." + Base64Url.encode(sign(payload));
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
Optional<Map<String, String>> read(String purpose, String token) {
|
||||
try {
|
||||
String[] parts = token.split("\\.", -1);
|
||||
if (parts.length != 2 || !MessageDigest.isEqual(sign(parts[0]), Base64Url.decode(parts[1]))) {
|
||||
return Optional.empty();
|
||||
}
|
||||
Map<String, String> claims = JSON.readValue(Base64Url.decode(parts[0]), CLAIMS);
|
||||
boolean valid = purpose.equals(claims.get("purpose"))
|
||||
&& Long.parseLong(claims.get("exp")) > clock.instant().getEpochSecond();
|
||||
return valid ? Optional.of(claims) : Optional.empty();
|
||||
} catch (IOException | IllegalArgumentException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] sign(String payload) {
|
||||
try {
|
||||
Mac mac = Mac.getInstance(HMAC);
|
||||
mac.init(key);
|
||||
return mac.doFinal(payload.getBytes(StandardCharsets.US_ASCII));
|
||||
} catch (GeneralSecurityException e) {
|
||||
throw new IllegalStateException(HMAC + " is part of every JDK", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.nio.file.attribute.PosixFilePermissions;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* The local accounts of USERS_FILE: {@code [{"username", "passwordHash"}]}. A missing file is an empty store
|
||||
* (setup mode). Every change is written back atomically before it counts, under one lock.
|
||||
*/
|
||||
public final class UserStore {
|
||||
|
||||
public enum Removal { REMOVED, UNKNOWN, LAST }
|
||||
|
||||
private record Entry(String username, String passwordHash) {
|
||||
}
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
/** What an unknown user is checked against, so that it costs as much as a wrong password. */
|
||||
private static final String DECOY = PasswordHash.create(randomPassword());
|
||||
|
||||
private final Path file;
|
||||
private Map<String, String> hashes;
|
||||
|
||||
UserStore(Path file, Map<String, String> hashes) {
|
||||
this.file = file;
|
||||
this.hashes = new LinkedHashMap<>(hashes);
|
||||
}
|
||||
|
||||
/** @throws IllegalArgumentException when the file exists but is unreadable or holds a bad account */
|
||||
public static UserStore load(Path file) {
|
||||
Map<String, String> hashes = new LinkedHashMap<>();
|
||||
if (Files.exists(file)) {
|
||||
List<Entry> entries;
|
||||
try {
|
||||
entries = JSON.readValue(file.toFile(), new TypeReference<>() {
|
||||
});
|
||||
} catch (IOException e) {
|
||||
throw new IllegalArgumentException("USERS_FILE " + file + " is not usable: " + e.getMessage(), e);
|
||||
}
|
||||
for (Entry entry : entries) {
|
||||
if (entry.username() == null || !Session.isValidUser(entry.username())) {
|
||||
throw new IllegalArgumentException("USERS_FILE: a username must match " + Session.USER_PATTERN);
|
||||
}
|
||||
PasswordHash.requireValid(entry.passwordHash());
|
||||
if (hashes.put(entry.username(), entry.passwordHash()) != null) {
|
||||
throw new IllegalArgumentException("USERS_FILE: duplicate username " + entry.username());
|
||||
}
|
||||
}
|
||||
}
|
||||
return new UserStore(file, hashes);
|
||||
}
|
||||
|
||||
public synchronized boolean isEmpty() {
|
||||
return hashes.isEmpty();
|
||||
}
|
||||
|
||||
public synchronized boolean exists(String username) {
|
||||
return hashes.containsKey(username);
|
||||
}
|
||||
|
||||
public synchronized List<String> usernames() {
|
||||
return List.copyOf(hashes.keySet());
|
||||
}
|
||||
|
||||
public boolean authenticate(String username, char[] password) {
|
||||
String stored;
|
||||
synchronized (this) {
|
||||
stored = hashes.get(username);
|
||||
}
|
||||
boolean matches = PasswordHash.matches(stored == null ? DECOY : stored, password);
|
||||
return stored != null && matches;
|
||||
}
|
||||
|
||||
/** The first account; false when there already is one, so only one setup can win. */
|
||||
public synchronized boolean addFirst(String username, String hash) {
|
||||
return hashes.isEmpty() && put(username, hash);
|
||||
}
|
||||
|
||||
/** @return false when the name is taken */
|
||||
public synchronized boolean add(String username, String hash) {
|
||||
return !hashes.containsKey(username) && put(username, hash);
|
||||
}
|
||||
|
||||
/** @return false when there is no such account */
|
||||
public synchronized boolean changePassword(String username, String hash) {
|
||||
return hashes.containsKey(username) && put(username, hash);
|
||||
}
|
||||
|
||||
public synchronized Removal remove(String username) {
|
||||
if (!hashes.containsKey(username)) {
|
||||
return Removal.UNKNOWN;
|
||||
}
|
||||
if (hashes.size() == 1) {
|
||||
return Removal.LAST;
|
||||
}
|
||||
var next = new LinkedHashMap<>(hashes);
|
||||
next.remove(username);
|
||||
write(next);
|
||||
hashes = next;
|
||||
return Removal.REMOVED;
|
||||
}
|
||||
|
||||
private boolean put(String username, String hash) {
|
||||
var next = new LinkedHashMap<>(hashes);
|
||||
next.put(username, hash);
|
||||
write(next);
|
||||
hashes = next;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Temp file next to the target, synced, then renamed over it; the old file stays if anything fails. */
|
||||
private void write(Map<String, String> accounts) {
|
||||
Path directory = file.toAbsolutePath().getParent();
|
||||
Path temp = null;
|
||||
try {
|
||||
temp = Files.createTempFile(directory, "users", ".tmp",
|
||||
PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")));
|
||||
Files.write(temp, JSON.writerWithDefaultPrettyPrinter().writeValueAsBytes(accounts.entrySet().stream()
|
||||
.map(e -> new Entry(e.getKey(), e.getValue())).toList()));
|
||||
try (FileChannel channel = FileChannel.open(temp, StandardOpenOption.WRITE)) {
|
||||
channel.force(true);
|
||||
}
|
||||
Files.move(temp, file, StandardCopyOption.ATOMIC_MOVE);
|
||||
} catch (IOException e) {
|
||||
if (temp != null) {
|
||||
temp.toFile().delete();
|
||||
}
|
||||
throw new UncheckedIOException("cannot write USERS_FILE " + file, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static char[] randomPassword() {
|
||||
byte[] bytes = new byte[24];
|
||||
new SecureRandom().nextBytes(bytes);
|
||||
return Base64.getEncoder().encodeToString(bytes).toCharArray();
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,13 @@ import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import home.backend.application.GatewayBattery;
|
||||
import home.backend.application.GatewayCommands;
|
||||
import home.backend.application.GatewayRefresh;
|
||||
import home.backend.domain.BatteryChange;
|
||||
import home.backend.domain.BatteryResult;
|
||||
import home.backend.domain.BatteryView.Reserve;
|
||||
import home.backend.domain.Command;
|
||||
import home.backend.domain.CommandResult;
|
||||
import home.backend.domain.RefreshResult;
|
||||
import home.backend.domain.RestoreResult;
|
||||
import home.backend.domain.SetpointState;
|
||||
|
||||
@@ -25,8 +27,8 @@ import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/** Sends commands to the gateway's {@code /commands} and {@code /battery} with the shared token (S6). */
|
||||
public class GatewayHttpCommands implements GatewayCommands, GatewayBattery {
|
||||
/** Calls the gateway's {@code /commands}, {@code /battery} and {@code /refresh} with the shared token (S6). */
|
||||
public class GatewayHttpCommands implements GatewayCommands, GatewayBattery, GatewayRefresh {
|
||||
|
||||
/** The gateway answers a verdict with 200 (applied), 422 (rejected) or 502 (failed). */
|
||||
private static final Set<Integer> VERDICTS = Set.of(200, 422, 502);
|
||||
@@ -66,6 +68,7 @@ public class GatewayHttpCommands implements GatewayCommands, GatewayBattery {
|
||||
private final URI endpoint;
|
||||
private final URI renewEndpoint;
|
||||
private final URI restoreEndpoint;
|
||||
private final URI refreshEndpoint;
|
||||
private final URI reserveEndpoint;
|
||||
private final URI scheduleEndpoint;
|
||||
private final String token;
|
||||
@@ -77,6 +80,7 @@ public class GatewayHttpCommands implements GatewayCommands, GatewayBattery {
|
||||
this.endpoint = baseUri.resolve("/commands");
|
||||
this.renewEndpoint = baseUri.resolve("/commands/renew");
|
||||
this.restoreEndpoint = baseUri.resolve("/commands/restore");
|
||||
this.refreshEndpoint = baseUri.resolve("/refresh");
|
||||
this.reserveEndpoint = baseUri.resolve("/battery/reserve");
|
||||
this.scheduleEndpoint = baseUri.resolve("/battery/schedule");
|
||||
this.token = token;
|
||||
@@ -113,6 +117,12 @@ public class GatewayHttpCommands implements GatewayCommands, GatewayBattery {
|
||||
return JSON.readValue(send(request, Set.of(200)), Renewal.class).renewed();
|
||||
}
|
||||
|
||||
@Override
|
||||
public RefreshResult refresh() throws IOException {
|
||||
var request = request(refreshEndpoint).POST(HttpRequest.BodyPublishers.noBody());
|
||||
return JSON.readValue(send(request, Set.of(200)), RefreshResult.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Reserve reserve() throws IOException {
|
||||
return JSON.readValue(send(request(reserveEndpoint).GET(), Set.of(200)), Reserve.class);
|
||||
|
||||
@@ -9,10 +9,12 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpHandler;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import home.backend.adapters.auth.Authentication;
|
||||
import home.backend.application.AuditService;
|
||||
import home.backend.application.BatteryService;
|
||||
import home.backend.application.EnergyDetailsService;
|
||||
import home.backend.application.EnergyService;
|
||||
import home.backend.application.GatewayRefresh;
|
||||
import home.backend.application.HealthService;
|
||||
import home.backend.application.HeatPumpService;
|
||||
import home.backend.application.HistoryService;
|
||||
@@ -72,11 +74,6 @@ public class ApiServer {
|
||||
private static final String PRESETS_PATH = "/api/presets/";
|
||||
private static final String SETPOINTS_PATH = "/api/setpoints/";
|
||||
private static final Pattern RESTORE_PATH = Pattern.compile("/api/setpoints/([a-z_]{1,32})/restore");
|
||||
/** The user name Traefik's auth forwards; anything odd falls back to the default. */
|
||||
private static final String USER_HEADER = "Remote-User";
|
||||
private static final String DEFAULT_USER = "phone";
|
||||
private static final Pattern USER_NAME = Pattern.compile("[\\w.@+-]{1,64}");
|
||||
|
||||
private static final SecurityHeaders SECURITY_HEADERS = new SecurityHeaders();
|
||||
|
||||
/** A setpoint request is a few dozen bytes; anything near this is not from our UI. */
|
||||
@@ -84,22 +81,40 @@ public class ApiServer {
|
||||
private static final int STOP_GRACE_SECONDS = 5;
|
||||
|
||||
private final HttpServer server;
|
||||
private final SessionFilter sessionFilter;
|
||||
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
||||
|
||||
/** Binds the port now; 0 picks a free one. */
|
||||
public ApiServer(int port, HeatPumpService heatPump, EnergyService energy,
|
||||
EnergyDetailsService energyDetails, HealthService health,
|
||||
HistoryService history, SetpointService setpoints, PiSensorService piSensor,
|
||||
BatteryService battery, AuditService audit, StatsService stats, WeatherService weather)
|
||||
throws IOException {
|
||||
BatteryService battery, AuditService audit, StatsService stats, WeatherService weather,
|
||||
GatewayRefresh refresh, Authentication auth) throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress(port), 0);
|
||||
server.setExecutor(executor);
|
||||
sessionFilter = new SessionFilter(auth);
|
||||
// Everything outside the API, with the security headers too: the web UI is the frontend's (ADR-010).
|
||||
context("/", exchange -> {
|
||||
open("/", exchange -> {
|
||||
try (exchange) {
|
||||
exchange.sendResponseHeaders(404, -1);
|
||||
}
|
||||
});
|
||||
var signIn = new AuthEndpoints(auth);
|
||||
openRoute("/api/auth/config", signIn::config);
|
||||
openRoute("/api/auth/login", signIn::login);
|
||||
openRoute("/api/auth/logout", signIn::logout);
|
||||
openRoute("/api/auth/me", signIn::me);
|
||||
openRoute("/api/auth/setup", signIn::setup);
|
||||
openRoute("/api/auth/users", signIn::users);
|
||||
openRoute("/api/auth/password", signIn::password);
|
||||
open("/api/auth/users/", signIn::deleteUser);
|
||||
openRoute("/api/auth/oidc/start", signIn::oidcStart);
|
||||
openRoute("/api/auth/oidc/callback", signIn::oidcCallback);
|
||||
// The container healthcheck: no session, nothing in the answer.
|
||||
openRoute("/api/live", exchange -> handle(exchange, Map.of("GET", () -> {
|
||||
exchange.sendResponseHeaders(200, 2);
|
||||
exchange.getResponseBody().write("ok".getBytes(StandardCharsets.US_ASCII));
|
||||
})));
|
||||
api("/api/heat-pump", query -> heatPump.current());
|
||||
api("/api/energy", query -> energy.current());
|
||||
api("/api/energy/details", query -> energyDetails.current());
|
||||
@@ -108,6 +123,8 @@ public class ApiServer {
|
||||
"POST", () -> guarded(exchange, body -> postSchedule(exchange, body, battery)))));
|
||||
route("/api/battery/reserve", exchange -> handle(exchange, Map.of(
|
||||
"POST", () -> guarded(exchange, body -> postReserve(exchange, body, battery)))));
|
||||
route("/api/refresh", exchange -> handle(exchange, Map.of(
|
||||
"POST", () -> guarded(exchange, body -> postRefresh(exchange, refresh)))));
|
||||
api("/api/pi-sensor", query -> piSensor.current());
|
||||
api("/api/weather", query -> weather.current());
|
||||
api("/api/health", query -> health.current());
|
||||
@@ -132,9 +149,17 @@ public class ApiServer {
|
||||
"POST", () -> guarded(exchange, body -> postPreset(exchange, body, setpoints)))));
|
||||
}
|
||||
|
||||
/** The JDK matches a context by prefix, so /api/heat-pumpX would reach /api/heat-pump. */
|
||||
private void route(String path, HttpHandler handler) {
|
||||
context(path, exchange -> {
|
||||
context(path, exactly(path, handler));
|
||||
}
|
||||
|
||||
private void openRoute(String path, HttpHandler handler) {
|
||||
open(path, exactly(path, handler));
|
||||
}
|
||||
|
||||
/** The JDK matches a context by prefix, so /api/heat-pumpX would reach /api/heat-pump. */
|
||||
private static HttpHandler exactly(String path, HttpHandler handler) {
|
||||
return exchange -> {
|
||||
if (path.equals(exchange.getRequestURI().getPath())) {
|
||||
handler.handle(exchange);
|
||||
} else {
|
||||
@@ -142,10 +167,18 @@ public class ApiServer {
|
||||
exchange.sendResponseHeaders(404, -1);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/** Needs a session. */
|
||||
private void context(String path, HttpHandler handler) {
|
||||
var context = server.createContext(path, handler);
|
||||
context.getFilters().add(SECURITY_HEADERS);
|
||||
context.getFilters().add(sessionFilter);
|
||||
}
|
||||
|
||||
/** Answers without a session. */
|
||||
private void open(String path, HttpHandler handler) {
|
||||
server.createContext(path, handler).getFilters().add(SECURITY_HEADERS);
|
||||
}
|
||||
|
||||
@@ -181,7 +214,7 @@ public class ApiServer {
|
||||
* A page on another site can make the browser send a simple POST with the user's cookies. It cannot
|
||||
* add a custom header, set a JSON content type or fake the Origin, so a write needs all three.
|
||||
*/
|
||||
private static void guarded(HttpExchange exchange, Write write) throws IOException {
|
||||
static void guarded(HttpExchange exchange, Write write) throws IOException {
|
||||
var headers = exchange.getRequestHeaders();
|
||||
String origin = headers.getFirst("Origin");
|
||||
if (origin != null && !origin.endsWith("//" + headers.getFirst("Host"))) {
|
||||
@@ -259,6 +292,14 @@ public class ApiServer {
|
||||
}
|
||||
}
|
||||
|
||||
private static void postRefresh(HttpExchange exchange, GatewayRefresh refresh) throws IOException {
|
||||
try {
|
||||
send(exchange, 200, refresh.refresh());
|
||||
} catch (IOException e) {
|
||||
send(exchange, 503, error(UNREACHABLE));
|
||||
}
|
||||
}
|
||||
|
||||
private static void postRestore(HttpExchange exchange, String setpoint, SetpointService setpoints)
|
||||
throws IOException {
|
||||
try {
|
||||
@@ -326,9 +367,9 @@ public class ApiServer {
|
||||
};
|
||||
}
|
||||
|
||||
/** The signed-in user, set by the session filter. */
|
||||
private static String user(HttpExchange exchange) {
|
||||
String name = exchange.getRequestHeaders().getFirst(USER_HEADER);
|
||||
return name != null && USER_NAME.matcher(name).matches() ? name : DEFAULT_USER;
|
||||
return (String) exchange.getAttribute(SessionFilter.USER_ATTRIBUTE);
|
||||
}
|
||||
|
||||
private void handleHistory(HttpExchange exchange, HistoryService history) throws IOException {
|
||||
@@ -369,15 +410,15 @@ public class ApiServer {
|
||||
}));
|
||||
}
|
||||
|
||||
private interface Action {
|
||||
interface Action {
|
||||
void run() throws IOException;
|
||||
}
|
||||
|
||||
private interface Write {
|
||||
interface Write {
|
||||
void run(byte[] body) throws IOException;
|
||||
}
|
||||
|
||||
private static void handle(HttpExchange exchange, Map<String, Action> byMethod) throws IOException {
|
||||
static void handle(HttpExchange exchange, Map<String, Action> byMethod) throws IOException {
|
||||
try (exchange) {
|
||||
Action action = byMethod.get(exchange.getRequestMethod());
|
||||
if (action == null) {
|
||||
@@ -396,7 +437,7 @@ public class ApiServer {
|
||||
}
|
||||
}
|
||||
|
||||
private static void send(HttpExchange exchange, int status, Object body) throws IOException {
|
||||
static void send(HttpExchange exchange, int status, Object body) throws IOException {
|
||||
byte[] bytes = JSON.writeValueAsBytes(body);
|
||||
exchange.getResponseHeaders().set("Content-Type", "application/json");
|
||||
exchange.getResponseHeaders().set("Cache-Control", "no-store");
|
||||
@@ -408,7 +449,7 @@ public class ApiServer {
|
||||
return Map.of("error", message);
|
||||
}
|
||||
|
||||
private static Map<String, String> parseQuery(String raw) {
|
||||
static Map<String, String> parseQuery(String raw) {
|
||||
Map<String, String> query = new HashMap<>();
|
||||
if (raw == null) {
|
||||
return query;
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
package home.backend.adapters.http;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import home.backend.adapters.auth.Authentication;
|
||||
import home.backend.adapters.auth.OidcException;
|
||||
import home.backend.adapters.auth.Session;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** The endpoints under {@code /api/auth/}: sign-in needs no session, managing accounts does. */
|
||||
final class AuthEndpoints {
|
||||
|
||||
private record LoginRequest(String username, String password) {
|
||||
}
|
||||
|
||||
private record PasswordRequest(String current, @JsonProperty("new") String next) {
|
||||
}
|
||||
|
||||
private static final Pattern DELETE_PATH = Pattern.compile("/api/auth/users/([^/]{1,64})/delete");
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
private static final int MAX_CREDENTIAL_LENGTH = 256;
|
||||
|
||||
private final Authentication auth;
|
||||
|
||||
AuthEndpoints(Authentication auth) {
|
||||
this.auth = auth;
|
||||
}
|
||||
|
||||
void config(HttpExchange exchange) throws IOException {
|
||||
ApiServer.handle(exchange, Map.of("GET", () -> ApiServer.send(exchange, 200,
|
||||
Map.of("local", auth.localEnabled(), "oidc", auth.oidcEnabled(),
|
||||
"setup", auth.setupNeeded()))));
|
||||
}
|
||||
|
||||
void login(HttpExchange exchange) throws IOException {
|
||||
ApiServer.handle(exchange, Map.of("POST", () -> ApiServer.guarded(exchange, body -> postLogin(exchange, body))));
|
||||
}
|
||||
|
||||
void logout(HttpExchange exchange) throws IOException {
|
||||
ApiServer.handle(exchange, Map.of("POST", () -> ApiServer.guarded(exchange, body -> {
|
||||
String cookie = auth.clearedSessionCookie();
|
||||
if (!cookie.isEmpty()) {
|
||||
exchange.getResponseHeaders().add("Set-Cookie", cookie);
|
||||
}
|
||||
exchange.sendResponseHeaders(204, -1);
|
||||
})));
|
||||
}
|
||||
|
||||
void me(HttpExchange exchange) throws IOException {
|
||||
ApiServer.handle(exchange, Map.of("GET", () -> {
|
||||
var session = auth.session(SessionFilter.cookies(exchange));
|
||||
if (session.isPresent()) {
|
||||
ApiServer.send(exchange, 200, user(session.get()));
|
||||
} else {
|
||||
ApiServer.send(exchange, 401, Map.of("error", "unauthenticated"));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
void oidcStart(HttpExchange exchange) throws IOException {
|
||||
ApiServer.handle(exchange, Map.of("GET", () -> {
|
||||
if (!auth.oidcEnabled()) {
|
||||
exchange.sendResponseHeaders(404, -1);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var start = auth.oidc().start();
|
||||
exchange.getResponseHeaders().add("Set-Cookie", start.setCookie());
|
||||
redirect(exchange, start.location().toString());
|
||||
} catch (OidcException e) {
|
||||
redirectWithError(exchange, e);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
void oidcCallback(HttpExchange exchange) throws IOException {
|
||||
ApiServer.handle(exchange, Map.of("GET", () -> {
|
||||
if (!auth.oidcEnabled()) {
|
||||
exchange.sendResponseHeaders(404, -1);
|
||||
return;
|
||||
}
|
||||
exchange.getResponseHeaders().add("Set-Cookie", auth.oidc().clearCookie());
|
||||
try {
|
||||
String name = auth.oidc().callback(ApiServer.parseQuery(exchange.getRequestURI().getRawQuery()),
|
||||
SessionFilter.cookies(exchange));
|
||||
exchange.getResponseHeaders().add("Set-Cookie", auth.sessionCookie(name, "oidc"));
|
||||
redirect(exchange, "/");
|
||||
} catch (OidcException e) {
|
||||
redirectWithError(exchange, e);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private void postLogin(HttpExchange exchange, byte[] body) throws IOException {
|
||||
if (!auth.localEnabled()) {
|
||||
ApiServer.send(exchange, 404, Map.of("error", "local login is not enabled"));
|
||||
return;
|
||||
}
|
||||
LoginRequest request = credentials(exchange, body);
|
||||
if (request == null) {
|
||||
return;
|
||||
}
|
||||
String ip = ClientIp.of(exchange, auth.proxyHops());
|
||||
var login = auth.login(request.username(), request.password().toCharArray(), ip);
|
||||
switch (login.status()) {
|
||||
case OK -> {
|
||||
exchange.getResponseHeaders().add("Set-Cookie", auth.sessionCookie(request.username(), "local"));
|
||||
ApiServer.send(exchange, 200, Map.of("user", request.username(), "method", "local"));
|
||||
}
|
||||
case INVALID -> {
|
||||
System.err.println("home-pump-backend: failed login from " + ip);
|
||||
ApiServer.send(exchange, 401, Map.of("error", "invalid"));
|
||||
}
|
||||
case THROTTLED -> throttled(exchange, login);
|
||||
}
|
||||
}
|
||||
|
||||
/** The username and password of the body, or null after answering 400. */
|
||||
private static LoginRequest credentials(HttpExchange exchange, byte[] body) throws IOException {
|
||||
LoginRequest request;
|
||||
try {
|
||||
request = JSON.readValue(body, LoginRequest.class);
|
||||
} catch (JsonProcessingException e) {
|
||||
request = null;
|
||||
}
|
||||
if (request == null || request.username() == null || request.password() == null
|
||||
|| request.username().length() > MAX_CREDENTIAL_LENGTH
|
||||
|| request.password().length() > MAX_CREDENTIAL_LENGTH) {
|
||||
ApiServer.send(exchange, 400, Map.of("error", "expected {\"username\", \"password\"}"));
|
||||
return null;
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
private static void throttled(HttpExchange exchange, Authentication.Login login) throws IOException {
|
||||
exchange.getResponseHeaders().set("Retry-After", String.valueOf(login.retryAfterSeconds()));
|
||||
ApiServer.send(exchange, 429, Map.of("error", "throttled", "retryAfterSeconds", login.retryAfterSeconds()));
|
||||
}
|
||||
|
||||
/** True when the name and password may become an account; else answers 400. */
|
||||
private static boolean acceptable(HttpExchange exchange, String username, String password) throws IOException {
|
||||
if (!Session.isValidUser(username)) {
|
||||
ApiServer.send(exchange, 400, Map.of("error", "invalid_username"));
|
||||
return false;
|
||||
}
|
||||
if (!Authentication.strongEnough(password)) {
|
||||
ApiServer.send(exchange, 400, Map.of("error", "weak_password"));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void setup(HttpExchange exchange) throws IOException {
|
||||
ApiServer.handle(exchange, Map.of("POST", () -> ApiServer.guarded(exchange, body -> {
|
||||
if (!auth.localEnabled()) {
|
||||
ApiServer.send(exchange, 404, Map.of("error", "local login is not enabled"));
|
||||
return;
|
||||
}
|
||||
LoginRequest request = credentials(exchange, body);
|
||||
if (request == null) {
|
||||
return;
|
||||
}
|
||||
if (!auth.setupNeeded()) {
|
||||
ApiServer.send(exchange, 409, Map.of("error", "setup_done"));
|
||||
return;
|
||||
}
|
||||
if (!acceptable(exchange, request.username(), request.password())) {
|
||||
return;
|
||||
}
|
||||
var result = auth.setup(request.username(), request.password().toCharArray(),
|
||||
ClientIp.of(exchange, auth.proxyHops()));
|
||||
switch (result.status()) {
|
||||
case OK -> {
|
||||
exchange.getResponseHeaders().add("Set-Cookie", auth.sessionCookie(request.username(), "local"));
|
||||
ApiServer.send(exchange, 200, Map.of("user", request.username(), "method", "local"));
|
||||
}
|
||||
case INVALID -> ApiServer.send(exchange, 409, Map.of("error", "setup_done"));
|
||||
case THROTTLED -> throttled(exchange, result);
|
||||
}
|
||||
})));
|
||||
}
|
||||
|
||||
/** Runs the action for a signed-in user of a configured local store; else answers 401 or 404. */
|
||||
private void asUser(HttpExchange exchange, SignedIn action) throws IOException {
|
||||
var session = auth.session(SessionFilter.cookies(exchange));
|
||||
if (session.isEmpty()) {
|
||||
ApiServer.send(exchange, 401, Map.of("error", "unauthenticated"));
|
||||
} else if (!auth.localEnabled()) {
|
||||
ApiServer.send(exchange, 404, Map.of("error", "local login is not enabled"));
|
||||
} else {
|
||||
action.run(session.get());
|
||||
}
|
||||
}
|
||||
|
||||
private interface SignedIn {
|
||||
void run(Session session) throws IOException;
|
||||
}
|
||||
|
||||
void users(HttpExchange exchange) throws IOException {
|
||||
ApiServer.handle(exchange, Map.of(
|
||||
"GET", () -> asUser(exchange, session -> ApiServer.send(exchange, 200,
|
||||
auth.usernames().stream().map(name -> Map.of("username", name)).toList())),
|
||||
"POST", () -> ApiServer.guarded(exchange, body -> asUser(exchange, session -> {
|
||||
LoginRequest request = credentials(exchange, body);
|
||||
if (request == null || !acceptable(exchange, request.username(), request.password())) {
|
||||
return;
|
||||
}
|
||||
if (auth.addUser(session.user(), request.username(), request.password().toCharArray())) {
|
||||
ApiServer.send(exchange, 200, Map.of("username", request.username()));
|
||||
} else {
|
||||
ApiServer.send(exchange, 409, Map.of("error", "exists"));
|
||||
}
|
||||
}))));
|
||||
}
|
||||
|
||||
void password(HttpExchange exchange) throws IOException {
|
||||
ApiServer.handle(exchange, Map.of("POST", () -> ApiServer.guarded(exchange, body -> asUser(exchange, session -> {
|
||||
PasswordRequest request;
|
||||
try {
|
||||
request = JSON.readValue(body, PasswordRequest.class);
|
||||
} catch (JsonProcessingException e) {
|
||||
request = null;
|
||||
}
|
||||
if (request == null || request.current() == null || request.next() == null
|
||||
|| request.current().length() > MAX_CREDENTIAL_LENGTH
|
||||
|| request.next().length() > MAX_CREDENTIAL_LENGTH) {
|
||||
ApiServer.send(exchange, 400, Map.of("error", "expected {\"current\", \"new\"}"));
|
||||
} else if (!Authentication.LOCAL.equals(session.method())) {
|
||||
ApiServer.send(exchange, 403, Map.of("error", "local_only"));
|
||||
} else if (!Authentication.strongEnough(request.next())) {
|
||||
ApiServer.send(exchange, 400, Map.of("error", "weak_password"));
|
||||
} else {
|
||||
var result = auth.changePassword(session.user(), request.current().toCharArray(),
|
||||
request.next().toCharArray(), ClientIp.of(exchange, auth.proxyHops()));
|
||||
switch (result.status()) {
|
||||
case OK -> exchange.sendResponseHeaders(204, -1);
|
||||
case INVALID -> ApiServer.send(exchange, 401, Map.of("error", "invalid"));
|
||||
case THROTTLED -> throttled(exchange, result);
|
||||
}
|
||||
}
|
||||
}))));
|
||||
}
|
||||
|
||||
void deleteUser(HttpExchange exchange) throws IOException {
|
||||
ApiServer.handle(exchange, Map.of("POST", () -> ApiServer.guarded(exchange, body -> asUser(exchange, session -> {
|
||||
var path = DELETE_PATH.matcher(exchange.getRequestURI().getRawPath());
|
||||
if (!path.matches()) {
|
||||
exchange.sendResponseHeaders(404, -1);
|
||||
return;
|
||||
}
|
||||
String name = path.group(1);
|
||||
switch (auth.removeUser(session.user(), name)) {
|
||||
case UNKNOWN -> ApiServer.send(exchange, 404, Map.of("error", "unknown_user"));
|
||||
case LAST -> ApiServer.send(exchange, 409, Map.of("error", "last_user"));
|
||||
case REMOVED -> {
|
||||
if (name.equals(session.user())) {
|
||||
exchange.getResponseHeaders().add("Set-Cookie", auth.clearedSessionCookie());
|
||||
}
|
||||
exchange.sendResponseHeaders(204, -1);
|
||||
}
|
||||
}
|
||||
}))));
|
||||
}
|
||||
|
||||
private static Map<String, String> user(Session session) {
|
||||
return Map.of("user", session.user(), "method", session.method());
|
||||
}
|
||||
|
||||
private static void redirectWithError(HttpExchange exchange, OidcException e) throws IOException {
|
||||
System.err.println("home-pump-backend: OIDC sign-in failed: " + e.getMessage());
|
||||
redirect(exchange, "/#login?error=" + e.reason().code());
|
||||
}
|
||||
|
||||
private static void redirect(HttpExchange exchange, String location) throws IOException {
|
||||
exchange.getResponseHeaders().set("Location", location);
|
||||
exchange.getResponseHeaders().set("Cache-Control", "no-store");
|
||||
exchange.sendResponseHeaders(302, -1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package home.backend.adapters.http;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The client's address for the login throttle. Each trusted proxy appends the address it saw to
|
||||
* X-Forwarded-For, so the entry {@code hops} from the right is the first one no trusted proxy vouches for
|
||||
* beyond that; anything the client put in front of it is ignored. Without enough entries the request did not
|
||||
* come through the proxies, and the socket's address is used.
|
||||
*/
|
||||
final class ClientIp {
|
||||
|
||||
private ClientIp() {
|
||||
}
|
||||
|
||||
static String of(HttpExchange exchange, int hops) {
|
||||
List<String> values = exchange.getRequestHeaders().get("X-Forwarded-For");
|
||||
if (values != null) {
|
||||
List<String> chain = Arrays.stream(String.join(",", values).split(",")).map(String::trim).toList();
|
||||
if (chain.size() >= hops && !chain.get(chain.size() - hops).isEmpty()) {
|
||||
return chain.get(chain.size() - hops);
|
||||
}
|
||||
}
|
||||
return exchange.getRemoteAddress().getAddress().getHostAddress();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package home.backend.adapters.http;
|
||||
|
||||
import com.sun.net.httpserver.Filter;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import home.backend.adapters.auth.Authentication;
|
||||
import home.backend.adapters.auth.Session;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Lets a request through only with a valid session, and hands the user's name on to the handlers. */
|
||||
final class SessionFilter extends Filter {
|
||||
|
||||
static final String USER_ATTRIBUTE = "user";
|
||||
|
||||
private final Authentication auth;
|
||||
|
||||
SessionFilter(Authentication auth) {
|
||||
this.auth = auth;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void doFilter(HttpExchange exchange, Chain chain) throws IOException {
|
||||
Optional<Session> session = auth.session(cookies(exchange));
|
||||
if (session.isPresent()) {
|
||||
exchange.setAttribute(USER_ATTRIBUTE, session.get().user());
|
||||
chain.doFilter(exchange);
|
||||
} else {
|
||||
try (exchange) {
|
||||
ApiServer.send(exchange, 401, Map.of("error", "unauthenticated"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static String cookies(HttpExchange exchange) {
|
||||
var values = exchange.getRequestHeaders().get("Cookie");
|
||||
return values == null ? null : String.join("; ", values);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return "session check";
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import java.time.LocalDate;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
|
||||
/** Open-Meteo (free, no key): daily values for the past 14 days and the next 3, and the temperature now. */
|
||||
/** Open-Meteo (free, no key): daily values for the past 14 days and the next 4, and the temperature now. */
|
||||
public final class OpenMeteoClient implements ForecastSource {
|
||||
|
||||
private static final String DAILY = "weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,"
|
||||
@@ -26,7 +26,7 @@ public final class OpenMeteoClient implements ForecastSource {
|
||||
this.http = new HttpJson(timeout);
|
||||
this.uri = URI.create(baseUri.toString().replaceAll("/+$", "") + "/v1/forecast?latitude="
|
||||
+ place.latitude() + "&longitude=" + place.longitude() + "&daily=" + DAILY
|
||||
+ "¤t=temperature_2m&timezone=Europe%2FWarsaw&past_days=14&forecast_days=3");
|
||||
+ "¤t=temperature_2m&timezone=Europe%2FWarsaw&past_days=14&forecast_days=4");
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -19,8 +19,8 @@ public class AuditService {
|
||||
private static final Map<String, Duration> RANGES =
|
||||
Map.of("24h", Duration.ofHours(24), "7d", Duration.ofDays(7), "30d", Duration.ofDays(30));
|
||||
private static final Map<String, String> SETPOINTS = Map.of(
|
||||
"cwu_economy", "CWU eko", "cwu_comfort", "CWU komfort",
|
||||
"co_economy", "CO eko", "co_comfort", "CO komfort");
|
||||
"cwu_economy", "Ciepła woda – oszczędnie", "cwu_comfort", "Ciepła woda – komfort",
|
||||
"co_economy", "Ogrzewanie – oszczędnie", "co_comfort", "Ogrzewanie – komfort");
|
||||
private static final String RESTORE_USER = "restore";
|
||||
private static final String RESTORE_LABEL = "automatycznie (przywrócenie)";
|
||||
|
||||
|
||||
@@ -86,8 +86,13 @@ public class BatteryService {
|
||||
: "Rezerwa na awarię: bateria nie rozładowuje się na co dzień");
|
||||
}
|
||||
|
||||
/** Slot n runs from its start to the start of slot n + 1; the last one wraps to the first. */
|
||||
/** Slot n runs from its start to the start of slot n + 1; the last one wraps to the first.
|
||||
* No schedule at all when a start is missing. */
|
||||
private static List<Slot> schedule(Map<String, Double> s) {
|
||||
boolean complete = IntStream.rangeClosed(1, SLOTS).allMatch(n -> whole(s, "prog" + n + "_start_hhmm") != null);
|
||||
if (!complete) {
|
||||
return List.of();
|
||||
}
|
||||
return IntStream.rangeClosed(1, SLOTS).mapToObj(n -> {
|
||||
String prefix = "prog" + n + "_";
|
||||
String next = "prog" + (n % SLOTS + 1) + "_start_hhmm";
|
||||
|
||||
@@ -4,8 +4,13 @@ import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
|
||||
/** One remote answer kept for a while; when a refresh fails the older answer is served and marked stale. */
|
||||
/**
|
||||
* One remote answer kept for a while, and no longer than its local day when a zone is given. When a refresh
|
||||
* fails the older answer is served marked stale, and the source is left alone for a short back-off so a
|
||||
* source that is down does not make every request wait for its timeout.
|
||||
*/
|
||||
final class Cached<T> {
|
||||
|
||||
interface Fetch<T> {
|
||||
@@ -15,33 +20,61 @@ final class Cached<T> {
|
||||
record Entry<T>(T value, boolean stale) {
|
||||
}
|
||||
|
||||
private static final Duration BACK_OFF = Duration.ofSeconds(60);
|
||||
|
||||
private final Duration ttl;
|
||||
private final ZoneId dayZone;
|
||||
private final Clock clock;
|
||||
private final Fetch<T> source;
|
||||
private T value;
|
||||
private Instant fetchedAt;
|
||||
private Instant failedAt;
|
||||
|
||||
Cached(Duration ttl, Clock clock, Fetch<T> source) {
|
||||
this(ttl, null, clock, source);
|
||||
}
|
||||
|
||||
/** @param dayZone the answer expires at midnight of this zone even before the ttl, or null */
|
||||
Cached(Duration ttl, ZoneId dayZone, Clock clock, Fetch<T> source) {
|
||||
this.ttl = ttl;
|
||||
this.dayZone = dayZone;
|
||||
this.clock = clock;
|
||||
this.source = source;
|
||||
}
|
||||
|
||||
/** @throws IOException when the refresh fails and there is nothing older to serve */
|
||||
/** @throws IOException when the refresh fails or is backing off, and there is nothing older to serve */
|
||||
synchronized Entry<T> get() throws IOException {
|
||||
Instant now = clock.instant();
|
||||
if (value != null && now.isBefore(fetchedAt.plus(ttl))) {
|
||||
if (value != null && now.isBefore(expiry())) {
|
||||
return new Entry<>(value, false);
|
||||
}
|
||||
if (failedAt != null && now.isBefore(failedAt.plus(BACK_OFF))) {
|
||||
return older(new IOException("source failed recently, not retrying yet"));
|
||||
}
|
||||
try {
|
||||
value = source.fetch();
|
||||
fetchedAt = now;
|
||||
failedAt = null;
|
||||
return new Entry<>(value, false);
|
||||
} catch (IOException e) {
|
||||
if (value == null) {
|
||||
throw e;
|
||||
}
|
||||
return new Entry<>(value, true);
|
||||
failedAt = now;
|
||||
return older(e);
|
||||
}
|
||||
}
|
||||
|
||||
private Entry<T> older(IOException failure) throws IOException {
|
||||
if (value == null) {
|
||||
throw failure;
|
||||
}
|
||||
return new Entry<>(value, true);
|
||||
}
|
||||
|
||||
private Instant expiry() {
|
||||
Instant byTtl = fetchedAt.plus(ttl);
|
||||
if (dayZone == null) {
|
||||
return byTtl;
|
||||
}
|
||||
Instant midnight = fetchedAt.atZone(dayZone).toLocalDate().plusDays(1).atStartOfDay(dayZone).toInstant();
|
||||
return midnight.isBefore(byTtl) ? midnight : byTtl;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.RefreshResult;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/** Asks the gateway for fresh readings; it decides which devices may be polled again yet. */
|
||||
public interface GatewayRefresh {
|
||||
|
||||
RefreshResult refresh() throws IOException;
|
||||
}
|
||||
@@ -37,7 +37,7 @@ public class WeatherService {
|
||||
Clock clock) {
|
||||
this.clock = clock;
|
||||
this.pv = pv;
|
||||
this.forecast = new Cached<>(Duration.ofMinutes(30), clock, () -> calibrated(forecastSource.forecast()));
|
||||
this.forecast = new Cached<>(Duration.ofMinutes(30), ZONE, clock, () -> calibrated(forecastSource.forecast()));
|
||||
this.warnings = new Cached<>(Duration.ofMinutes(10), clock, warningSource::warnings);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** The gateway's answer to a refresh: the devices it will poll again, and those polled too recently. */
|
||||
public record RefreshResult(List<String> requested, List<Skipped> skipped) {
|
||||
|
||||
public record Skipped(String device, long retryInSeconds) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,672 @@
|
||||
package home.backend;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import home.backend.adapters.auth.AuthSettings;
|
||||
import home.backend.adapters.auth.OidcSettings;
|
||||
import home.backend.adapters.auth.PasswordHash;
|
||||
import home.backend.adapters.auth.StubIssuer;
|
||||
import home.backend.adapters.http.ApiServer;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.net.URLDecoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** Sign-in end to end: local accounts and OIDC against a stub issuer, behind the real HTTP server. */
|
||||
class AuthApiTest {
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
private static final String SIGNING_KEY = "0123456789abcdef0123456789abcdef-test";
|
||||
private static final String PASSWORD = "correct horse";
|
||||
private static final String LOGIN = "{\"username\":\"tata\",\"password\":\"" + PASSWORD + "\"}";
|
||||
private static final String CALLBACK = "https://pompa.example/api/auth/oidc/callback";
|
||||
private static String hash;
|
||||
|
||||
private final HttpClient client = HttpClient.newHttpClient();
|
||||
private final List<ApiServer> servers = new ArrayList<>();
|
||||
private final List<HttpServer> stubs = new ArrayList<>();
|
||||
private final List<String> requestedBy = new ArrayList<>();
|
||||
private StubIssuer issuer;
|
||||
private ApiServer backend;
|
||||
private HttpServer gateway;
|
||||
@TempDir
|
||||
Path dir;
|
||||
|
||||
@BeforeAll
|
||||
static void hashOnce() {
|
||||
hash = PasswordHash.create(PASSWORD.toCharArray());
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void stop() {
|
||||
servers.forEach(ApiServer::stop);
|
||||
stubs.forEach(s -> s.stop(0));
|
||||
if (issuer != null) {
|
||||
issuer.stop();
|
||||
}
|
||||
}
|
||||
|
||||
// --- set-up ---
|
||||
|
||||
private void launch(boolean local, boolean oidc, String role) throws IOException {
|
||||
issuer = new StubIssuer();
|
||||
launch(local ? users() : null, oidc ? oidcSettings(issuer.issuer(), role) : null, false);
|
||||
}
|
||||
|
||||
private Path users() throws IOException {
|
||||
return Files.writeString(dir.resolve("users.json"),
|
||||
"[{\"username\":\"tata\",\"passwordHash\":\"" + hash + "\"}]");
|
||||
}
|
||||
|
||||
private static OidcSettings oidcSettings(URI issuer, String role) {
|
||||
return new OidcSettings(issuer, StubIssuer.CLIENT_ID, StubIssuer.CLIENT_KEY, URI.create(CALLBACK), role);
|
||||
}
|
||||
|
||||
private void launch(Path usersFile, OidcSettings oidc, boolean disabled) throws IOException {
|
||||
gateway = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
|
||||
gateway.createContext("/commands", this::commands);
|
||||
gateway.start();
|
||||
stubs.add(gateway);
|
||||
URI dead = URI.create("http://127.0.0.1:1");
|
||||
var settings = new Settings(URI.create("http://127.0.0.1:" + gateway.getAddress().getPort()), dead, 0,
|
||||
Duration.ofSeconds(2), Duration.ofSeconds(2), "s3cret-token", null,
|
||||
new AuthSettings(usersFile, oidc, SIGNING_KEY, 30, disabled, 1));
|
||||
backend = Backend.assemble(settings, Clock.systemUTC());
|
||||
servers.add(backend);
|
||||
backend.start();
|
||||
}
|
||||
|
||||
private void commands(HttpExchange exchange) throws IOException {
|
||||
boolean post = exchange.getRequestMethod().equals("POST");
|
||||
if (post) {
|
||||
requestedBy.add(exchange.getRequestHeaders().getFirst("X-Requested-By"));
|
||||
}
|
||||
byte[] body = Files.readAllBytes(Path.of("../gateway/src/test/resources/commands/"
|
||||
+ (post ? "response-applied.json" : "setpoints.json")));
|
||||
exchange.sendResponseHeaders(200, body.length);
|
||||
exchange.getResponseBody().write(body);
|
||||
exchange.close();
|
||||
}
|
||||
|
||||
// --- requests ---
|
||||
|
||||
private HttpResponse<String> send(String method, String path, String body, String... headers) throws Exception {
|
||||
var request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + backend.port() + path))
|
||||
.method(method, body == null ? HttpRequest.BodyPublishers.noBody()
|
||||
: HttpRequest.BodyPublishers.ofString(body));
|
||||
if (headers.length > 0) {
|
||||
request.headers(headers);
|
||||
}
|
||||
return client.send(request.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private HttpResponse<String> get(String path, String... headers) throws Exception {
|
||||
return send("GET", path, null, headers);
|
||||
}
|
||||
|
||||
private HttpResponse<String> post(String path, String body, String... headers) throws Exception {
|
||||
var all = new ArrayList<>(List.of("Content-Type", "application/json", "X-Requested-With", "home-pump"));
|
||||
all.addAll(List.of(headers));
|
||||
return send("POST", path, body, all.toArray(String[]::new));
|
||||
}
|
||||
|
||||
private static String cookie(HttpResponse<?> response, String name) {
|
||||
return response.headers().allValues("Set-Cookie").stream().filter(c -> c.startsWith(name + "="))
|
||||
.findFirst().map(c -> c.split(";")[0]).orElse(null);
|
||||
}
|
||||
|
||||
private static String setCookie(HttpResponse<?> response, String name) {
|
||||
return response.headers().allValues("Set-Cookie").stream().filter(c -> c.startsWith(name + "="))
|
||||
.findFirst().orElse(null);
|
||||
}
|
||||
|
||||
private String signIn() throws Exception {
|
||||
return cookie(post("/api/auth/login", LOGIN), "hp_session");
|
||||
}
|
||||
|
||||
private static JsonNode json(HttpResponse<String> response) throws IOException {
|
||||
return JSON.readTree(response.body());
|
||||
}
|
||||
|
||||
private static JsonNode example(String name) throws IOException {
|
||||
return JSON.readTree(Files.readString(Path.of("src/test/resources/api/" + name + ".json")));
|
||||
}
|
||||
|
||||
// --- what is open and what is not ---
|
||||
|
||||
@Test
|
||||
void configTellsWhichSignInsExist() throws Exception {
|
||||
launch(true, true, null);
|
||||
|
||||
assertEquals(example("auth-config"), json(get("/api/auth/config")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void everythingElseNeedsASession() throws Exception {
|
||||
launch(true, true, null);
|
||||
|
||||
for (String path : List.of("/api/health", "/api/heat-pump", "/api/setpoints", "/api/audit", "/api/weather")) {
|
||||
var response = get(path);
|
||||
assertEquals(401, response.statusCode(), path);
|
||||
assertEquals("{\"error\":\"unauthenticated\"}", response.body(), path);
|
||||
}
|
||||
assertEquals(401, post("/api/setpoints", "{}").statusCode());
|
||||
assertEquals(401, get("/api/auth/me").statusCode());
|
||||
assertEquals(200, get("/api/live").statusCode());
|
||||
assertEquals("ok", get("/api/live").body());
|
||||
assertEquals(404, get("/api/unknown").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sessionCookieOpensTheApiAndNamesTheRequester() throws Exception {
|
||||
launch(true, false, null);
|
||||
String session = signIn();
|
||||
|
||||
assertEquals(200, get("/api/health", "Cookie", session).statusCode());
|
||||
assertEquals(200, post("/api/setpoints", "{\"setpoint\":\"cwu_economy\",\"value\":45,\"hours\":2}",
|
||||
"Cookie", session, "Remote-User", "intruder").statusCode());
|
||||
assertEquals(List.of("tata"), requestedBy);
|
||||
}
|
||||
|
||||
@Test
|
||||
void forgedSessionCookiesAreRefused() throws Exception {
|
||||
launch(true, false, null);
|
||||
String session = signIn();
|
||||
|
||||
assertEquals(401, get("/api/health", "Cookie", session + "x").statusCode());
|
||||
assertEquals(401, get("/api/health", "Cookie", "hp_session=" + session.split("\\.")[0].split("=")[1]).statusCode());
|
||||
assertEquals(401, get("/api/auth/me", "Cookie", "hp_session=eyJ1c2VyIjoiYWRtaW4ifQ.AAAA").statusCode());
|
||||
}
|
||||
|
||||
// --- local login ---
|
||||
|
||||
@Test
|
||||
void localLoginSetsAHardenedCookieAndMeAnswers() throws Exception {
|
||||
launch(true, false, null);
|
||||
|
||||
var login = post("/api/auth/login", LOGIN);
|
||||
|
||||
assertEquals(200, login.statusCode());
|
||||
assertEquals(JSON.readTree("{\"user\":\"tata\",\"method\":\"local\"}"), json(login));
|
||||
String set = setCookie(login, "hp_session");
|
||||
for (String flag : new String[] {"HttpOnly", "Secure", "SameSite=Lax", "Path=/", "Max-Age=2592000"}) {
|
||||
assertTrue(set.contains(flag), flag);
|
||||
}
|
||||
var me = get("/api/auth/me", "Cookie", cookie(login, "hp_session"));
|
||||
assertEquals(200, me.statusCode());
|
||||
assertEquals(example("auth-me"), json(me));
|
||||
}
|
||||
|
||||
@Test
|
||||
void wrongPasswordAndUnknownUserLookAlike() throws Exception {
|
||||
launch(true, false, null);
|
||||
|
||||
for (String body : List.of("{\"username\":\"tata\",\"password\":\"nope\"}",
|
||||
"{\"username\":\"ghost\",\"password\":\"" + PASSWORD + "\"}")) {
|
||||
var response = post("/api/auth/login", body);
|
||||
assertEquals(401, response.statusCode());
|
||||
assertEquals("{\"error\":\"invalid\"}", response.body());
|
||||
assertEquals(null, setCookie(response, "hp_session"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void fiveFailuresThrottleTheUserAndTheAddressEvenForTheRightPassword() throws Exception {
|
||||
launch(true, false, null);
|
||||
String wrong = "{\"username\":\"tata\",\"password\":\"nope\"}";
|
||||
for (int i = 0; i < 5; i++) {
|
||||
assertEquals(401, post("/api/auth/login", wrong, "X-Forwarded-For", "1.1.1." + i + ", 7.7.7.7").statusCode());
|
||||
}
|
||||
|
||||
var user = post("/api/auth/login", LOGIN, "X-Forwarded-For", "8.8.8.8");
|
||||
var address = post("/api/auth/login", "{\"username\":\"ghost\",\"password\":\"x\"}",
|
||||
"X-Forwarded-For", "2.2.2.2, 7.7.7.7");
|
||||
var other = post("/api/auth/login", "{\"username\":\"ghost\",\"password\":\"x\"}",
|
||||
"X-Forwarded-For", "2.2.2.2, 8.8.8.8");
|
||||
|
||||
assertEquals(429, user.statusCode());
|
||||
assertEquals("throttled", json(user).get("error").asText());
|
||||
int wait = json(user).get("retryAfterSeconds").asInt();
|
||||
assertTrue(wait > 14 * 60 && wait <= 15 * 60 + 1, "retry after " + wait);
|
||||
assertEquals(String.valueOf(wait), user.headers().firstValue("Retry-After").orElseThrow());
|
||||
assertEquals(429, address.statusCode());
|
||||
assertEquals(401, other.statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginAndLogoutKeepTheWriteGuards() throws Exception {
|
||||
launch(true, false, null);
|
||||
|
||||
assertEquals(403, post("/api/auth/login", LOGIN, "Origin", "https://evil.example").statusCode());
|
||||
assertEquals(403, send("POST", "/api/auth/login", LOGIN, "Content-Type", "application/json").statusCode());
|
||||
assertEquals(415, send("POST", "/api/auth/login", LOGIN, "X-Requested-With", "home-pump",
|
||||
"Content-Type", "text/plain").statusCode());
|
||||
assertEquals(400, post("/api/auth/login", "{}").statusCode());
|
||||
assertEquals(400, post("/api/auth/login", "not json").statusCode());
|
||||
assertEquals(403, send("POST", "/api/auth/logout", "{}", "Content-Type", "application/json").statusCode());
|
||||
assertEquals(405, get("/api/auth/login").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sessionOfARemovedAccountStopsWorking() throws Exception {
|
||||
launch(true, false, null);
|
||||
String session = signIn();
|
||||
assertEquals(200, get("/api/health", "Cookie", session).statusCode());
|
||||
|
||||
Files.writeString(dir.resolve("users.json"), "[{\"username\":\"mama\",\"passwordHash\":\"" + hash + "\"}]");
|
||||
launch(dir.resolve("users.json"), null, false);
|
||||
|
||||
assertEquals(401, get("/api/health", "Cookie", session).statusCode());
|
||||
assertEquals(401, get("/api/auth/me", "Cookie", session).statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void parallelWrongPasswordsAreEvaluatedAtMostFiveTimes() throws Exception {
|
||||
launch(true, false, null);
|
||||
var pool = java.util.concurrent.Executors.newFixedThreadPool(40);
|
||||
var futures = new ArrayList<java.util.concurrent.Future<Integer>>();
|
||||
for (int i = 0; i < 40; i++) {
|
||||
futures.add(pool.submit(() -> post("/api/auth/login", "{\"username\":\"tata\",\"password\":\"nope\"}")
|
||||
.statusCode()));
|
||||
}
|
||||
int evaluated = 0;
|
||||
for (var future : futures) {
|
||||
if (future.get() == 401) {
|
||||
evaluated++;
|
||||
}
|
||||
}
|
||||
pool.shutdown();
|
||||
|
||||
assertTrue(evaluated >= 1 && evaluated <= 5, "evaluated " + evaluated);
|
||||
}
|
||||
|
||||
// --- first account and account management ---
|
||||
|
||||
private static final String NEW_PASSWORD = "a brand new password";
|
||||
|
||||
private static String account(String name, String password) {
|
||||
return "{\"username\":\"" + name + "\",\"password\":\"" + password + "\"}";
|
||||
}
|
||||
|
||||
private void launchInSetupMode() throws IOException {
|
||||
launch(dir.resolve("auth-users.json"), null, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
void setupCreatesTheFirstAccountOnceAndSignsItIn() throws Exception {
|
||||
launchInSetupMode();
|
||||
assertEquals(JSON.readTree("{\"local\":true,\"oidc\":false,\"setup\":true}"), json(get("/api/auth/config")));
|
||||
|
||||
var created = post("/api/auth/setup", account("tata", NEW_PASSWORD));
|
||||
|
||||
assertEquals(200, created.statusCode());
|
||||
assertEquals(JSON.readTree("{\"user\":\"tata\",\"method\":\"local\"}"), json(created));
|
||||
assertEquals(200, get("/api/health", "Cookie", cookie(created, "hp_session")).statusCode());
|
||||
assertEquals(JSON.readTree("{\"local\":true,\"oidc\":false,\"setup\":false}"), json(get("/api/auth/config")));
|
||||
var second = post("/api/auth/setup", account("mama", NEW_PASSWORD));
|
||||
assertEquals(409, second.statusCode());
|
||||
assertEquals("{\"error\":\"setup_done\"}", second.body());
|
||||
assertEquals(200, post("/api/auth/login", account("tata", NEW_PASSWORD)).statusCode());
|
||||
assertEquals(401, post("/api/auth/login", account("mama", NEW_PASSWORD)).statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void setupWithAWeakPasswordOrOddNameIsRefusedAndCreatesNothing() throws Exception {
|
||||
launchInSetupMode();
|
||||
|
||||
var weak = post("/api/auth/setup", account("tata", "short"));
|
||||
var odd = post("/api/auth/setup", account("a b", NEW_PASSWORD));
|
||||
|
||||
assertEquals(400, weak.statusCode());
|
||||
assertEquals("{\"error\":\"weak_password\"}", weak.body());
|
||||
assertEquals(400, odd.statusCode());
|
||||
assertEquals(true, json(get("/api/auth/config")).get("setup").asBoolean());
|
||||
assertFalse(Files.exists(dir.resolve("auth-users.json")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void setupKeepsTheWriteGuards() throws Exception {
|
||||
launchInSetupMode();
|
||||
|
||||
assertEquals(403, post("/api/auth/setup", account("tata", NEW_PASSWORD), "Origin", "https://evil.example")
|
||||
.statusCode());
|
||||
assertEquals(403, send("POST", "/api/auth/setup", account("tata", NEW_PASSWORD), "Content-Type",
|
||||
"application/json").statusCode());
|
||||
assertEquals(415, send("POST", "/api/auth/setup", account("tata", NEW_PASSWORD), "X-Requested-With",
|
||||
"home-pump", "Content-Type", "text/plain").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void setupIsNotFoundWithoutLocalAccounts() throws Exception {
|
||||
launch(false, true, null);
|
||||
|
||||
assertEquals(404, post("/api/auth/setup", account("tata", NEW_PASSWORD)).statusCode());
|
||||
assertEquals(JSON.readTree("{\"local\":false,\"oidc\":true,\"setup\":false}"), json(get("/api/auth/config")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void twoParallelSetupsLeaveExactlyOneWinner() throws Exception {
|
||||
launchInSetupMode();
|
||||
var pool = java.util.concurrent.Executors.newFixedThreadPool(2);
|
||||
var a = pool.submit(() -> post("/api/auth/setup", account("tata", NEW_PASSWORD)).statusCode());
|
||||
var b = pool.submit(() -> post("/api/auth/setup", account("mama", NEW_PASSWORD)).statusCode());
|
||||
var codes = new java.util.TreeSet<>(List.of(a.get(), b.get()));
|
||||
pool.shutdown();
|
||||
|
||||
assertEquals(new java.util.TreeSet<>(List.of(200, 409)), codes);
|
||||
assertEquals(1, JSON.readTree(Files.readString(dir.resolve("auth-users.json"))).size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void accountManagementNeedsASession() throws Exception {
|
||||
launch(true, false, null);
|
||||
|
||||
assertEquals(401, get("/api/auth/users").statusCode());
|
||||
assertEquals(401, post("/api/auth/users", account("mama", NEW_PASSWORD)).statusCode());
|
||||
assertEquals(401, post("/api/auth/password", "{\"current\":\"x\",\"new\":\"" + NEW_PASSWORD + "\"}")
|
||||
.statusCode());
|
||||
assertEquals(401, post("/api/auth/users/tata/delete", "{}").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void usersCanBeListedAddedAndRemovedButNotTheLastOne() throws Exception {
|
||||
launch(true, false, null);
|
||||
String session = signIn();
|
||||
|
||||
assertEquals(JSON.readTree("[{\"username\":\"tata\"}]"), json(get("/api/auth/users", "Cookie", session)));
|
||||
assertEquals(200, post("/api/auth/users", account("mama", NEW_PASSWORD), "Cookie", session).statusCode());
|
||||
assertEquals(409, post("/api/auth/users", account("mama", NEW_PASSWORD), "Cookie", session).statusCode());
|
||||
assertEquals(400, post("/api/auth/users", account("zosia", "short"), "Cookie", session).statusCode());
|
||||
assertEquals(2, json(get("/api/auth/users", "Cookie", session)).size());
|
||||
assertEquals(200, post("/api/auth/login", account("mama", NEW_PASSWORD)).statusCode());
|
||||
|
||||
assertEquals(204, post("/api/auth/users/mama/delete", "{}", "Cookie", session).statusCode());
|
||||
assertEquals(404, post("/api/auth/users/mama/delete", "{}", "Cookie", session).statusCode());
|
||||
var last = post("/api/auth/users/tata/delete", "{}", "Cookie", session);
|
||||
assertEquals(409, last.statusCode());
|
||||
assertEquals("{\"error\":\"last_user\"}", last.body());
|
||||
assertEquals(403, send("POST", "/api/auth/users/mama/delete", "{}", "Cookie", session,
|
||||
"Content-Type", "application/json").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anOidcSessionMayManageAccountsButNotChangeAPassword() throws Exception {
|
||||
launch(true, true, null);
|
||||
Started started = start();
|
||||
issuer.idTokenToServe = issuer.idToken(started.query().get("nonce"), c -> { });
|
||||
String session = cookie(callbackWithCode(started), "hp_session");
|
||||
|
||||
assertEquals(200, post("/api/auth/users", account("mama", NEW_PASSWORD), "Cookie", session).statusCode());
|
||||
assertEquals(403, post("/api/auth/password", "{\"current\":\"x\",\"new\":\"" + NEW_PASSWORD + "\"}",
|
||||
"Cookie", session).statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void removedAccountLosesItsSessionAtOnceAndRemovingYourselfSignsYouOut() throws Exception {
|
||||
launch(true, false, null);
|
||||
String tata = signIn();
|
||||
post("/api/auth/users", account("mama", NEW_PASSWORD), "Cookie", tata);
|
||||
String mama = cookie(post("/api/auth/login", account("mama", NEW_PASSWORD)), "hp_session");
|
||||
assertEquals(200, get("/api/health", "Cookie", mama).statusCode());
|
||||
|
||||
assertEquals(204, post("/api/auth/users/mama/delete", "{}", "Cookie", tata).statusCode());
|
||||
assertEquals(401, get("/api/health", "Cookie", mama).statusCode());
|
||||
|
||||
post("/api/auth/users", account("zosia", NEW_PASSWORD), "Cookie", tata);
|
||||
var self = post("/api/auth/users/tata/delete", "{}", "Cookie", tata);
|
||||
assertEquals(204, self.statusCode());
|
||||
assertTrue(setCookie(self, "hp_session").startsWith("hp_session=; Max-Age=0"));
|
||||
assertEquals(401, get("/api/health", "Cookie", tata).statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownPasswordChangeNeedsTheCurrentOneAndCountsFailuresInTheThrottle() throws Exception {
|
||||
launch(true, false, null);
|
||||
String session = signIn();
|
||||
String wrong = "{\"current\":\"nope\",\"new\":\"" + NEW_PASSWORD + "\"}";
|
||||
|
||||
assertEquals(400, post("/api/auth/password", "{\"current\":\"" + PASSWORD + "\",\"new\":\"short\"}",
|
||||
"Cookie", session).statusCode());
|
||||
var rejected = post("/api/auth/password", wrong, "Cookie", session);
|
||||
assertEquals(401, rejected.statusCode());
|
||||
assertEquals("{\"error\":\"invalid\"}", rejected.body());
|
||||
assertEquals(204, post("/api/auth/password", "{\"current\":\"" + PASSWORD + "\",\"new\":\""
|
||||
+ NEW_PASSWORD + "\"}", "Cookie", session).statusCode());
|
||||
|
||||
assertEquals(401, post("/api/auth/login", LOGIN).statusCode());
|
||||
assertEquals(200, post("/api/auth/login", account("tata", NEW_PASSWORD)).statusCode());
|
||||
for (int i = 0; i < 5; i++) {
|
||||
post("/api/auth/password", wrong, "Cookie", session);
|
||||
}
|
||||
assertEquals(429, post("/api/auth/password", wrong, "Cookie", session).statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void changesSurviveARestartBecauseTheFileIsRewritten() throws Exception {
|
||||
launch(true, false, null);
|
||||
String session = signIn();
|
||||
post("/api/auth/users", account("mama", NEW_PASSWORD), "Cookie", session);
|
||||
|
||||
launch(dir.resolve("users.json"), null, false);
|
||||
|
||||
assertEquals(200, post("/api/auth/login", account("mama", NEW_PASSWORD)).statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void logoutClearsTheCookie() throws Exception {
|
||||
launch(true, false, null);
|
||||
|
||||
var response = post("/api/auth/logout", "{}", "Cookie", signIn());
|
||||
|
||||
assertEquals(204, response.statusCode());
|
||||
assertTrue(setCookie(response, "hp_session").startsWith("hp_session=; Max-Age=0"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginIsNotFoundWithoutLocalAccounts() throws Exception {
|
||||
launch(false, true, null);
|
||||
|
||||
assertEquals(404, post("/api/auth/login", LOGIN).statusCode());
|
||||
assertEquals(JSON.readTree("{\"local\":false,\"oidc\":true,\"setup\":false}"), json(get("/api/auth/config")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledAuthenticationOpensTheApiAsPhone() throws Exception {
|
||||
launch(null, null, true);
|
||||
|
||||
assertEquals(200, get("/api/health").statusCode());
|
||||
assertEquals(JSON.readTree("{\"user\":\"phone\",\"method\":\"none\"}"), json(get("/api/auth/me")));
|
||||
assertEquals(JSON.readTree("{\"local\":false,\"oidc\":false,\"setup\":false}"), json(get("/api/auth/config")));
|
||||
assertEquals(204, post("/api/auth/logout", "{}").statusCode());
|
||||
}
|
||||
|
||||
// --- OIDC ---
|
||||
|
||||
private record Started(Map<String, String> query, String transaction) {
|
||||
}
|
||||
|
||||
private Started start() throws Exception {
|
||||
var response = get("/api/auth/oidc/start");
|
||||
assertEquals(302, response.statusCode());
|
||||
URI location = URI.create(response.headers().firstValue("Location").orElseThrow());
|
||||
assertEquals(issuer.issuer() + "/protocol/openid-connect/auth", location.toString().split("\\?")[0]);
|
||||
String set = setCookie(response, "hp_oidc");
|
||||
assertTrue(set.contains("HttpOnly") && set.contains("Secure") && set.contains("SameSite=Lax")
|
||||
&& set.contains("Max-Age=600"), set);
|
||||
return new Started(query(location.getRawQuery()), cookie(response, "hp_oidc"));
|
||||
}
|
||||
|
||||
private static Map<String, String> query(String raw) {
|
||||
Map<String, String> query = new HashMap<>();
|
||||
for (String pair : raw.split("&")) {
|
||||
String[] kv = pair.split("=", 2);
|
||||
query.put(kv[0], URLDecoder.decode(kv[1], StandardCharsets.UTF_8));
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
private HttpResponse<String> callback(Started started, String queryString) throws Exception {
|
||||
return get("/api/auth/oidc/callback?" + queryString, "Cookie", started.transaction());
|
||||
}
|
||||
|
||||
private HttpResponse<String> callbackWithCode(Started started) throws Exception {
|
||||
return callback(started, "code=abc&state=" + started.query().get("state"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void oidcLoginSendsTheRightRequestAndSignsTheUserIn() throws Exception {
|
||||
launch(false, true, "home-pump");
|
||||
Started started = start();
|
||||
issuer.idTokenToServe = issuer.idToken(started.query().get("nonce"), c -> { });
|
||||
|
||||
var response = callbackWithCode(started);
|
||||
|
||||
assertEquals(302, response.statusCode());
|
||||
assertEquals("/", response.headers().firstValue("Location").orElseThrow());
|
||||
assertTrue(setCookie(response, "hp_oidc").startsWith("hp_oidc=; Max-Age=0"));
|
||||
var me = get("/api/auth/me", "Cookie", cookie(response, "hp_session"));
|
||||
assertEquals(JSON.readTree("{\"user\":\"tata\",\"method\":\"oidc\"}"), json(me));
|
||||
|
||||
var authorize = started.query();
|
||||
assertEquals("code", authorize.get("response_type"));
|
||||
assertEquals(StubIssuer.CLIENT_ID, authorize.get("client_id"));
|
||||
assertEquals(CALLBACK, authorize.get("redirect_uri"));
|
||||
assertEquals("S256", authorize.get("code_challenge_method"));
|
||||
assertTrue(authorize.get("scope").contains("openid"));
|
||||
var token = issuer.tokenRequests.getFirst();
|
||||
assertEquals("Basic " + Base64.getEncoder().encodeToString(
|
||||
(StubIssuer.CLIENT_ID + ":" + StubIssuer.CLIENT_KEY).getBytes(StandardCharsets.UTF_8)),
|
||||
token.authorization());
|
||||
assertEquals("authorization_code", token.form().get("grant_type"));
|
||||
assertEquals("abc", token.form().get("code"));
|
||||
assertEquals(CALLBACK, token.form().get("redirect_uri"));
|
||||
assertEquals(authorize.get("code_challenge"), Base64.getUrlEncoder().withoutPadding().encodeToString(
|
||||
MessageDigest.getInstance("SHA-256").digest(
|
||||
token.form().get("code_verifier").getBytes(StandardCharsets.US_ASCII))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void oidcTokensThatDoNotVerifyEndInOidcFailed() throws Exception {
|
||||
launch(false, true, null);
|
||||
long now = Instant.now().getEpochSecond();
|
||||
List<Function<String, String>> bad = List.of(
|
||||
nonce -> StubIssuer.sign("RS256", "k1", Map.of("iss", issuer.issuer().toString(),
|
||||
"aud", StubIssuer.CLIENT_ID, "nonce", nonce, "iat", now, "exp", now + 300,
|
||||
"preferred_username", "tata"), StubIssuer.newKeys().getPrivate()),
|
||||
nonce -> issuer.idToken(nonce, c -> c.put("aud", "other")),
|
||||
nonce -> issuer.idToken(nonce, c -> c.put("exp", now - 600)),
|
||||
nonce -> issuer.idToken("another-nonce", c -> { }));
|
||||
for (var token : bad) {
|
||||
Started started = start();
|
||||
issuer.idTokenToServe = token.apply(started.query().get("nonce"));
|
||||
|
||||
var response = callbackWithCode(started);
|
||||
|
||||
assertEquals("/#login?error=oidc_failed", response.headers().firstValue("Location").orElseThrow());
|
||||
assertEquals(null, cookie(response, "hp_session"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void wrongStateOrMissingLoginIsRefusedWithoutAskingTheIssuer() throws Exception {
|
||||
launch(false, true, null);
|
||||
Started started = start();
|
||||
|
||||
var wrongState = callback(started, "code=abc&state=forged");
|
||||
var noCookie = get("/api/auth/oidc/callback?code=abc&state=" + started.query().get("state"));
|
||||
var noParameters = callback(started, "");
|
||||
|
||||
for (var response : List.of(wrongState, noCookie, noParameters)) {
|
||||
assertEquals("/#login?error=oidc_failed", response.headers().firstValue("Location").orElseThrow());
|
||||
}
|
||||
assertTrue(issuer.tokenRequests.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingRoleIsDenied() throws Exception {
|
||||
launch(false, true, "admin");
|
||||
Started started = start();
|
||||
issuer.idTokenToServe = issuer.idToken(started.query().get("nonce"), c -> { });
|
||||
|
||||
var response = callbackWithCode(started);
|
||||
|
||||
assertEquals("/#login?error=oidc_denied", response.headers().firstValue("Location").orElseThrow());
|
||||
assertEquals(null, cookie(response, "hp_session"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusedByTheUserIsDenied() throws Exception {
|
||||
launch(false, true, null);
|
||||
Started started = start();
|
||||
|
||||
var response = callback(started, "error=access_denied&state=" + started.query().get("state"));
|
||||
|
||||
assertEquals("/#login?error=oidc_denied", response.headers().firstValue("Location").orElseThrow());
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenEndpointRefusalIsOidcFailed() throws Exception {
|
||||
launch(false, true, null);
|
||||
Started started = start();
|
||||
issuer.tokenStatus = 400;
|
||||
|
||||
assertEquals("/#login?error=oidc_failed",
|
||||
callbackWithCode(started).headers().firstValue("Location").orElseThrow());
|
||||
}
|
||||
|
||||
@Test
|
||||
void keycloakDownBeforeTheStartRedirectsBackWhileLocalLoginWorks() throws Exception {
|
||||
issuer = new StubIssuer();
|
||||
URI gone = issuer.issuer();
|
||||
issuer.stop();
|
||||
launch(users(), oidcSettings(gone, null), false);
|
||||
|
||||
var start = get("/api/auth/oidc/start");
|
||||
|
||||
assertEquals(302, start.statusCode());
|
||||
assertEquals("/#login?error=oidc_unavailable", start.headers().firstValue("Location").orElseThrow());
|
||||
assertEquals(null, cookie(start, "hp_oidc"));
|
||||
assertEquals(200, get("/api/auth/config").statusCode());
|
||||
assertTrue(signIn() != null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void keycloakDownDuringTheCallbackIsUnavailable() throws Exception {
|
||||
launch(true, true, null);
|
||||
Started started = start();
|
||||
issuer.stop();
|
||||
|
||||
var response = callbackWithCode(started);
|
||||
|
||||
assertEquals("/#login?error=oidc_unavailable", response.headers().firstValue("Location").orElseThrow());
|
||||
assertEquals(200, post("/api/auth/login", LOGIN).statusCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package home.backend;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** The environment rules that keep the backend from starting open by accident. */
|
||||
class AuthSettingsTest {
|
||||
|
||||
private static final String SIGNING_KEY = "0123456789abcdef0123456789abcdef";
|
||||
|
||||
private static Map<String, String> env(String... extra) {
|
||||
Map<String, String> env = new HashMap<>(Map.of("GATEWAY_URL", "http://10.0.0.2:8080",
|
||||
"INFLUX_URL", "https://influx.example"));
|
||||
for (int i = 0; i < extra.length; i += 2) {
|
||||
env.put(extra[i], extra[i + 1]);
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
private static String refusal(String... extra) {
|
||||
return assertThrows(IllegalArgumentException.class, () -> Settings.fromEnvironment(env(extra))).getMessage();
|
||||
}
|
||||
|
||||
@Test
|
||||
void withoutAnySignInTheBackendRefusesToStart() {
|
||||
assertTrue(refusal().contains("AUTH_DISABLED=true"));
|
||||
assertTrue(refusal("AUTH_DISABLED", "false").contains("no sign-in configured"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void authDisabledIsAnExplicitChoice() {
|
||||
var auth = Settings.fromEnvironment(env("AUTH_DISABLED", "true")).auth();
|
||||
|
||||
assertTrue(auth.disabled());
|
||||
assertNull(auth.oidc());
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledContradictsConfiguredSignIn() {
|
||||
assertTrue(refusal("AUTH_DISABLED", "true", "USERS_FILE", "/data/users.json").contains("contradicts"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void configuredSignInNeedsALongSessionSecret() {
|
||||
assertTrue(refusal("USERS_FILE", "/data/users.json").contains("SESSION_SECRET"));
|
||||
assertTrue(refusal("USERS_FILE", "/data/users.json", "SESSION_SECRET", "short").contains("32 bytes"));
|
||||
assertTrue(refusal("OIDC_ISSUER", "https://login.example/realms/home", "OIDC_CLIENT_ID", "x",
|
||||
"OIDC_CLIENT_SECRET", "y", "OIDC_REDIRECT_URI", "https://pompa.example/cb").contains("SESSION_SECRET"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void oidcWithoutARequiredRoleWarns() {
|
||||
var open = Settings.fromEnvironment(env("OIDC_ISSUER", "https://login.example/realms/home",
|
||||
"OIDC_CLIENT_ID", "x", "OIDC_CLIENT_SECRET", "y", "OIDC_REDIRECT_URI", "https://pompa.example/cb",
|
||||
"SESSION_SECRET", SIGNING_KEY)).auth();
|
||||
var restricted = Settings.fromEnvironment(env("OIDC_ISSUER", "https://login.example/realms/home",
|
||||
"OIDC_CLIENT_ID", "x", "OIDC_CLIENT_SECRET", "y", "OIDC_REDIRECT_URI", "https://pompa.example/cb",
|
||||
"OIDC_REQUIRED_ROLE", "family", "SESSION_SECRET", SIGNING_KEY)).auth();
|
||||
|
||||
assertTrue(open.warnings().getFirst().contains("every user of the Keycloak realm"));
|
||||
assertTrue(restricted.warnings().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void oidcNeedsItsClientSettings() {
|
||||
assertTrue(refusal("OIDC_ISSUER", "https://login.example/realms/home", "SESSION_SECRET", SIGNING_KEY)
|
||||
.contains("OIDC_CLIENT_ID"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void localAndOidcAreConfiguredIndependently() {
|
||||
var local = Settings.fromEnvironment(env("USERS_FILE", "/data/users.json", "SESSION_SECRET", SIGNING_KEY)).auth();
|
||||
var oidc = Settings.fromEnvironment(env("OIDC_ISSUER", "https://login.example/realms/home",
|
||||
"OIDC_CLIENT_ID", "home-pump", "OIDC_CLIENT_SECRET", "y", "OIDC_REDIRECT_URI",
|
||||
"https://pompa.example/api/auth/oidc/callback", "OIDC_REQUIRED_ROLE", "family",
|
||||
"SESSION_SECRET", SIGNING_KEY, "SESSION_DAYS", "7", "TRUSTED_PROXY_HOPS", "2")).auth();
|
||||
|
||||
assertEquals("/data/users.json", local.usersFile().toString());
|
||||
assertNull(local.oidc());
|
||||
assertEquals(30, local.sessionDays());
|
||||
assertEquals(1, local.proxyHops());
|
||||
assertNull(oidc.usersFile());
|
||||
assertEquals("family", oidc.oidc().requiredRole());
|
||||
assertEquals(7, oidc.sessionDays());
|
||||
assertEquals(2, oidc.proxyHops());
|
||||
assertFalse(oidc.disabled());
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import home.backend.adapters.auth.AuthSettings;
|
||||
import home.backend.adapters.http.ApiServer;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -43,6 +44,8 @@ class BackendTest {
|
||||
/** Just after the readings in the snapshot fixture, so they count as fresh. */
|
||||
private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-10-23T22:27:00Z"), ZoneOffset.UTC);
|
||||
|
||||
private static final AuthSettings OPEN = new AuthSettings(null, null, null, 30, true, 1);
|
||||
|
||||
private final HttpClient client = HttpClient.newHttpClient();
|
||||
private HttpServer gateway;
|
||||
private HttpServer influx;
|
||||
@@ -56,7 +59,7 @@ class BackendTest {
|
||||
gateway.createContext("/commands", this::commands);
|
||||
influx = stub(Map.of("/query", "src/test/resources/influx/influx-query-cwu-6h.json"));
|
||||
var settings = new Settings(url(gateway), url(influx), 0, Duration.ofSeconds(2), Duration.ofSeconds(2),
|
||||
"s3cret-token", null);
|
||||
"s3cret-token", null, OPEN);
|
||||
backend = Backend.assemble(settings, CLOCK);
|
||||
backend.start();
|
||||
}
|
||||
@@ -83,7 +86,7 @@ class BackendTest {
|
||||
|
||||
private HttpResponse<String> post(String path, String body) throws Exception {
|
||||
var request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + backend.port() + path))
|
||||
.header("Remote-User", "anna").header("Content-Type", "application/json")
|
||||
.header("Content-Type", "application/json")
|
||||
.header("X-Requested-With", "home-pump")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body)).build();
|
||||
return client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
@@ -199,7 +202,7 @@ class BackendTest {
|
||||
JSON.readTree(response.body()));
|
||||
assertEquals(1, commandsSeen.size());
|
||||
assertEquals("Bearer s3cret-token", commandsSeen.getFirst().authorization());
|
||||
assertEquals("anna", commandsSeen.getFirst().requestedBy());
|
||||
assertEquals("phone", commandsSeen.getFirst().requestedBy());
|
||||
assertEquals(JSON.readTree(Files.readString(Path.of("../gateway/src/test/resources/commands/request-boost.json"))),
|
||||
JSON.readTree(commandsSeen.getFirst().body()));
|
||||
}
|
||||
@@ -209,7 +212,7 @@ class BackendTest {
|
||||
Path file = Files.writeString(dir.resolve("presets.json"),
|
||||
"[{\"id\":\"bath\",\"name\":\"Kąpiel\",\"changes\":[{\"setpoint\":\"cwu_economy\",\"value\":48,\"hours\":1}]}]");
|
||||
var custom = Backend.assemble(new Settings(url(gateway), url(influx), 0, Duration.ofSeconds(2),
|
||||
Duration.ofSeconds(2), "s3cret-token", file), CLOCK);
|
||||
Duration.ofSeconds(2), "s3cret-token", file, OPEN), CLOCK);
|
||||
custom.start();
|
||||
try {
|
||||
var request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + custom.port() + "/api/presets/bath"))
|
||||
@@ -229,7 +232,7 @@ class BackendTest {
|
||||
@Test
|
||||
void withoutTokenSetpointsAreUnavailableButLiveStateStays() throws Exception {
|
||||
var noToken = Backend.assemble(new Settings(url(gateway), url(influx), 0, Duration.ofSeconds(2),
|
||||
Duration.ofSeconds(2), null, null), CLOCK);
|
||||
Duration.ofSeconds(2), null, null, OPEN), CLOCK);
|
||||
noToken.start();
|
||||
try {
|
||||
var base = "http://127.0.0.1:" + noToken.port();
|
||||
@@ -256,7 +259,7 @@ class BackendTest {
|
||||
@Test
|
||||
void urlsMayEndWithASingleSlash() {
|
||||
var settings = Settings.fromEnvironment(
|
||||
Map.of("GATEWAY_URL", "http://gw:8080/", "INFLUX_URL", "https://influx.example/"));
|
||||
Map.of("GATEWAY_URL", "http://gw:8080/", "INFLUX_URL", "https://influx.example/", "AUTH_DISABLED", "true"));
|
||||
|
||||
assertEquals(URI.create("http://gw:8080/"), settings.gatewayUrl());
|
||||
}
|
||||
@@ -264,7 +267,7 @@ class BackendTest {
|
||||
@Test
|
||||
void settingsHaveDefaults() {
|
||||
var settings = Settings.fromEnvironment(
|
||||
Map.of("GATEWAY_URL", "http://gw:8080", "INFLUX_URL", "https://influx.example"));
|
||||
Map.of("GATEWAY_URL", "http://gw:8080", "INFLUX_URL", "https://influx.example", "AUTH_DISABLED", "true"));
|
||||
|
||||
assertEquals(8080, settings.port());
|
||||
assertEquals(Duration.ofSeconds(5), settings.gatewayTimeout());
|
||||
@@ -273,7 +276,8 @@ class BackendTest {
|
||||
|
||||
@Test
|
||||
void tokenAndPresetsFileAreOptional() {
|
||||
var urls = Map.of("GATEWAY_URL", "http://gw:8080", "INFLUX_URL", "https://influx.example");
|
||||
var urls = Map.of("GATEWAY_URL", "http://gw:8080", "INFLUX_URL", "https://influx.example",
|
||||
"AUTH_DISABLED", "true");
|
||||
var none = Settings.fromEnvironment(urls);
|
||||
var env = new HashMap<>(urls);
|
||||
env.put("GATEWAY_TOKEN", " s3cret-token ");
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.time.Clock;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import static home.backend.adapters.auth.OidcException.Reason.DENIED;
|
||||
import static home.backend.adapters.auth.OidcException.Reason.FAILED;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
class IdTokenVerifierTest {
|
||||
|
||||
private static StubIssuer issuer;
|
||||
|
||||
@BeforeAll
|
||||
static void start() throws IOException {
|
||||
issuer = new StubIssuer();
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void stop() {
|
||||
issuer.stop();
|
||||
}
|
||||
|
||||
private IdTokenVerifier verifier(String role) {
|
||||
return new IdTokenVerifier(new OidcSettings(issuer.issuer(), StubIssuer.CLIENT_ID, "x",
|
||||
URI.create("https://pompa.example/api/auth/oidc/callback"), role), Clock.systemUTC());
|
||||
}
|
||||
|
||||
private String verify(String token, String role) throws OidcException {
|
||||
return verifier(role).verify(token, "n1", kid -> kid.equals("k1") ? issuer.publicKey() : null);
|
||||
}
|
||||
|
||||
private OidcException failure(String token, String role) {
|
||||
return assertThrows(OidcException.class, () -> verify(token, role));
|
||||
}
|
||||
|
||||
private String token(Consumer<Map<String, Object>> change) {
|
||||
return issuer.idToken("n1", change);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validTokenGivesTheUserName() throws Exception {
|
||||
assertEquals("tata", verify(token(c -> { }), "home-pump"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void userNameFallsBackToEmailThenSubject() throws Exception {
|
||||
assertEquals("tata@example.pl", verify(token(c -> {
|
||||
c.remove("preferred_username");
|
||||
c.put("email", "tata@example.pl");
|
||||
}), null));
|
||||
assertEquals("7f1c", verify(token(c -> c.remove("preferred_username")), null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void signatureOfAnotherKeyIsRefused() {
|
||||
var forged = StubIssuer.sign("RS256", "k1", Map.of("iss", issuer.issuer().toString(), "aud", "home-pump"),
|
||||
StubIssuer.newKeys().getPrivate());
|
||||
|
||||
assertEquals(FAILED, failure(forged, null).reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void tamperedPayloadIsRefused() {
|
||||
String[] parts = token(c -> { }).split("\\.");
|
||||
String other = token(c -> c.put("preferred_username", "admin")).split("\\.")[1];
|
||||
|
||||
assertEquals(FAILED, failure(parts[0] + "." + other + "." + parts[2], null).reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void otherAlgorithmsAreRefused() {
|
||||
String none = "eyJhbGciOiJub25lIn0." + token(c -> { }).split("\\.")[1] + ".";
|
||||
|
||||
assertEquals(FAILED, failure(none, null).reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenForAnotherClientIsRefused() {
|
||||
assertEquals(FAILED, failure(token(c -> c.put("aud", "other-app")), null).reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void severalAudiencesNeedOurClientAsAuthorizedParty() throws Exception {
|
||||
assertEquals(FAILED, failure(token(c -> c.put("aud", List.of("home-pump", "account"))), null).reason());
|
||||
assertEquals("tata", verify(token(c -> {
|
||||
c.put("aud", List.of("home-pump", "account"));
|
||||
c.put("azp", "home-pump");
|
||||
}), null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void wrongIssuerIsRefused() {
|
||||
assertEquals(FAILED, failure(token(c -> c.put("iss", "https://evil.example/realms/home")), null).reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiredTokenIsRefusedButASmallSkewIsAllowed() throws Exception {
|
||||
long now = java.time.Instant.now().getEpochSecond();
|
||||
|
||||
assertEquals(FAILED, failure(token(c -> c.put("exp", now - 120)), null).reason());
|
||||
assertEquals("tata", verify(token(c -> c.put("exp", now - 10)), null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenIssuedInTheFutureIsRefused() {
|
||||
long now = java.time.Instant.now().getEpochSecond();
|
||||
|
||||
assertEquals(FAILED, failure(token(c -> c.put("iat", now + 600)), null).reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void wrongNonceIsRefused() {
|
||||
assertEquals(FAILED, failure(issuer.idToken("another", c -> { }), null).reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void requiredRoleMayBeARealmRoleOrAClientRole() throws Exception {
|
||||
assertEquals("tata", verify(token(c -> { }), "home-pump"));
|
||||
assertEquals("tata", verify(token(c -> {
|
||||
c.remove("realm_access");
|
||||
c.put("resource_access", Map.of("home-pump", Map.of("roles", List.of("family"))));
|
||||
}), "family"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingRoleIsDenied() {
|
||||
assertEquals(DENIED, failure(token(c -> { }), "admin").reason());
|
||||
assertEquals(DENIED, failure(token(c -> c.put("resource_access",
|
||||
Map.of("account", Map.of("roles", List.of("admin"))))), "admin").reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownKeyIsRefused() {
|
||||
var verifier = verifier(null);
|
||||
|
||||
assertEquals(FAILED, assertThrows(OidcException.class,
|
||||
() -> verifier.verify(token(c -> { }), "n1", kid -> {
|
||||
throw new OidcException(FAILED, "no signing key " + kid);
|
||||
})).reason());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.PrintStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.PosixFilePermissions;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** Hashing, the accounts file and the hash-password command. */
|
||||
class PasswordTest {
|
||||
|
||||
private static String hash;
|
||||
|
||||
@BeforeAll
|
||||
static void hashOnce() {
|
||||
hash = PasswordHash.create("correct horse".toCharArray());
|
||||
}
|
||||
|
||||
@Test
|
||||
void hashIsSelfDescribingSaltedAndChecked() {
|
||||
String[] parts = hash.split("\\$");
|
||||
|
||||
assertEquals("pbkdf2-sha256", parts[0]);
|
||||
assertTrue(Integer.parseInt(parts[1]) >= 310_000);
|
||||
assertTrue(PasswordHash.matches(hash, "correct horse".toCharArray()));
|
||||
assertFalse(PasswordHash.matches(hash, "wrong horse".toCharArray()));
|
||||
assertNotEquals(hash, PasswordHash.create("correct horse".toCharArray()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void weakOrMalformedHashesAreRefused() {
|
||||
for (String bad : new String[] {"", "plain", "pbkdf2-sha256$1000$AAAAAAAAAAAAAAAAAAAAAA==$AAAA",
|
||||
"pbkdf2-sha256$x$y$z", "md5$600000$AAAAAAAAAAAAAAAAAAAAAA==$AAAA"}) {
|
||||
assertThrows(IllegalArgumentException.class, () -> PasswordHash.requireValid(bad), bad);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void usersFileHoldsAccounts(@TempDir Path dir) throws IOException {
|
||||
UserStore users = UserStore.load(file(dir, "[{\"username\":\"tata\",\"passwordHash\":\"" + hash + "\"}]"));
|
||||
|
||||
assertTrue(users.authenticate("tata", "correct horse".toCharArray()));
|
||||
assertFalse(users.authenticate("tata", "nope".toCharArray()));
|
||||
assertFalse(users.authenticate("ghost", "correct horse".toCharArray()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void badUsersFilesStopTheStart(@TempDir Path dir) throws IOException {
|
||||
String account = "{\"username\":\"tata\",\"passwordHash\":\"" + hash + "\"}";
|
||||
for (String bad : new String[] {"not json", "[" + account + "," + account + "]",
|
||||
"[{\"username\":\"a b\",\"passwordHash\":\"" + hash + "\"}]",
|
||||
"[{\"username\":\"tata\",\"passwordHash\":\"plain\"}]"}) {
|
||||
Path file = file(dir, bad);
|
||||
assertThrows(IllegalArgumentException.class, () -> UserStore.load(file), bad);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingOrEmptyFileIsAnEmptyStoreForSetup(@TempDir Path dir) throws IOException {
|
||||
assertTrue(UserStore.load(dir.resolve("missing.json")).isEmpty());
|
||||
assertTrue(UserStore.load(file(dir, "[]")).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void changesAreWrittenAtomicallyPrivatelyAndReadBack(@TempDir Path dir) throws IOException {
|
||||
Path file = dir.resolve("users.json");
|
||||
UserStore users = UserStore.load(file);
|
||||
|
||||
assertTrue(users.addFirst("tata", hash));
|
||||
assertFalse(users.addFirst("mama", hash));
|
||||
assertTrue(users.add("mama", hash));
|
||||
assertFalse(users.add("mama", hash));
|
||||
assertTrue(users.changePassword("mama", PasswordHash.create("another password".toCharArray())));
|
||||
assertFalse(users.changePassword("ghost", hash));
|
||||
|
||||
assertEquals("rw-------", PosixFilePermissions.toString(Files.getPosixFilePermissions(file)));
|
||||
try (var listing = Files.list(dir)) {
|
||||
assertEquals(List.of("users.json"), listing.map(p -> p.getFileName().toString()).toList());
|
||||
}
|
||||
UserStore again = UserStore.load(file);
|
||||
assertEquals(List.of("tata", "mama"), again.usernames());
|
||||
assertTrue(again.authenticate("tata", "correct horse".toCharArray()));
|
||||
assertTrue(again.authenticate("mama", "another password".toCharArray()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void lastAccountCannotBeRemoved(@TempDir Path dir) {
|
||||
UserStore users = UserStore.load(dir.resolve("users.json"));
|
||||
users.addFirst("tata", hash);
|
||||
users.add("mama", hash);
|
||||
|
||||
assertEquals(UserStore.Removal.REMOVED, users.remove("mama"));
|
||||
assertEquals(UserStore.Removal.LAST, users.remove("tata"));
|
||||
assertEquals(UserStore.Removal.UNKNOWN, users.remove("mama"));
|
||||
assertEquals(List.of("tata"), UserStore.load(dir.resolve("users.json")).usernames());
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedWriteLeavesTheStoreAsItWas(@TempDir Path dir) {
|
||||
UserStore users = UserStore.load(dir.resolve("gone/users.json"));
|
||||
|
||||
assertThrows(UncheckedIOException.class, () -> users.addFirst("tata", hash));
|
||||
assertTrue(users.isEmpty());
|
||||
}
|
||||
|
||||
/** An unknown user is run through the same hashing, so the answer does not tell which names exist. */
|
||||
@Test
|
||||
void unknownUserCostsAboutAsMuchAsAWrongPassword(@TempDir Path dir) throws IOException {
|
||||
UserStore users = UserStore.load(file(dir, "[{\"username\":\"tata\",\"passwordHash\":\"" + hash + "\"}]"));
|
||||
users.authenticate("tata", "warm up".toCharArray());
|
||||
|
||||
long wrong = nanos(() -> users.authenticate("tata", "nope".toCharArray()));
|
||||
long unknown = nanos(() -> users.authenticate("ghost", "nope".toCharArray()));
|
||||
|
||||
assertTrue(unknown > wrong / 2, "unknown " + unknown + " ns vs wrong " + wrong + " ns");
|
||||
}
|
||||
|
||||
@Test
|
||||
void commandPrintsAHashFromStdin() throws IOException {
|
||||
var out = new ByteArrayOutputStream();
|
||||
|
||||
int code = PasswordHashCli.run(null, stream("s3cr3t-pass\n"), new PrintStream(out), System.err);
|
||||
|
||||
assertEquals(0, code);
|
||||
assertTrue(PasswordHash.matches(out.toString(StandardCharsets.UTF_8).trim(), "s3cr3t-pass".toCharArray()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void commandRefusesAnEmptyPassword() throws IOException {
|
||||
var err = new ByteArrayOutputStream();
|
||||
|
||||
assertEquals(1, PasswordHashCli.run(null, stream("\n"), System.out, new PrintStream(err)));
|
||||
assertTrue(err.toString(StandardCharsets.UTF_8).contains("No password"));
|
||||
}
|
||||
|
||||
private static ByteArrayInputStream stream(String text) {
|
||||
return new ByteArrayInputStream(text.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private static Path file(Path dir, String content) throws IOException {
|
||||
return Files.writeString(dir.resolve("users-" + content.hashCode() + ".json"), content);
|
||||
}
|
||||
|
||||
private static long nanos(Runnable action) {
|
||||
long start = System.nanoTime();
|
||||
action.run();
|
||||
return System.nanoTime() - start;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** The session cookie, the throttle and the other signed cookie. */
|
||||
class SessionsTest {
|
||||
|
||||
private static final byte[] SIGNING_KEY = "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.UTF_8);
|
||||
private static final Instant T0 = Instant.parse("2026-10-24T10:00:00Z");
|
||||
|
||||
private Instant now = T0;
|
||||
private final Clock clock = new Clock() {
|
||||
@Override
|
||||
public ZoneOffset getZone() {
|
||||
return ZoneOffset.UTC;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Clock withZone(java.time.ZoneId zone) {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Instant instant() {
|
||||
return now;
|
||||
}
|
||||
};
|
||||
private final SignedTokens tokens = new SignedTokens(SIGNING_KEY, clock);
|
||||
private final Sessions sessions = new Sessions(tokens, clock, Duration.ofDays(30));
|
||||
|
||||
private static String cookieHeader(String setCookie) {
|
||||
return setCookie.split(";")[0];
|
||||
}
|
||||
|
||||
@Test
|
||||
void cookieCarriesTheSessionAndTheRightFlags() {
|
||||
String setCookie = sessions.setCookie("tata", "local");
|
||||
|
||||
assertEquals(Optional.of(new Session("tata", "local", T0)), sessions.read(cookieHeader(setCookie)));
|
||||
for (String flag : new String[] {"HttpOnly", "Secure", "SameSite=Lax", "Path=/", "Max-Age=2592000"}) {
|
||||
assertTrue(setCookie.contains(flag), flag);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void sessionExpiresAfterItsLifetime() {
|
||||
String cookie = cookieHeader(sessions.setCookie("tata", "oidc"));
|
||||
|
||||
now = T0.plus(Duration.ofDays(30)).minusSeconds(1);
|
||||
assertTrue(sessions.read(cookie).isPresent());
|
||||
now = T0.plus(Duration.ofDays(30));
|
||||
assertTrue(sessions.read(cookie).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void tamperedOrForeignCookiesAreRefused() {
|
||||
String cookie = cookieHeader(sessions.setCookie("tata", "local"));
|
||||
String other = cookieHeader(sessions.setCookie("mama", "local"));
|
||||
String forgedPayload = other.substring(0, other.indexOf('.')) + cookie.substring(cookie.indexOf('.'));
|
||||
var otherSecret = new Sessions(new SignedTokens("another-secret-another-secret-12".getBytes(StandardCharsets.UTF_8),
|
||||
clock), clock, Duration.ofDays(30));
|
||||
|
||||
assertTrue(sessions.read(forgedPayload).isEmpty());
|
||||
assertTrue(sessions.read(cookie + "x").isEmpty());
|
||||
assertTrue(sessions.read("hp_session=garbage").isEmpty());
|
||||
assertTrue(sessions.read("hp_session=a.b.c").isEmpty());
|
||||
assertTrue(sessions.read(null).isEmpty());
|
||||
assertTrue(otherSecret.read(cookie).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aTokenForAnotherPurposeIsNotASession() {
|
||||
String token = tokens.issue("oidc-login", Duration.ofMinutes(10),
|
||||
Map.of("user", "tata", "method", "local", "iat", "0"));
|
||||
|
||||
assertTrue(sessions.read("hp_session=" + token).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void clearingExpiresTheCookie() {
|
||||
assertTrue(sessions.clearCookie().startsWith("hp_session=; Max-Age=0"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void throttleBlocksTheSixthAttemptUntilTheOldestLeavesTheWindow() {
|
||||
var throttle = new LoginThrottle(clock, 5, Duration.ofMinutes(15));
|
||||
for (int i = 0; i < 4; i++) {
|
||||
assertEquals(0, throttle.tryAcquire("user:tata", "ip:1.2.3.4"));
|
||||
}
|
||||
|
||||
now = T0.plusSeconds(60);
|
||||
assertEquals(0, throttle.tryAcquire("user:tata", "ip:1.2.3.4"));
|
||||
|
||||
assertEquals(15 * 60 - 60 + 1, throttle.tryAcquire("user:tata"));
|
||||
assertEquals(0, throttle.tryAcquire("user:mama", "ip:5.6.7.8"));
|
||||
now = T0.plus(Duration.ofMinutes(15));
|
||||
assertEquals(0, throttle.tryAcquire("user:tata", "ip:1.2.3.4"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void releasedAttemptsAndSuccessDoNotCount() {
|
||||
var throttle = new LoginThrottle(clock, 2, Duration.ofMinutes(15));
|
||||
throttle.tryAcquire("user:tata");
|
||||
throttle.release("user:tata");
|
||||
throttle.tryAcquire("user:tata");
|
||||
assertEquals(0, throttle.tryAcquire("user:tata"));
|
||||
assertTrue(throttle.tryAcquire("user:tata") > 0);
|
||||
|
||||
throttle.cleared("user:tata");
|
||||
|
||||
assertEquals(0, throttle.tryAcquire("user:tata"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parallelAttemptsCannotOutrunTheLimit() throws Exception {
|
||||
var throttle = new LoginThrottle(clock, 5, Duration.ofMinutes(15));
|
||||
var pool = java.util.concurrent.Executors.newFixedThreadPool(32);
|
||||
var allowed = new java.util.concurrent.atomic.AtomicInteger();
|
||||
var tasks = new java.util.ArrayList<java.util.concurrent.Future<?>>();
|
||||
for (int i = 0; i < 200; i++) {
|
||||
tasks.add(pool.submit(() -> {
|
||||
if (throttle.tryAcquire("user:tata", "ip:1.2.3.4") == 0) {
|
||||
allowed.incrementAndGet();
|
||||
}
|
||||
}));
|
||||
}
|
||||
for (var task : tasks) {
|
||||
task.get();
|
||||
}
|
||||
pool.shutdown();
|
||||
|
||||
assertEquals(5, allowed.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void odditiesInTheProvidersErrorAreCleanedForTheLog() {
|
||||
assertEquals("server_error", OidcFlow.printable("server_error"));
|
||||
assertEquals("a?b?c", OidcFlow.printable("a\nb\rc"));
|
||||
assertEquals(40 + 3, OidcFlow.printable("x".repeat(100)).length());
|
||||
assertEquals("nothing", OidcFlow.printable(null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.net.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.Signature;
|
||||
import java.security.interfaces.RSAPublicKey;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/** A Keycloak stand-in on localhost: discovery, signing keys and a token endpoint that hands out what a test says. */
|
||||
public final class StubIssuer {
|
||||
|
||||
public static final String CLIENT_ID = "home-pump";
|
||||
public static final String CLIENT_KEY = "stub-client-secret";
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
|
||||
/** What the token endpoint received. */
|
||||
public record TokenRequest(String authorization, Map<String, String> form) {
|
||||
}
|
||||
|
||||
public final List<TokenRequest> tokenRequests = new ArrayList<>();
|
||||
/** What the token endpoint answers with; set by the test. */
|
||||
public volatile String idTokenToServe = "";
|
||||
public volatile int tokenStatus = 200;
|
||||
|
||||
private final KeyPair keys = newKeys();
|
||||
private final HttpServer server;
|
||||
|
||||
public StubIssuer() throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
|
||||
server.createContext("/realms/home/.well-known/openid-configuration", exchange -> reply(exchange, 200,
|
||||
Map.of("issuer", issuer().toString(),
|
||||
"authorization_endpoint", issuer() + "/protocol/openid-connect/auth",
|
||||
"token_endpoint", issuer() + "/protocol/openid-connect/token",
|
||||
"jwks_uri", issuer() + "/protocol/openid-connect/certs")));
|
||||
server.createContext("/realms/home/protocol/openid-connect/certs", exchange -> reply(exchange, 200,
|
||||
Map.of("keys", List.of(Map.of("kty", "RSA", "kid", "broken", "n", "***", "e", "AQAB"), jwk()))));
|
||||
server.createContext("/realms/home/protocol/openid-connect/token", this::token);
|
||||
server.start();
|
||||
}
|
||||
|
||||
public URI issuer() {
|
||||
return URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/realms/home");
|
||||
}
|
||||
|
||||
public java.security.PublicKey publicKey() {
|
||||
return keys.getPublic();
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
server.stop(0);
|
||||
}
|
||||
|
||||
/** A valid ID token for the nonce, with {@code change} applied to the claims. */
|
||||
public String idToken(String nonce, Consumer<Map<String, Object>> change) {
|
||||
long now = Instant.now().getEpochSecond();
|
||||
Map<String, Object> claims = new LinkedHashMap<>();
|
||||
claims.put("iss", issuer().toString());
|
||||
claims.put("aud", CLIENT_ID);
|
||||
claims.put("sub", "7f1c");
|
||||
claims.put("preferred_username", "tata");
|
||||
claims.put("iat", now);
|
||||
claims.put("exp", now + 300);
|
||||
claims.put("nonce", nonce);
|
||||
claims.put("realm_access", Map.of("roles", List.of("home-pump")));
|
||||
change.accept(claims);
|
||||
return sign("RS256", "k1", claims, keys.getPrivate());
|
||||
}
|
||||
|
||||
/** Signs with any key, so a test can forge a token. */
|
||||
public static String sign(String alg, String kid, Map<String, Object> claims, PrivateKey key) {
|
||||
try {
|
||||
String header = Base64Url.encode(JSON.writeValueAsBytes(Map.of("alg", alg, "kid", kid, "typ", "JWT")));
|
||||
String signed = header + "." + Base64Url.encode(JSON.writeValueAsBytes(claims));
|
||||
Signature rsa = Signature.getInstance("SHA256withRSA");
|
||||
rsa.initSign(key);
|
||||
rsa.update(signed.getBytes(StandardCharsets.US_ASCII));
|
||||
return signed + "." + Base64Url.encode(rsa.sign());
|
||||
} catch (IOException | GeneralSecurityException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static KeyPair newKeys() {
|
||||
try {
|
||||
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
|
||||
generator.initialize(2048);
|
||||
return generator.generateKeyPair();
|
||||
} catch (GeneralSecurityException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, String> jwk() {
|
||||
var key = (RSAPublicKey) keys.getPublic();
|
||||
return Map.of("kty", "RSA", "use", "sig", "alg", "RS256", "kid", "k1",
|
||||
"n", Base64Url.encode(unsigned(key.getModulus())), "e", Base64Url.encode(unsigned(key.getPublicExponent())));
|
||||
}
|
||||
|
||||
private static byte[] unsigned(BigInteger number) {
|
||||
byte[] bytes = number.toByteArray();
|
||||
return bytes[0] == 0 ? java.util.Arrays.copyOfRange(bytes, 1, bytes.length) : bytes;
|
||||
}
|
||||
|
||||
private void token(HttpExchange exchange) throws IOException {
|
||||
Map<String, String> form = new HashMap<>();
|
||||
for (String pair : new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8).split("&")) {
|
||||
String[] kv = pair.split("=", 2);
|
||||
form.put(URLDecoder.decode(kv[0], StandardCharsets.UTF_8), URLDecoder.decode(kv[1], StandardCharsets.UTF_8));
|
||||
}
|
||||
tokenRequests.add(new TokenRequest(exchange.getRequestHeaders().getFirst("Authorization"), form));
|
||||
reply(exchange, tokenStatus, Map.of("id_token", idTokenToServe, "token_type", "Bearer"));
|
||||
}
|
||||
|
||||
private static void reply(HttpExchange exchange, int status, Object body) throws IOException {
|
||||
byte[] bytes = JSON.writeValueAsBytes(body);
|
||||
exchange.getResponseHeaders().set("Content-Type", "application/json");
|
||||
exchange.sendResponseHeaders(status, bytes.length);
|
||||
exchange.getResponseBody().write(bytes);
|
||||
exchange.close();
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import home.backend.domain.BatteryResult;
|
||||
import home.backend.domain.BatteryView;
|
||||
import home.backend.domain.Command;
|
||||
import home.backend.domain.CommandResult.Status;
|
||||
import home.backend.domain.RefreshResult;
|
||||
import home.backend.domain.RestoreResult.Outcome;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -65,7 +66,7 @@ class GatewayHttpCommandsTest {
|
||||
}
|
||||
|
||||
private void answerCommands() {
|
||||
for (String path : List.of("/commands", "/battery")) {
|
||||
for (String path : List.of("/commands", "/battery", "/refresh")) {
|
||||
server.createContext(path, this::answer);
|
||||
}
|
||||
}
|
||||
@@ -225,6 +226,29 @@ class GatewayHttpCommandsTest {
|
||||
assertEquals(seenBefore, seen.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshPostsToRefreshWithTheTokenAndParsesTheAnswer() throws Exception {
|
||||
serve(200, Files.readString(Path.of("src/test/resources/api/refresh.json")));
|
||||
|
||||
var result = commands("s3cret-token").refresh();
|
||||
|
||||
assertEquals("POST", seen.getFirst().method());
|
||||
assertEquals("/refresh", seen.getFirst().path());
|
||||
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
|
||||
assertEquals(List.of("deye", "house-meter"), result.requested());
|
||||
assertEquals(new RefreshResult.Skipped("heat-pump", 41), result.skipped().getFirst());
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshFailsOnAnyOtherStatusOrWithoutToken() {
|
||||
serve(401, "{\"error\":\"x\"}");
|
||||
assertThrows(IOException.class, () -> commands("s3cret-token").refresh());
|
||||
|
||||
var seenBefore = seen.size();
|
||||
assertThrows(IOException.class, () -> commands(null).refresh());
|
||||
assertEquals(seenBefore, seen.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsTheBatteryReserveWithTheToken() throws Exception {
|
||||
serve(200, contract("battery-reserve.json"));
|
||||
|
||||
@@ -7,6 +7,7 @@ import home.backend.application.AuditService;
|
||||
import home.backend.application.BatteryService;
|
||||
import home.backend.application.EnergyDetailsService;
|
||||
import home.backend.application.EnergyService;
|
||||
import home.backend.adapters.auth.Authentication;
|
||||
import home.backend.adapters.config.PresetFile;
|
||||
import home.backend.application.GatewayClient;
|
||||
import home.backend.application.GatewayBattery;
|
||||
@@ -18,6 +19,7 @@ import home.backend.domain.BatteryView.Reserve;
|
||||
import home.backend.domain.Command;
|
||||
import home.backend.domain.CommandResult;
|
||||
import home.backend.domain.CommandResult.Status;
|
||||
import home.backend.domain.RefreshResult;
|
||||
import home.backend.domain.RestoreResult;
|
||||
import home.backend.domain.RestoreResult.Outcome;
|
||||
import home.backend.domain.Restore;
|
||||
@@ -80,6 +82,7 @@ class ApiServerTest {
|
||||
private final List<Command> sent = new ArrayList<>();
|
||||
private Status verdict;
|
||||
private Outcome restoreOutcome;
|
||||
private int refreshes;
|
||||
private final List<String> restored = new ArrayList<>();
|
||||
private boolean commandsDown;
|
||||
private boolean batteryDown;
|
||||
@@ -204,7 +207,15 @@ class ApiServerTest {
|
||||
new BatteryService(gateway, batteryCommands, clock),
|
||||
new AuditService(auditRows(), clock), new StatsService(samples, clock),
|
||||
new WeatherService(() -> exampleForecast(), () -> List.of(exampleWarning()),
|
||||
(from, to) -> exampleMeasuredPv(), clock));
|
||||
(from, to) -> exampleMeasuredPv(), clock),
|
||||
() -> {
|
||||
if (commandsDown) {
|
||||
throw new IOException("tunnel down");
|
||||
}
|
||||
refreshes++;
|
||||
return new RefreshResult(List.of("deye", "house-meter"),
|
||||
List.of(new RefreshResult.Skipped("heat-pump", 41)));
|
||||
}, Authentication.disabled());
|
||||
server.start();
|
||||
}
|
||||
|
||||
@@ -379,13 +390,12 @@ class ApiServerTest {
|
||||
|
||||
@Test
|
||||
void scheduleChangeGoesToTheGatewayWithTheUserAndAnswersWithTheVerdict() throws Exception {
|
||||
var response = post("/api/battery/schedule", example("battery-schedule-request").toString(),
|
||||
"Remote-User", "anna");
|
||||
var response = post("/api/battery/schedule", example("battery-schedule-request").toString());
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals(List.of("schedule " + new BatteryChange(true, List.of(
|
||||
new BatteryChange.Slot(2, java.time.LocalTime.of(9, 30), 90, true),
|
||||
new BatteryChange.Slot(3, null, 85, null))) + " by anna"), batterySent);
|
||||
new BatteryChange.Slot(3, null, 85, null))) + " by phone"), batterySent);
|
||||
assertEquals(JSON.readTree("{\"status\":\"APPLIED\",\"message\":\"applied\",\"read\":{\"167\":90}}"),
|
||||
JSON.readTree(response.body()));
|
||||
}
|
||||
@@ -632,11 +642,11 @@ class ApiServerTest {
|
||||
|
||||
@Test
|
||||
void restoreMatchesExampleAndAsksForTheUsersSetpoint() throws Exception {
|
||||
HttpResponse<String> response = post("/api/setpoints/cwu_economy/restore", "{}", "Remote-User", "anna");
|
||||
HttpResponse<String> response = post("/api/setpoints/cwu_economy/restore", "{}");
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals(example("setpoint-restored"), JSON.readTree(response.body()));
|
||||
assertEquals(List.of("cwu_economy by anna"), restored);
|
||||
assertEquals(List.of("cwu_economy by phone"), restored);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -667,6 +677,32 @@ class ApiServerTest {
|
||||
assertTrue(restored.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshPassesTheGatewaysAnswerThrough() throws Exception {
|
||||
var response = post("/api/refresh", "{}");
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals(example("refresh"), JSON.readTree(response.body()));
|
||||
assertEquals(1, refreshes);
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshIs503WhenTheGatewayCannotBeReached() throws Exception {
|
||||
commandsDown = true;
|
||||
|
||||
assertEquals(503, post("/api/refresh", "{}").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshIsGuardedLikeEveryWrite() throws Exception {
|
||||
String path = "/api/refresh";
|
||||
assertEquals(415, rawPost(path, "{}", "X-Requested-With", "home-pump").statusCode());
|
||||
assertEquals(403, rawPost(path, "{}", "Content-Type", "application/json").statusCode());
|
||||
assertEquals(403, post(path, "{}", "Origin", "https://evil.example").statusCode());
|
||||
assertEquals(405, get(path).statusCode());
|
||||
assertEquals(0, refreshes);
|
||||
}
|
||||
|
||||
@Test
|
||||
void otherPathsUnderSetpointsAre404() throws Exception {
|
||||
for (String path : List.of("/api/setpoints/", "/api/setpoints/cwu_economy",
|
||||
@@ -694,12 +730,10 @@ class ApiServerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardedUserIsPassedOnAndOddNamesFallBack() throws Exception {
|
||||
post("/api/setpoints", BOOST, "Remote-User", "anna.k@example.pl");
|
||||
post("/api/setpoints", BOOST, "Remote-User", "anna smith");
|
||||
post("/api/setpoints", BOOST);
|
||||
void aRemoteUserHeaderIsNotTrusted() throws Exception {
|
||||
post("/api/setpoints", BOOST, "Remote-User", "anna");
|
||||
|
||||
assertEquals(List.of("anna.k@example.pl", "phone", "phone"), sent.stream().map(Command::requestedBy).toList());
|
||||
assertEquals(List.of("phone"), sent.stream().map(Command::requestedBy).toList());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -54,7 +54,7 @@ class AuditServiceTest {
|
||||
|
||||
var entries = service.audit(null).entries();
|
||||
|
||||
assertEquals(List.of("CWU eko", "Rezerwa baterii", "CO komfort"),
|
||||
assertEquals(List.of("Ciepła woda – oszczędnie", "Rezerwa baterii", "Ogrzewanie – komfort"),
|
||||
entries.stream().map(AuditEntry::what).toList());
|
||||
assertEquals(List.of("heat_pump", "battery", "heat_pump"),
|
||||
entries.stream().map(AuditEntry::source).toList());
|
||||
|
||||
@@ -279,6 +279,14 @@ class ServicesTest {
|
||||
assertEquals(new BatteryView.Slot("21:00", "01:00", 8000, 60, false), schedule.get(5));
|
||||
}
|
||||
|
||||
@Test
|
||||
void batteryScheduleIsEmptyWhenASlotStartIsMissing() throws IOException {
|
||||
Map<String, Double> fields = settings(1);
|
||||
fields.remove("prog3_start_hhmm");
|
||||
|
||||
assertTrue(battery(deyeSettings(fields, READ)).schedule().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void batteryModeFollowsTimeOfUse() throws IOException {
|
||||
BatteryView backup = battery(deyeSettings(settings(0), READ));
|
||||
|
||||
@@ -55,6 +55,7 @@ class WeatherServiceTest {
|
||||
private boolean warningsDown;
|
||||
private boolean influxDown;
|
||||
private int measuredDays = 5;
|
||||
private LocalDate today = TODAY;
|
||||
private List<Warning> warned = List.of(warning(START.plus(Duration.ofHours(8))));
|
||||
|
||||
private final WeatherService service = new WeatherService(
|
||||
@@ -76,24 +77,24 @@ class WeatherServiceTest {
|
||||
if (influxDown) {
|
||||
throw new IOException("influx down");
|
||||
}
|
||||
assertEquals(TODAY.minusDays(14), from);
|
||||
assertEquals(TODAY, to);
|
||||
assertEquals(today.minusDays(14), from);
|
||||
assertEquals(today, to);
|
||||
Map<LocalDate, Double> measured = new HashMap<>();
|
||||
for (int i = 1; i <= measuredDays; i++) {
|
||||
measured.put(TODAY.minusDays(i), 15.0);
|
||||
measured.put(today.minusDays(i), 15.0);
|
||||
}
|
||||
return measured;
|
||||
},
|
||||
clock);
|
||||
|
||||
private static Forecast forecast() {
|
||||
private Forecast forecast() {
|
||||
Map<LocalDate, DailyForecast> days = new TreeMap<>();
|
||||
for (int i = 1; i <= 14; i++) {
|
||||
days.put(TODAY.minusDays(i), new DailyForecast(3, 10.0, 2.0, 0.0, 5.0, 3600.0));
|
||||
days.put(today.minusDays(i), new DailyForecast(3, 10.0, 2.0, 0.0, 5.0, 3600.0));
|
||||
}
|
||||
days.put(TODAY, new DailyForecast(0, 12.34, 3.0, 0.0, 6.0, 5400.0));
|
||||
days.put(TODAY.plusDays(1), new DailyForecast(63, 9.0, 4.0, 5.5, null, null));
|
||||
days.put(TODAY.plusDays(2), new DailyForecast(null, null, null, null, 1.0, 0.0));
|
||||
days.put(today, new DailyForecast(0, 12.34, 3.0, 0.0, 6.0, 5400.0));
|
||||
days.put(today.plusDays(1), new DailyForecast(63, 9.0, 4.0, 5.5, null, null));
|
||||
days.put(today.plusDays(2), new DailyForecast(null, null, null, null, 1.0, 0.0));
|
||||
return new Forecast(8.5, days);
|
||||
}
|
||||
|
||||
@@ -130,6 +131,48 @@ class WeatherServiceTest {
|
||||
assertEquals(TODAY, service.current().today().date());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aForecastFetchedBeforeLocalMidnightIsNotUsedAfterIt() throws IOException {
|
||||
now = Instant.parse("2026-10-24T21:50:00Z");
|
||||
service.current();
|
||||
|
||||
now = Instant.parse("2026-10-24T22:05:00Z");
|
||||
today = TODAY.plusDays(1);
|
||||
WeatherView view = service.current();
|
||||
|
||||
assertEquals(2, forecastCalls.get());
|
||||
assertEquals(today, view.today().date());
|
||||
assertEquals(today.plusDays(2), view.dayAfter().date());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aFailedRefreshServesTheOlderWarningsWithoutAskingAgainFor60Seconds() throws IOException {
|
||||
service.current();
|
||||
warningsDown = true;
|
||||
now = START.plus(Duration.ofMinutes(11));
|
||||
service.current();
|
||||
assertEquals(2, warningCalls.get());
|
||||
|
||||
now = now.plusSeconds(59);
|
||||
assertTrue(service.current().stale());
|
||||
assertEquals(2, warningCalls.get());
|
||||
|
||||
now = now.plusSeconds(1);
|
||||
service.current();
|
||||
assertEquals(3, warningCalls.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void withoutAnyForecastItFailsFastDuringTheBackOff() {
|
||||
forecastDown = true;
|
||||
assertThrows(IOException.class, service::current);
|
||||
|
||||
now = START.plusSeconds(30);
|
||||
assertThrows(IOException.class, service::current);
|
||||
|
||||
assertEquals(1, forecastCalls.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fewerThanFiveMeasuredDaysGiveNoEstimate() throws IOException {
|
||||
measuredDays = 4;
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
"entries": [
|
||||
{ "time": "2026-10-24T09:30:00Z", "source": "battery", "what": "Rezerwa baterii", "oldValue": 50, "newValue": 60,
|
||||
"unit": "%", "who": "mariusz", "status": "APPLIED" },
|
||||
{ "time": "2026-10-24T08:00:00Z", "source": "heat_pump", "what": "CWU eko", "oldValue": 45, "newValue": 8,
|
||||
{ "time": "2026-10-24T08:00:00Z", "source": "heat_pump", "what": "Ciepła woda – oszczędnie", "oldValue": 45, "newValue": 8,
|
||||
"unit": "°C", "who": "automatycznie (przywrócenie)", "status": "APPLIED" },
|
||||
{ "time": "2026-10-23T18:00:00Z", "source": "battery", "what": "Przedział 3: ładowanie z sieci", "oldValue": 0,
|
||||
"newValue": 1, "unit": "0/1", "who": "mariusz", "status": "FAILED" },
|
||||
{ "time": "2026-10-23T07:00:00Z", "source": "heat_pump", "what": "CO komfort", "oldValue": null, "newValue": 22,
|
||||
{ "time": "2026-10-23T07:00:00Z", "source": "heat_pump", "what": "Ogrzewanie – komfort", "oldValue": null, "newValue": 22,
|
||||
"unit": "°C", "who": "mariusz", "status": "REJECTED" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{ "local": true, "oidc": true, "setup": false }
|
||||
@@ -0,0 +1 @@
|
||||
{ "user": "tata", "method": "local" }
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"requested": ["deye", "house-meter"],
|
||||
"skipped": [{ "device": "heat-pump", "retryInSeconds": 41 }]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
# ADR-014: Sign-in in the app: local accounts and Keycloak, independent
|
||||
|
||||
Status: accepted · 2026-10-24
|
||||
|
||||
## Context
|
||||
|
||||
ADR-009 left the UI without a login, admitted by Traefik's address whitelist. The app is to be
|
||||
used away from home, and writes to the heat pump and the battery are on. Keycloak runs in Kraków
|
||||
but may be down, and then my parents must still get in.
|
||||
|
||||
## Decision
|
||||
|
||||
- The backend authenticates itself; it no longer trusts `Remote-User` (the header handling and
|
||||
the Traefik middleware that cleared it are dropped). The whitelist of ADR-009 stays in front for now.
|
||||
- Two independent ways in, either or both configured:
|
||||
- Local accounts: `USERS_FILE`, PBKDF2-SHA256 hashes (at least 310000 iterations, 600000 by
|
||||
default), managed in the app by any signed-in user; the backend rewrites the file atomically. Five failures in 15 minutes block the username and
|
||||
the client address.
|
||||
- OIDC with Keycloak: authorization code with PKCE, state and nonce, a confidential client, the
|
||||
ID token verified in the backend with the JDK. Discovery and keys are fetched lazily, so
|
||||
Keycloak being down never stops the start or local login.
|
||||
- With `USERS_FILE` set but no account in it the app is in setup mode: the first visitor creates the
|
||||
first account. Accepted because Traefik admits only the two home networks.
|
||||
- A stateless session cookie signed with `SESSION_SECRET` (30 days, HttpOnly, Secure, SameSite=Lax).
|
||||
- No silent open mode: without either configured the backend refuses to start unless
|
||||
`AUTH_DISABLED=true`. `/api/live` and `/api/auth/*` are the only open paths.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Supersedes the "no login" part of ADR-009; its network rules and "no published port" stay.
|
||||
- Sessions cannot be revoked one by one; changing `SESSION_SECRET` signs everyone out.
|
||||
- Auditing names the real user, whichever way they came in.
|
||||
- Operating it needs a Keycloak client and a users file next to the stack, outside the image.
|
||||
+3
-2
@@ -75,7 +75,8 @@ Functional:
|
||||
|
||||
- F1 Live state of the heat pump: idle / heating CWU / heating CO, compressor stage, pumps,
|
||||
heater, alarm, current targets and temperatures; the BME280 reading (temperature, humidity,
|
||||
pressure) of the living room.
|
||||
pressure) of the living room. An "Odśwież" button asks for one extra poll of each device, at most once
|
||||
a minute per device (the MK01 takes one client).
|
||||
- F2 History for the last 7 days, and per-day heat pump statistics for 7 or 30 days (compressor, CWU, CO and heater hours,
|
||||
starts "at least" because polls are 5 minutes apart).
|
||||
- F3 Change four setpoints: economy and comfort temperature of CWU (4143, 4144) and CO
|
||||
@@ -110,7 +111,7 @@ Safety, for every write:
|
||||
|
||||
Non-functional:
|
||||
|
||||
- N1 Reachable from outside only through Traefik with authentication.
|
||||
- N1 Reachable from outside only through Traefik (Krosno addresses), and behind the app's own sign-in: local accounts and Keycloak, independent ([ADR-014](decisions/014-login.md)).
|
||||
- N2 Recovers on its own from device, InfluxDB and tunnel outages.
|
||||
- N3 Existing Grafana dashboards keep working.
|
||||
- N4 Usable by my parents: large controls, few screens, Polish UI.
|
||||
+17
-1
@@ -20,17 +20,33 @@ and gateway's versions in the "Wersje" card of the Stan screen.
|
||||
The Energia screen has three views, kept in the URL hash so Back works: `#energy` (Teraz), `#energy/battery` (Bateria, also
|
||||
opened by tapping the battery in the flow diagram) and `#energy/inverters` (Falowniki, the device details).
|
||||
|
||||
Every screen title (`PageTitle`) has the "Odśwież" button (`Refresh.tsx`) beside it, with its status text below: it posts `api/refresh`, waits 5 s for the gateway's extra poll, then
|
||||
`refreshAll()` (`poll.ts`) fetches every poller that is on screen again. When the gateway skipped every device (polled within a minute) it says so and
|
||||
waits out the shortest cooldown.
|
||||
|
||||
Ustawienia ends with the "Historia zmian" card (`ChangeHistory.tsx`): the last 7 days of `api/audit`, 20 rows and "Pokaż więcej".
|
||||
|
||||
In the Bateria view, `editable` from `api/battery` (the gateway takes battery changes, ADR-013) adds the reserve stepper and "Edytuj" for the
|
||||
schedule: the user confirms a summary, only changed fields are sent to `api/battery/schedule` and `api/battery/reserve`, and the UI
|
||||
checks the gateway's rules first (levels from the reserve to 100 %, starts increasing).
|
||||
|
||||
Podgląd shows the "Pogoda" card (3 days, PV estimate) and one banner per IMGW warning from `api/weather`; Bateria hints to keep the battery full on a storm or strong-wind warning (a hint only, nothing changes by itself).
|
||||
Podgląd opens with the alarm and one collapsible line per distinct IMGW warning, then four key figures (CWU, home, outdoor, power), the schematic with the device rows, the "Pogoda" card (3 days, PV estimate, from `api/weather`) and "Szczegóły temperatur"; Bateria hints to keep the battery full on a storm or strong-wind warning (a hint only, nothing changes by itself).
|
||||
|
||||
Historia has two views in the hash, like Energia: `#history` (Wykresy) and `#history/stats` (Statystyki: totals and a daily chart of
|
||||
compressor hours from `api/stats`, 7 or 30 days). `SubView.tsx` holds the shared tab switch.
|
||||
|
||||
## Login
|
||||
|
||||
`AuthGate` (`Auth.tsx`) asks `api/auth/me` first: 200 shows the app, 401 the login screen, any other failure "Brak połączenia z
|
||||
serwerem" with a retry. The screen has the login and password form (always the way in when Keycloak is down) and, when
|
||||
`api/auth/config` says `oidc`, "Zaloguj przez Keycloak" (a full page visit to `api/auth/oidc/start`; its failures return as
|
||||
`#login?error=…`). If the config cannot be read the form still shows. A 401 from any poller or write (`api.ts` reports it)
|
||||
ends the session: the app unmounts, so every poller stops, and the login screen says "Sesja wygasła"; the hash is kept, so
|
||||
after login the same screen opens. "Wyloguj" is in Stan and the side panel.
|
||||
|
||||
When `api/auth/config` has `setup`, the form is "Utwórz pierwsze konto" (`api/auth/setup`; a 409 falls back to the login form).
|
||||
The "Konta" card at the end of Ustawienia (`Accounts.tsx`, only with a login of our own) adds and deletes accounts and changes the own password.
|
||||
|
||||
## Install on a phone (PWA)
|
||||
|
||||
The build has a manifest (`public/`), icons and a service worker (`src/sw.js`, written to `dist/sw.js` with the list of built
|
||||
|
||||
@@ -20,10 +20,9 @@ services:
|
||||
traefik.http.routers.home-pump.tls.certresolver: http
|
||||
traefik.http.routers.home-pump.service: home-pump
|
||||
traefik.http.services.home-pump.loadbalancer.server.port: "8080"
|
||||
# ADR-009: only Krosno may open it, and no client may name the user who changes a setpoint.
|
||||
# ADR-009: only Krosno may open it; the backend signs users in itself (ADR-014).
|
||||
traefik.http.middlewares.home-pump-krosno.ipwhitelist.sourcerange: ${KROSNO_SOURCES:?Krosno's address or range}
|
||||
traefik.http.middlewares.home-pump-no-user.headers.customrequestheaders.Remote-User: ""
|
||||
traefik.http.routers.home-pump.middlewares: home-pump-krosno,home-pump-no-user
|
||||
traefik.http.routers.home-pump.middlewares: home-pump-krosno
|
||||
|
||||
networks:
|
||||
home-pump:
|
||||
|
||||
@@ -20,6 +20,8 @@ server {
|
||||
|
||||
location /api/ {
|
||||
proxy_pass ${BACKEND_URL};
|
||||
# Do not set X-Forwarded-For here (no $proxy_add_x_forwarded_for): the backend's login throttle
|
||||
# takes the entry Traefik added, TRUSTED_PROXY_HOPS=1, and nginx passes the header on unchanged.
|
||||
# With the port: the backend compares Origin with Host before a write.
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_read_timeout 30s;
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { answer } from "./fixtures";
|
||||
|
||||
type Handler = (body: any) => Promise<Response>;
|
||||
const bodyOf = (init?: RequestInit) => (init?.body ? JSON.parse(String(init.body)) : undefined);
|
||||
const LONG = "dlugie-haslo-1";
|
||||
const empty = () => Promise.resolve(new Response(null, { status: 204 }));
|
||||
|
||||
// A logged-in user and a list of accounts; POSTs go to the handlers by URL.
|
||||
async function open(method: string, users: string[], posts: Record<string, Handler> = {}) {
|
||||
const fetch = vi.fn((url: string, init?: RequestInit) => {
|
||||
if (init?.method === "POST") return (posts[url] ?? (() => answer({}, 404)))(bodyOf(init));
|
||||
if (url === "api/auth/me") return answer({ user: "mariusz", method });
|
||||
if (url === "api/auth/users") return answer(users.map((username) => ({ username })));
|
||||
return answer({}, 404);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
await mount();
|
||||
return fetch;
|
||||
}
|
||||
async function mount() {
|
||||
const { AuthGate } = await import("./Auth");
|
||||
const { Accounts } = await import("./Accounts");
|
||||
render(<AuthGate><Accounts /></AuthGate>);
|
||||
}
|
||||
const type = (label: string, value: string) => fireEvent.change(screen.getByLabelText(label), { target: { value } });
|
||||
const click = (name: string) => fireEvent.click(screen.getByRole("button", { name }));
|
||||
const fillNew = (name: string, password: string, repeat = password) => {
|
||||
click("Dodaj konto");
|
||||
type("Login", name);
|
||||
type("Hasło", password);
|
||||
type("Powtórz hasło", repeat);
|
||||
click("Zapisz");
|
||||
};
|
||||
const changePassword = () => {
|
||||
click("Zmień moje hasło");
|
||||
type("Obecne hasło", "stare-haslo-1");
|
||||
type("Nowe hasło", "nowe-haslo-123");
|
||||
type("Powtórz hasło", "nowe-haslo-123");
|
||||
click("Zapisz");
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("matchMedia", () => ({ matches: false, addEventListener() {}, removeEventListener() {} }));
|
||||
});
|
||||
|
||||
describe("Accounts", () => {
|
||||
it("lists the accounts and marks the own one", async () => {
|
||||
await open("local", ["mariusz", "ola"]);
|
||||
|
||||
expect(await screen.findByText("ola")).toBeInTheDocument();
|
||||
expect(screen.getByText("(Ty)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("is not shown on a server without login", async () => {
|
||||
const fetch = await open("none", []);
|
||||
|
||||
await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith("api/auth/me", expect.anything()));
|
||||
expect(screen.queryByText("Konta")).toBeNull();
|
||||
expect(fetch.mock.calls.map(([url]) => url)).not.toContain("api/auth/users");
|
||||
});
|
||||
|
||||
it("adds an account and lists the accounts again", async () => {
|
||||
const post = vi.fn(() => answer({ username: "ola" }));
|
||||
const fetch = await open("local", ["mariusz"], { "api/auth/users": post });
|
||||
await screen.findByText("mariusz");
|
||||
|
||||
fillNew("ola", "dlugie-haslo-1");
|
||||
|
||||
expect(await screen.findByText("Dodano konto")).toBeInTheDocument();
|
||||
expect(post).toHaveBeenCalledWith({ username: "ola", password: LONG });
|
||||
expect(fetch.mock.calls.filter(([url, init]) => url === "api/auth/users" && init?.method !== "POST")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["exists", () => answer({ error: "exists" }, 409), "Takie konto już istnieje"],
|
||||
["weak", () => answer({ error: "weak_password" }, 400), "Hasło jest za słabe (co najmniej 10 znaków)"],
|
||||
])("says so when adding fails: %s", async (_name, reply, text) => {
|
||||
await open("local", ["mariusz"], { "api/auth/users": reply });
|
||||
await screen.findByText("mariusz");
|
||||
|
||||
fillNew("ola", "dlugie-haslo-1");
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(text);
|
||||
});
|
||||
|
||||
it("checks the repeated password before sending", async () => {
|
||||
const post = vi.fn(() => answer({}));
|
||||
await open("local", ["mariusz"], { "api/auth/users": post });
|
||||
await screen.findByText("mariusz");
|
||||
|
||||
fillNew("ola", "dlugie-haslo-1", "inne-haslo-123");
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Hasła nie są takie same");
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("changes the own password", async () => {
|
||||
const post = vi.fn(empty);
|
||||
await open("local", ["mariusz"], { "api/auth/password": post });
|
||||
await screen.findByText("mariusz");
|
||||
|
||||
changePassword();
|
||||
|
||||
expect(await screen.findByText("Hasło zmienione")).toBeInTheDocument();
|
||||
expect(post).toHaveBeenCalledWith({ current: "stare-haslo-1", new: "nowe-haslo-123" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["401", () => answer({ error: "invalid" }, 401), "Nieprawidłowe obecne hasło"],
|
||||
["400", () => answer({ error: "weak_password" }, 400), "Hasło jest za słabe (co najmniej 10 znaków)"],
|
||||
["429", () => answer({ error: "throttled", retryAfterSeconds: 120 }, 429), "Za dużo prób – spróbuj za 2 min"],
|
||||
])("says so when the password change fails: %s", async (_name, reply, text) => {
|
||||
await open("local", ["mariusz"], { "api/auth/password": reply });
|
||||
await screen.findByText("mariusz");
|
||||
|
||||
changePassword();
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(text);
|
||||
});
|
||||
|
||||
it("offers no password change to a Keycloak user", async () => {
|
||||
await open("oidc", ["mariusz"]);
|
||||
await screen.findByText("mariusz");
|
||||
|
||||
expect(screen.getByRole("button", { name: "Dodaj konto" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Zmień moje hasło" })).toBeNull();
|
||||
});
|
||||
|
||||
it("deletes only after the confirmation", async () => {
|
||||
const post = vi.fn(empty);
|
||||
await open("local", ["mariusz", "ola"], { "api/auth/users/ola/delete": post });
|
||||
await screen.findByText("ola");
|
||||
|
||||
click("Usuń ola");
|
||||
expect(screen.getByText("Usunąć konto ola?")).toBeInTheDocument();
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
click("Anuluj");
|
||||
expect(screen.queryByText("Usunąć konto ola?")).toBeNull();
|
||||
|
||||
click("Usuń ola");
|
||||
click("Tak, usuń");
|
||||
|
||||
expect(await screen.findByText("Usunięto konto ola")).toBeInTheDocument();
|
||||
expect(post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not delete the last account", async () => {
|
||||
await open("local", ["mariusz"]);
|
||||
await screen.findByText("mariusz");
|
||||
|
||||
expect(screen.getByRole("button", { name: "Usuń mariusz" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("says so when the server refuses to delete the last account", async () => {
|
||||
await open("local", ["mariusz", "ola"], { "api/auth/users/ola/delete": () => answer({ error: "last_user" }, 409) });
|
||||
await screen.findByText("ola");
|
||||
|
||||
click("Usuń ola");
|
||||
click("Tak, usuń");
|
||||
|
||||
expect(await screen.findByText("Nie można usunąć ostatniego konta")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("returns to the login screen when the own account is deleted", async () => {
|
||||
let deleted = false;
|
||||
vi.stubGlobal("fetch", vi.fn((url: string, init?: RequestInit) => {
|
||||
if (init?.method === "POST") {
|
||||
deleted = true;
|
||||
return empty();
|
||||
}
|
||||
if (url === "api/auth/me") return answer({ user: "mariusz", method: "local" });
|
||||
if (url === "api/auth/users") return deleted ? answer({ error: "unauthenticated" }, 401) : answer([{ username: "mariusz" }, { username: "ola" }]);
|
||||
return answer({ local: true, oidc: false });
|
||||
}));
|
||||
await mount();
|
||||
await screen.findByText("ola");
|
||||
|
||||
click("Usuń mariusz");
|
||||
click("Tak, usuń");
|
||||
|
||||
expect(await screen.findByText("Sesja wygasła – zaloguj się ponownie")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useCallback, useEffect, useState, type FormEvent } from "react";
|
||||
import { getJson, postAuth } from "./api";
|
||||
import { accountFailure, BUTTON, FIELD, newPasswordProblem, useSession } from "./Auth";
|
||||
import { Card } from "./ui";
|
||||
|
||||
type Form = "add" | "password" | null;
|
||||
|
||||
function Field({ label, value, onChange, auto, type = "password" }: {
|
||||
label: string; value: string; onChange: (value: string) => void; auto: string; type?: string;
|
||||
}) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1 block font-semibold">{label}</span>
|
||||
<input value={value} onChange={(e) => onChange(e.target.value)} type={type} autoComplete={auto} required
|
||||
autoCapitalize="none" spellCheck={false} className={FIELD} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// Sends the fields once the new password passes the checks; says what went wrong, or closes the form.
|
||||
function AccountForm({ title, fields, send, onDone }: {
|
||||
title: string; fields: "add" | "password";
|
||||
send: (values: Record<string, string>) => Promise<{ status: number; body: any }>; onDone: () => void;
|
||||
}) {
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const set = (key: string) => (value: string) => setValues((now) => ({ ...now, [key]: value }));
|
||||
const secret = (fields === "add" ? values.password : values.new) ?? "";
|
||||
|
||||
const submit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const problem = newPasswordProblem(secret, values.repeat ?? "");
|
||||
if (problem) return setError(problem);
|
||||
setSending(true);
|
||||
setError("");
|
||||
const { status, body } = await send(values);
|
||||
setSending(false);
|
||||
if (status === 200 || status === 204) return onDone();
|
||||
setError(status === 401 ? "Nieprawidłowe obecne hasło" : accountFailure(status, body));
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="mt-3 space-y-3 border-t border-edge pt-3">
|
||||
<h3 className="font-semibold">{title}</h3>
|
||||
{fields === "add" ? (
|
||||
<>
|
||||
<Field label="Login" value={values.username ?? ""} onChange={set("username")} auto="off" type="text" />
|
||||
<Field label="Hasło" value={values.password ?? ""} onChange={set("password")} auto="new-password" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Field label="Obecne hasło" value={values.current ?? ""} onChange={set("current")} auto="current-password" />
|
||||
<Field label="Nowe hasło" value={values.new ?? ""} onChange={set("new")} auto="new-password" />
|
||||
</>
|
||||
)}
|
||||
<Field label="Powtórz hasło" value={values.repeat ?? ""} onChange={set("repeat")} auto="new-password" />
|
||||
{error && <p role="alert" className="font-semibold text-alarm">{error}</p>}
|
||||
<button type="submit" disabled={sending} className={`${BUTTON} bg-brand text-white dark:text-side`}>Zapisz</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
const SMALL = "min-h-11 rounded-xl border-2 border-brand px-3 font-bold text-brand disabled:opacity-40";
|
||||
|
||||
/** The accounts that may log in: add, change the own password, delete. Nothing without a login of our own. */
|
||||
export function Accounts() {
|
||||
const session = useSession();
|
||||
const [users, setUsers] = useState<string[]>();
|
||||
const [form, setForm] = useState<Form>(null);
|
||||
const [confirming, setConfirming] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState("");
|
||||
const managed = session != null && session.method !== "none";
|
||||
const load = useCallback(() => {
|
||||
getJson<{ username: string }[]>("api/auth/users").then(
|
||||
(list) => setUsers(list.map((u) => u.username)), () => setMessage("Nie udało się pobrać kont"));
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (managed) load();
|
||||
}, [managed, load]);
|
||||
if (!managed) return null;
|
||||
|
||||
const done = (text: string) => () => {
|
||||
setForm(null);
|
||||
setMessage(text);
|
||||
load();
|
||||
};
|
||||
// Deleting yourself ends the session: the list request that follows gets 401 and the login screen shows.
|
||||
const remove = async (name: string) => {
|
||||
setConfirming(null);
|
||||
const { status, body } = await postAuth(`api/auth/users/${encodeURIComponent(name)}/delete`);
|
||||
if (status === 204) return done(`Usunięto konto ${name}`)();
|
||||
setMessage(body?.error === "last_user" ? "Nie można usunąć ostatniego konta" : accountFailure(status, body));
|
||||
};
|
||||
const toggle = (next: Form) => setForm(form === next ? null : next);
|
||||
|
||||
return (
|
||||
<Card title="Konta" className="mt-4">
|
||||
{users?.map((name) => (
|
||||
<div key={name} className="flex items-center justify-between gap-3 border-t border-edge py-2 first:border-t-0">
|
||||
<span>{name}{name === session.user && <span className="text-soft"> (Ty)</span>}</span>
|
||||
{confirming === name ? (
|
||||
<span className="flex flex-wrap items-center justify-end gap-2">
|
||||
<span className="text-sm font-semibold">Usunąć konto {name}?</span>
|
||||
<button onClick={() => remove(name)} className={`${SMALL} !border-alarm bg-alarm !text-white`}>Tak, usuń</button>
|
||||
<button onClick={() => setConfirming(null)} className={SMALL}>Anuluj</button>
|
||||
</span>
|
||||
) : (
|
||||
<button onClick={() => setConfirming(name)} disabled={users.length < 2} aria-label={`Usuń ${name}`} className={SMALL}>Usuń</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{message && <p role="status" className="mt-2 text-sm font-semibold text-soft">{message}</p>}
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button onClick={() => toggle("add")} aria-pressed={form === "add"} className={SMALL}>Dodaj konto</button>
|
||||
{session.method === "local" && (
|
||||
<button onClick={() => toggle("password")} aria-pressed={form === "password"} className={SMALL}>Zmień moje hasło</button>
|
||||
)}
|
||||
</div>
|
||||
{form === "add" && (
|
||||
<AccountForm title="Nowe konto" fields="add" onDone={done("Dodano konto")}
|
||||
send={({ username, password }) => postAuth("api/auth/users", { username, password })} />
|
||||
)}
|
||||
{form === "password" && (
|
||||
<AccountForm title="Zmiana hasła" fields="password" onDone={done("Hasło zmienione")}
|
||||
send={({ current, new: next }) => postAuth("api/auth/password", { current, new: next })} />
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
+18
-10
@@ -1,4 +1,5 @@
|
||||
import { lazy, Suspense, useEffect, useState, type ComponentType } from "react";
|
||||
import { Account } from "./Auth";
|
||||
import { celsius, hour, longDate } from "./format";
|
||||
import { Icon, type IconName } from "./icons";
|
||||
import { Energy } from "./Energy";
|
||||
@@ -6,7 +7,8 @@ import { Overview } from "./Overview";
|
||||
import { usePoll } from "./poll";
|
||||
import { useTheme, type Theme } from "./theme";
|
||||
import type { HeatPump } from "./types";
|
||||
import { Dot, Loading } from "./ui";
|
||||
import { Dot, isStale, Loading } from "./ui";
|
||||
import { useWeather, WeatherIcon } from "./Weather";
|
||||
|
||||
// Energy shares code with the first screen; the others load when opened.
|
||||
const History = lazy(() => import("./History").then((m) => ({ default: m.History })));
|
||||
@@ -44,7 +46,7 @@ function ThemeSwitch({ className }: { className: string }) {
|
||||
<div role="group" aria-label="Motyw" className={`flex gap-1 rounded-xl p-1 text-sm ${className}`}>
|
||||
{Object.entries(THEMES).map(([id, label]) => (
|
||||
<button key={id} aria-pressed={id === theme} onClick={() => setTheme(id as Theme)}
|
||||
className="flex-1 rounded-lg px-2.5 py-1.5 font-semibold opacity-70 aria-pressed:bg-white aria-pressed:text-side aria-pressed:opacity-100">
|
||||
className="min-h-11 flex-1 rounded-lg px-2.5 font-semibold opacity-80 aria-pressed:bg-white aria-pressed:text-side aria-pressed:opacity-100">
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
@@ -56,10 +58,12 @@ function ThemeSwitch({ className }: { className: string }) {
|
||||
function SideFooter() {
|
||||
const { data, offline } = usePoll<HeatPump>("api/heat-pump");
|
||||
const now = useClock();
|
||||
const old = !offline && data?.time != null && isStale(data.time, data.stale);
|
||||
const weather = useWeather();
|
||||
return (
|
||||
<div className="mt-auto space-y-4 px-2">
|
||||
<div className="mt-auto space-y-3 border-t border-white/10 px-2 pt-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon name="sun" className="size-9 text-amber-400" />
|
||||
{weather && <WeatherIcon code={weather.today.weatherCode} className="size-9" />}
|
||||
<div>
|
||||
<p className="text-2xl font-semibold">{celsius(data?.temperatures.outdoor)}</p>
|
||||
<p className="text-sm text-white/60">Szczepańcowa</p>
|
||||
@@ -67,10 +71,11 @@ function SideFooter() {
|
||||
</div>
|
||||
<p className="text-sm text-white/80">{longDate(now)} · {hour(now)}</p>
|
||||
<ThemeSwitch className="bg-white/10" />
|
||||
<Account className="text-sm text-white/80" />
|
||||
<div className="flex items-center gap-3 rounded-xl border border-white/10 bg-white/5 p-3">
|
||||
<Dot tone={offline ? "bad" : "ok"} live={!offline} />
|
||||
<Dot tone={offline ? "bad" : old ? "warn" : "ok"} live={!offline && !old} />
|
||||
<div className="text-sm">
|
||||
<p className="font-semibold">{offline ? "Brak połączenia" : "System online"}</p>
|
||||
<p className="font-semibold">{offline ? "Brak połączenia" : old ? "Dane nieaktualne" : "System online"}</p>
|
||||
<p className="text-white/60">{data?.time ? `Ostatni odczyt: ${hour(new Date(data.time))}` : "Czekam na odczyt"}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -81,14 +86,17 @@ function SideFooter() {
|
||||
export function App() {
|
||||
const [screen, setScreen] = useState<ScreenId>(fromHash);
|
||||
useEffect(() => {
|
||||
const onHash = () => setScreen(fromHash());
|
||||
// A link to another screen, such as "Energia ›", arrives here too.
|
||||
const onHash = () => {
|
||||
setScreen(fromHash());
|
||||
scrollTo(0, 0);
|
||||
};
|
||||
addEventListener("hashchange", onHash);
|
||||
return () => removeEventListener("hashchange", onHash);
|
||||
}, []);
|
||||
const show = (id: ScreenId) => {
|
||||
location.hash = id;
|
||||
setScreen(id);
|
||||
scrollTo(0, 0);
|
||||
};
|
||||
const View = SCREENS[screen].view;
|
||||
|
||||
@@ -99,8 +107,8 @@ export function App() {
|
||||
<div className="mb-8 hidden items-center gap-3 px-2 lg:flex">
|
||||
<span className="grid size-10 place-items-center rounded-xl bg-brand/20 text-brand"><Icon name="leaf" className="size-6" /></span>
|
||||
<div>
|
||||
<p className="text-lg font-bold leading-tight">Dom Szczepańcowa</p>
|
||||
<p className="text-xs text-white/60">Pompa ciepła i energia</p>
|
||||
<p className="text-base font-bold leading-tight">Dom Szczepańcowa</p>
|
||||
<p className="text-xs text-white/70">Pompa ciepła i energia</p>
|
||||
</div>
|
||||
</div>
|
||||
{Object.entries(SCREENS).map(([id, s]) => (
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { answer, example } from "./fixtures";
|
||||
|
||||
const ME = { user: "mariusz", method: "local" };
|
||||
const LONG = "dlugie-haslo-1";
|
||||
const CONFIG = { local: true, oidc: true };
|
||||
|
||||
type Handler = (body: unknown) => Promise<Response>;
|
||||
// Answers by "METHOD url"; what is not listed is a 404. Replace entries to change the server's mind.
|
||||
function server(routes: Record<string, Handler | unknown>) {
|
||||
const fetch = vi.fn((url: string, init?: RequestInit) => {
|
||||
const route = routes[`${init?.method ?? "GET"} ${url}`];
|
||||
if (typeof route === "function") return (route as Handler)(init?.body ? JSON.parse(String(init.body)) : undefined);
|
||||
return route === undefined ? answer({}, 404) : answer(route);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
return fetch;
|
||||
}
|
||||
const unauthenticated = () => answer({ error: "unauthenticated" }, 401);
|
||||
|
||||
async function open(path = "") {
|
||||
history.replaceState(null, "", `/${path}`);
|
||||
const { AuthGate } = await import("./Auth");
|
||||
const { App } = await import("./App");
|
||||
render(<AuthGate><App /></AuthGate>);
|
||||
}
|
||||
|
||||
const login = (name = "mariusz", password = "tajne") => {
|
||||
fireEvent.change(screen.getByLabelText("Login"), { target: { value: name } });
|
||||
fireEvent.change(screen.getByLabelText("Hasło"), { target: { value: password } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Zaloguj" }));
|
||||
};
|
||||
const loginScreen = () => screen.findByRole("button", { name: "Zaloguj" });
|
||||
|
||||
beforeEach(() => {
|
||||
history.replaceState(null, "", "/");
|
||||
vi.stubGlobal("matchMedia", () => ({ matches: false, addEventListener() {}, removeEventListener() {} }));
|
||||
});
|
||||
|
||||
describe("login", () => {
|
||||
it("shows the login screen, not the app, without a session", async () => {
|
||||
const fetch = server({ "GET api/auth/me": unauthenticated, "GET api/auth/config": { local: true, oidc: false } });
|
||||
await open();
|
||||
|
||||
expect(await loginScreen()).toBeEnabled();
|
||||
expect(screen.getByLabelText("Login")).toHaveAttribute("autocomplete", "username");
|
||||
expect(screen.getByLabelText("Hasło")).toHaveAttribute("autocomplete", "current-password");
|
||||
expect(screen.queryByRole("navigation")).toBeNull();
|
||||
expect(screen.queryByText("Zaloguj przez Keycloak")).toBeNull();
|
||||
expect(fetch.mock.calls.map(([url]) => url).filter((url) => !url.startsWith("api/auth"))).toEqual([]);
|
||||
});
|
||||
|
||||
it("shows the app after a successful login, and sends the password once with the page header", async () => {
|
||||
const post = vi.fn(() => answer(ME));
|
||||
const fetch = server({ "GET api/auth/me": unauthenticated, "GET api/auth/config": CONFIG, "POST api/auth/login": post });
|
||||
await open();
|
||||
await loginScreen();
|
||||
|
||||
login();
|
||||
|
||||
expect(await screen.findByRole("navigation", { name: "Ekrany" })).toBeInTheDocument();
|
||||
expect(post).toHaveBeenCalledWith({ username: "mariusz", password: "tajne" });
|
||||
const init = fetch.mock.calls.find(([url]) => url === "api/auth/login")![1]!;
|
||||
expect(init.headers).toMatchObject({ "X-Requested-With": "home-pump" });
|
||||
});
|
||||
|
||||
it("lets the password be shown and submits with Enter", async () => {
|
||||
const post = vi.fn(() => answer(ME));
|
||||
server({ "GET api/auth/me": unauthenticated, "GET api/auth/config": CONFIG, "POST api/auth/login": post });
|
||||
await open();
|
||||
await loginScreen();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Pokaż" }));
|
||||
expect(screen.getByLabelText("Hasło")).toHaveAttribute("type", "text");
|
||||
fireEvent.change(screen.getByLabelText("Login"), { target: { value: "a" } });
|
||||
fireEvent.change(screen.getByLabelText("Hasło"), { target: { value: "b" } });
|
||||
fireEvent.submit(screen.getByLabelText("Hasło").closest("form")!);
|
||||
|
||||
await screen.findByRole("navigation", { name: "Ekrany" });
|
||||
expect(post).toHaveBeenCalledWith({ username: "a", password: "b" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a wrong password", () => answer({ error: "invalid" }, 401), "Nieprawidłowy login lub hasło"],
|
||||
["too many tries", () => answer({ error: "throttled", retryAfterSeconds: 130 }, 429), "Za dużo prób – spróbuj za 3 min"],
|
||||
["a lost network", () => Promise.reject(new TypeError("offline")), "Brak połączenia z serwerem"],
|
||||
])("says so on %s and lets the user try again", async (_name, reply, text) => {
|
||||
server({ "GET api/auth/me": unauthenticated, "GET api/auth/config": CONFIG, "POST api/auth/login": reply });
|
||||
await open();
|
||||
await loginScreen();
|
||||
|
||||
login();
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(text);
|
||||
expect(screen.getByRole("button", { name: "Zaloguj" })).toBeEnabled();
|
||||
expect(screen.queryByRole("navigation")).toBeNull();
|
||||
});
|
||||
|
||||
it("disables the button while the login is sent", async () => {
|
||||
server({ "GET api/auth/me": unauthenticated, "GET api/auth/config": CONFIG, "POST api/auth/login": () => new Promise(() => {}) });
|
||||
await open();
|
||||
await loginScreen();
|
||||
|
||||
login();
|
||||
|
||||
await vi.waitFor(() => expect(screen.getByRole("button", { name: "Zaloguj" })).toBeDisabled());
|
||||
});
|
||||
|
||||
it("offers Keycloak only when enabled, as a full page navigation", async () => {
|
||||
const assign = vi.fn();
|
||||
vi.stubGlobal("location", { ...location, assign, hash: "" });
|
||||
server({ "GET api/auth/me": unauthenticated, "GET api/auth/config": CONFIG });
|
||||
await open();
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Zaloguj przez Keycloak" }));
|
||||
|
||||
expect(screen.getByText("lub")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Login")).toBeInTheDocument();
|
||||
expect(assign).toHaveBeenCalledWith("api/auth/oidc/start");
|
||||
});
|
||||
|
||||
it("hides the form when local login is off", async () => {
|
||||
server({ "GET api/auth/me": unauthenticated, "GET api/auth/config": { local: false, oidc: true } });
|
||||
await open();
|
||||
|
||||
expect(await screen.findByRole("button", { name: "Zaloguj przez Keycloak" })).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Login")).toBeNull();
|
||||
expect(screen.queryByText("lub")).toBeNull();
|
||||
});
|
||||
|
||||
it("still shows the form when the config cannot be read", async () => {
|
||||
server({ "GET api/auth/me": unauthenticated });
|
||||
await open();
|
||||
|
||||
expect(await loginScreen()).toBeInTheDocument();
|
||||
expect(screen.queryByText("Zaloguj przez Keycloak")).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["oidc_unavailable", "Keycloak jest niedostępny – zaloguj się loginem i hasłem"],
|
||||
["oidc_denied", "Brak uprawnień do aplikacji"],
|
||||
["oidc_failed", "Logowanie przez Keycloak nie powiodło się"],
|
||||
])("explains %s from the address", async (code, text) => {
|
||||
server({ "GET api/auth/me": unauthenticated, "GET api/auth/config": CONFIG });
|
||||
await open(`#login?error=${code}`);
|
||||
|
||||
expect(await screen.findByLabelText("Login")).toBeInTheDocument();
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(text);
|
||||
});
|
||||
|
||||
it.each(["__proto__", "constructor", "toString"])("ignores the unknown error code %s", async (code) => {
|
||||
server({ "GET api/auth/me": unauthenticated, "GET api/auth/config": CONFIG });
|
||||
await open(`#login?error=${code}`);
|
||||
|
||||
expect(await screen.findByLabelText("Login")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
it("says local login is off when the server answers 404", async () => {
|
||||
server({ "GET api/auth/me": unauthenticated, "POST api/auth/login": () => answer({}, 404) });
|
||||
await open();
|
||||
await loginScreen();
|
||||
|
||||
login();
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Logowanie loginem i hasłem jest wyłączone");
|
||||
});
|
||||
|
||||
it("offers a reload instead of an empty screen when no way to log in is enabled", async () => {
|
||||
server({ "GET api/auth/me": unauthenticated, "GET api/auth/config": { local: false, oidc: false } });
|
||||
await open();
|
||||
|
||||
expect(await screen.findByRole("button", { name: "Wczytaj ponownie" })).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Login")).toBeNull();
|
||||
});
|
||||
|
||||
it("says there is no connection instead of showing the login when the server cannot be reached", async () => {
|
||||
server({ "GET api/auth/me": () => Promise.reject(new TypeError("offline")) });
|
||||
await open();
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Brak połączenia z serwerem");
|
||||
expect(screen.queryByLabelText("Login")).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Spróbuj ponownie" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("session", () => {
|
||||
const health = example("health");
|
||||
|
||||
it("returns to the login screen when a poller gets 401, and to the same screen after login", async () => {
|
||||
let expired = false;
|
||||
server({
|
||||
"GET api/auth/me": ME,
|
||||
"GET api/health": () => (expired ? unauthenticated() : answer(health)),
|
||||
"GET api/auth/config": CONFIG,
|
||||
"POST api/auth/login": ME,
|
||||
});
|
||||
await open("#status");
|
||||
expect(await screen.findByRole("heading", { name: "Stan" })).toBeInTheDocument();
|
||||
|
||||
expired = true;
|
||||
await act(async () => document.dispatchEvent(new Event("visibilitychange")));
|
||||
|
||||
expect(await screen.findByText("Sesja wygasła – zaloguj się ponownie")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("navigation")).toBeNull();
|
||||
expired = false;
|
||||
await loginScreen();
|
||||
login();
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Stan" })).toBeInTheDocument();
|
||||
expect(location.hash).toBe("#status");
|
||||
});
|
||||
|
||||
it("shows the app without account or logout when the server has no login", async () => {
|
||||
server({ "GET api/auth/me": { user: "phone", method: "none" }, "GET api/health": health });
|
||||
await open("#status");
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Stan" })).toBeInTheDocument();
|
||||
expect(screen.queryByText("Zalogowano:")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Wyloguj" })).toBeNull();
|
||||
});
|
||||
|
||||
it("shows who is logged in and logs out", async () => {
|
||||
const out = vi.fn(() => new Response(null, { status: 204 }));
|
||||
server({
|
||||
"GET api/auth/me": ME, "GET api/health": health, "GET api/auth/config": CONFIG,
|
||||
"POST api/auth/logout": out as unknown as Handler,
|
||||
});
|
||||
await open("#status");
|
||||
|
||||
expect((await screen.findAllByText("Zalogowano:"))[0].parentElement).toHaveTextContent("Zalogowano: mariusz");
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Wyloguj" })[0]);
|
||||
|
||||
expect(await loginScreen()).toBeInTheDocument();
|
||||
expect(out).toHaveBeenCalled();
|
||||
expect(screen.queryByText("Sesja wygasła – zaloguj się ponownie")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("first account", () => {
|
||||
const SETUP = { local: true, oidc: true, setup: true };
|
||||
const fill = (password: string, repeat: string) => {
|
||||
fireEvent.change(screen.getByLabelText("Login"), { target: { value: "mariusz" } });
|
||||
fireEvent.change(screen.getByLabelText("Hasło"), { target: { value: password } });
|
||||
fireEvent.change(screen.getByLabelText("Powtórz hasło"), { target: { value: repeat } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Utwórz konto" }));
|
||||
};
|
||||
|
||||
it("offers the creation instead of the login form, with Keycloak below, and opens the app", async () => {
|
||||
const post = vi.fn(() => answer(ME));
|
||||
server({ "GET api/auth/me": unauthenticated, "GET api/auth/config": SETUP, "POST api/auth/setup": post });
|
||||
await open();
|
||||
|
||||
expect(await screen.findByText("Utwórz pierwsze konto")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Zaloguj" })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Zaloguj przez Keycloak" })).toBeInTheDocument();
|
||||
fill("dlugie-haslo-1", "dlugie-haslo-1");
|
||||
|
||||
expect(await screen.findByRole("navigation", { name: "Ekrany" })).toBeInTheDocument();
|
||||
expect(post).toHaveBeenCalledWith({ username: "mariusz", password: LONG });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["too short", "krotkie", "krotkie", "Hasło musi mieć co najmniej 10 znaków"],
|
||||
["different", "dlugie-haslo-1", "dlugie-haslo-2", "Hasła nie są takie same"],
|
||||
])("does not send a password that is %s", async (_name, password, repeat, text) => {
|
||||
const post = vi.fn(() => answer(ME));
|
||||
server({ "GET api/auth/me": unauthenticated, "GET api/auth/config": SETUP, "POST api/auth/setup": post });
|
||||
await open();
|
||||
await screen.findByText("Utwórz pierwsze konto");
|
||||
|
||||
fill(password, repeat);
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(text);
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the login form when somebody created the account first", async () => {
|
||||
server({ "GET api/auth/me": unauthenticated, "GET api/auth/config": SETUP, "POST api/auth/setup": () => answer({ error: "setup_done" }, 409) });
|
||||
await open();
|
||||
await screen.findByText("Utwórz pierwsze konto");
|
||||
|
||||
fill("dlugie-haslo-1", "dlugie-haslo-1");
|
||||
|
||||
expect(await loginScreen()).toBeInTheDocument();
|
||||
expect(screen.getByText("Konto już istnieje – zaloguj się")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a weak password", () => answer({ error: "weak_password" }, 400), "Hasło jest za słabe (co najmniej 10 znaków)"],
|
||||
["a bad login", () => answer({ error: "bad_username" }, 400), "Nieprawidłowy login"],
|
||||
["too many tries", () => answer({ error: "throttled", retryAfterSeconds: 60 }, 429), "Za dużo prób – spróbuj za 1 min"],
|
||||
["a lost network", () => Promise.reject(new TypeError("offline")), "Brak połączenia z serwerem"],
|
||||
])("says so on %s from the server", async (_name, reply, text) => {
|
||||
server({ "GET api/auth/me": unauthenticated, "GET api/auth/config": SETUP, "POST api/auth/setup": reply });
|
||||
await open();
|
||||
await screen.findByText("Utwórz pierwsze konto");
|
||||
|
||||
fill("dlugie-haslo-1", "dlugie-haslo-1");
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(text);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState, type FormEvent, type ReactNode } from "react";
|
||||
import { getJson, HttpError, postAuth, whenSessionExpires } from "./api";
|
||||
import { Icon } from "./icons";
|
||||
import { Banner, Card, Loading } from "./ui";
|
||||
|
||||
interface Session { user: string; method: "local" | "oidc" | "none" }
|
||||
interface AuthConfig { local: boolean; oidc: boolean; setup?: boolean }
|
||||
|
||||
type State =
|
||||
| { kind: "loading" }
|
||||
| { kind: "unreachable" }
|
||||
| { kind: "out"; expired: boolean }
|
||||
| { kind: "in"; session: Session };
|
||||
|
||||
const SessionContext = createContext<{ user: string; method: Session["method"]; logout: () => void } | null>(null);
|
||||
|
||||
// A Map, so a code such as "__proto__" finds nothing.
|
||||
const OIDC_ERRORS = new Map([
|
||||
["oidc_unavailable", "Keycloak jest niedostępny – zaloguj się loginem i hasłem"],
|
||||
["oidc_denied", "Brak uprawnień do aplikacji"],
|
||||
["oidc_failed", "Logowanie przez Keycloak nie powiodło się"],
|
||||
]);
|
||||
|
||||
// Keycloak sends the browser back to "#login?error=…".
|
||||
const oidcError = () => OIDC_ERRORS.get(new URLSearchParams(location.hash.split("?")[1]).get("error") ?? "");
|
||||
|
||||
export const FIELD = "min-h-12 w-full rounded-xl border-2 border-off bg-page px-3 text-lg text-ink focus:border-brand focus:outline-none";
|
||||
export const BUTTON = "min-h-12 w-full rounded-xl border-2 border-brand px-3 text-lg font-bold transition active:scale-[0.98] disabled:opacity-40";
|
||||
|
||||
export const MIN_PASSWORD = 10;
|
||||
|
||||
/** The message for a new password that cannot be sent, or "" when it can. */
|
||||
export function newPasswordProblem(password: string, repeat: string) {
|
||||
if (password.length < MIN_PASSWORD) return `Hasło musi mieć co najmniej ${MIN_PASSWORD} znaków`;
|
||||
return password === repeat ? "" : "Hasła nie są takie same";
|
||||
}
|
||||
|
||||
/** What the server's refusal of a new account or password means. */
|
||||
export function accountFailure(status: number, body: { error?: string; retryAfterSeconds?: number } | null) {
|
||||
if (status === 400) return body?.error === "weak_password" ? `Hasło jest za słabe (co najmniej ${MIN_PASSWORD} znaków)` : "Nieprawidłowy login";
|
||||
if (status === 409) return "Takie konto już istnieje";
|
||||
return failure(status, body);
|
||||
}
|
||||
|
||||
function failure(status: number, body: { retryAfterSeconds?: number } | null) {
|
||||
if (status === 0) return "Brak połączenia z serwerem";
|
||||
if (status === 429) return `Za dużo prób – spróbuj za ${Math.max(1, Math.ceil((body?.retryAfterSeconds ?? 0) / 60))} min`;
|
||||
if (status === 404) return "Logowanie loginem i hasłem jest wyłączone";
|
||||
return status === 401 ? "Nieprawidłowy login lub hasło" : "Logowanie nie powiodło się, spróbuj ponownie";
|
||||
}
|
||||
|
||||
function Screen({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<main className="mx-auto flex min-h-dvh max-w-sm flex-col justify-center gap-5 px-4 py-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="grid size-12 place-items-center rounded-xl bg-brand/20 text-brand"><Icon name="leaf" className="size-7" /></span>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold leading-tight">Dom Szczepańcowa</h1>
|
||||
<p className="text-soft">Pompa ciepła i energia</p>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginForm({ onLogin }: { onLogin: (session: Session) => void }) {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [shown, setShown] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const submit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setSending(true);
|
||||
setError("");
|
||||
const { status, body } = await postAuth("api/auth/login", { username, password });
|
||||
if (status === 200) return onLogin(body);
|
||||
setError(failure(status, body));
|
||||
setSending(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<label className="block">
|
||||
<span className="mb-1 block font-semibold">Login</span>
|
||||
<input value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" required
|
||||
autoCapitalize="none" spellCheck={false} className={FIELD} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block font-semibold">Hasło</span>
|
||||
<span className="flex gap-2">
|
||||
<input value={password} onChange={(e) => setPassword(e.target.value)} type={shown ? "text" : "password"}
|
||||
autoComplete="current-password" required className={FIELD} />
|
||||
<button type="button" onClick={() => setShown(!shown)} aria-pressed={shown}
|
||||
className="min-h-12 shrink-0 rounded-xl border-2 border-off px-3 font-semibold text-soft aria-pressed:border-brand aria-pressed:text-brand">
|
||||
{shown ? "Ukryj" : "Pokaż"}
|
||||
</button>
|
||||
</span>
|
||||
</label>
|
||||
{error && <p role="alert" className="font-semibold text-alarm">{error}</p>}
|
||||
<button type="submit" disabled={sending} className={`${BUTTON} bg-brand text-white dark:text-side`}>Zaloguj</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function SetupForm({ onLogin, onExists }: { onLogin: (session: Session) => void; onExists: () => void }) {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [repeat, setRepeat] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const submit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const problem = newPasswordProblem(password, repeat);
|
||||
if (problem) return setError(problem);
|
||||
setSending(true);
|
||||
setError("");
|
||||
const { status, body } = await postAuth("api/auth/setup", { username, password });
|
||||
if (status === 200) return onLogin(body);
|
||||
if (status === 409) return onExists();
|
||||
setError(accountFailure(status, body));
|
||||
setSending(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<h2 className="text-xl font-bold">Utwórz pierwsze konto</h2>
|
||||
<label className="block">
|
||||
<span className="mb-1 block font-semibold">Login</span>
|
||||
<input value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" required
|
||||
autoCapitalize="none" spellCheck={false} className={FIELD} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block font-semibold">Hasło</span>
|
||||
<input value={password} onChange={(e) => setPassword(e.target.value)} type="password" autoComplete="new-password" required className={FIELD} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block font-semibold">Powtórz hasło</span>
|
||||
<input value={repeat} onChange={(e) => setRepeat(e.target.value)} type="password" autoComplete="new-password" required className={FIELD} />
|
||||
</label>
|
||||
{error && <p role="alert" className="font-semibold text-alarm">{error}</p>}
|
||||
<button type="submit" disabled={sending} className={`${BUTTON} bg-brand text-white dark:text-side`}>Utwórz konto</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function Login({ expired, onLogin }: { expired: boolean; onLogin: (session: Session) => void }) {
|
||||
// The form is the way in when Keycloak is down, so it also shows when the config cannot be read.
|
||||
const [config, setConfig] = useState<AuthConfig>();
|
||||
useEffect(() => {
|
||||
getJson<AuthConfig>("api/auth/config").then(setConfig, () => setConfig({ local: true, oidc: false }));
|
||||
}, []);
|
||||
const [accountExists, setAccountExists] = useState(false);
|
||||
const keycloakProblem = oidcError();
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
{expired && <Banner tone="warn" compact>Sesja wygasła – zaloguj się ponownie</Banner>}
|
||||
{accountExists && <Banner tone="ok" compact>Konto już istnieje – zaloguj się</Banner>}
|
||||
{keycloakProblem && <Banner tone="bad" compact>{keycloakProblem}</Banner>}
|
||||
{!config ? <Loading /> : !config.local && !config.oidc ? (
|
||||
<>
|
||||
<p className="font-semibold">Logowanie jest teraz niedostępne.</p>
|
||||
<button type="button" onClick={() => location.reload()} className={`${BUTTON} bg-panel text-brand`}>Wczytaj ponownie</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{config.local && (config.setup && !accountExists
|
||||
? <SetupForm onLogin={onLogin} onExists={() => setAccountExists(true)} />
|
||||
: <LoginForm onLogin={onLogin} />)}
|
||||
{config.local && config.oidc && (
|
||||
<p className="flex items-center gap-3 text-soft before:h-px before:flex-1 before:bg-edge after:h-px after:flex-1 after:bg-edge">lub</p>
|
||||
)}
|
||||
{config.oidc && (
|
||||
<button type="button" onClick={() => location.assign("api/auth/oidc/start")} className={`${BUTTON} bg-panel text-brand`}>
|
||||
Zaloguj przez Keycloak
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
/** Shows the app only for a logged-in user; otherwise the login screen. */
|
||||
export function AuthGate({ children }: { children: ReactNode }) {
|
||||
const [state, setState] = useState<State>({ kind: "loading" });
|
||||
|
||||
const load = useCallback(() => {
|
||||
setState({ kind: "loading" });
|
||||
getJson<Session>("api/auth/me").then(
|
||||
(session) => setState({ kind: "in", session }),
|
||||
(error) => setState(error instanceof HttpError && error.status === 401 ? { kind: "out", expired: false } : { kind: "unreachable" }),
|
||||
);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
whenSessionExpires(() => setState((now) => (now.kind === "in" ? { kind: "out", expired: true } : now)));
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await postAuth("api/auth/logout");
|
||||
setState({ kind: "out", expired: false });
|
||||
}, []);
|
||||
|
||||
switch (state.kind) {
|
||||
case "loading": return <Screen><Loading /></Screen>;
|
||||
case "unreachable":
|
||||
return (
|
||||
<Screen>
|
||||
<Banner tone="bad" compact>Brak połączenia z serwerem</Banner>
|
||||
<button type="button" onClick={load} className={`${BUTTON} bg-panel text-brand`}>Spróbuj ponownie</button>
|
||||
</Screen>
|
||||
);
|
||||
case "out":
|
||||
return <Login expired={state.expired} onLogin={(session) => {
|
||||
// Back to where the user was; the "#login?error=…" of a failed Keycloak try is no screen.
|
||||
if (location.hash.startsWith("#login")) history.replaceState(null, "", location.pathname + location.search);
|
||||
setState({ kind: "in", session });
|
||||
}} />;
|
||||
case "in":
|
||||
return <SessionContext value={{ ...state.session, logout }}>{children}</SessionContext>;
|
||||
}
|
||||
}
|
||||
|
||||
/** The logged-in user, for the screens that manage accounts; null outside a login. */
|
||||
export const useSession = () => useContext(SessionContext);
|
||||
|
||||
/** "Zalogowano: …" with the logout button; nothing outside a login (the tests of single screens). */
|
||||
export function Account({ className = "", card = false }: { className?: string; card?: boolean }) {
|
||||
const session = useSession();
|
||||
if (!session || session.method === "none") return null; // no login in a development setup
|
||||
const line = (
|
||||
<div className={`flex items-center justify-between gap-3 ${className}`}>
|
||||
<p>Zalogowano: <b className="font-semibold">{session.user}</b></p>
|
||||
<button onClick={session.logout}
|
||||
className="min-h-11 rounded-xl border-2 border-current px-3 font-bold">Wyloguj</button>
|
||||
</div>
|
||||
);
|
||||
return card ? <Card title="Konto">{line}</Card> : line;
|
||||
}
|
||||
@@ -24,7 +24,7 @@ describe("Battery view", () => {
|
||||
await show({ powerW: 3 });
|
||||
|
||||
expect(screen.getByRole("img", { name: "Naładowanie baterii: 99 %" })).toBeInTheDocument();
|
||||
expect(screen.getByText("14,2 kWh z 14,3 kWh")).toBeInTheDocument();
|
||||
expect(screen.getByText("14,2 / 14,3 kWh")).toBeInTheDocument();
|
||||
expect(screen.getByText("stoi")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -47,7 +47,7 @@ describe("Battery view", () => {
|
||||
|
||||
expect(screen.getByText("Rezerwa na awarię: bateria nie rozładowuje się na co dzień")).toBeInTheDocument();
|
||||
const table = screen.getByRole("table");
|
||||
expect(screen.getByText("wyłączony")).toBeInTheDocument();
|
||||
expect(screen.getByText("Harmonogram wyłączony – bateria tylko na awarię.")).toBeInTheDocument();
|
||||
expect(table).toHaveClass("opacity-50");
|
||||
expect(within(table).getAllByRole("row")).toHaveLength(7);
|
||||
expect(within(table).getByRole("row", { name: /21:00–01:00/ })).toHaveTextContent("80 %nie");
|
||||
@@ -101,7 +101,7 @@ describe("Battery view", () => {
|
||||
total: { chargeKwh: null, dischargeKwh: null } });
|
||||
|
||||
expect(screen.getByText(/Dane są nieaktualne/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("img", { name: "Naładowanie baterii: —" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("img", { name: "Naładowanie baterii: —" }).closest(".opacity-50")).not.toBeNull();
|
||||
expect(screen.getByText("Dziś naładowano").parentElement).toHaveTextContent("—");
|
||||
});
|
||||
});
|
||||
+17
-11
@@ -1,10 +1,10 @@
|
||||
import { useState } from "react";
|
||||
import { flowing } from "./flow";
|
||||
import { amps, DASH, hour, kwh, number, percent, power } from "./format";
|
||||
import { amps, DASH, dateTime, hour, kwh, number, percent, power } from "./format";
|
||||
import { usePoll } from "./poll";
|
||||
import type { Battery as BatteryData } from "./types";
|
||||
import { Reserve, Schedule, type Notice } from "./BatterySettings";
|
||||
import { Banner, Card, Freshness, Loading, Row } from "./ui";
|
||||
import { Banner, Card, Freshness, isStale, Loading, Row } from "./ui";
|
||||
import { threatensPower, useWeather } from "./Weather";
|
||||
|
||||
/** What the battery does, in one word: + discharge / − charge. */
|
||||
@@ -31,21 +31,21 @@ function Gauge({ soc, stored, capacity }: { soc: number | null; stored: number |
|
||||
<div className="absolute inset-0 grid place-items-center text-center">
|
||||
<div>
|
||||
<p className="text-4xl font-bold tabular-nums">{percent(soc)}</p>
|
||||
{stored != null && capacity != null && <p className="text-sm text-soft">{kwh(stored)} z {kwh(capacity)}</p>}
|
||||
{stored != null && capacity != null && <p className="text-sm text-soft">{number(stored, 1)} / {kwh(capacity)}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Now({ b }: { b: BatteryData }) {
|
||||
function Now({ b, stale }: { b: BatteryData; stale: boolean }) {
|
||||
const stored = b.socPct != null && b.capacityKwh != null ? (b.socPct / 100) * b.capacityKwh : null;
|
||||
return (
|
||||
<Card title="Bateria teraz">
|
||||
<div className="flex flex-wrap items-center gap-5">
|
||||
<div className={`flex flex-wrap items-center gap-5 ${stale ? "opacity-50" : ""}`}>
|
||||
<Gauge soc={b.socPct} stored={stored} capacity={b.capacityKwh} />
|
||||
<div className="min-w-44 flex-1">
|
||||
<p className={`text-2xl font-bold ${flowing(b.powerW) ? "text-run" : ""}`}>
|
||||
<p className={`text-2xl font-bold ${flowing(b.powerW) ? "text-brand" : ""}`}>
|
||||
{batteryAction(b.powerW)}
|
||||
{flowing(b.powerW) && <span className="tabular-nums"> {power(Math.abs(b.powerW!))}</span>}
|
||||
</p>
|
||||
@@ -90,17 +90,23 @@ function Totals({ b }: { b: BatteryData }) {
|
||||
export function Battery() {
|
||||
const { data: b, offline } = usePoll<BatteryData>("api/battery");
|
||||
const [notice, setNotice] = useState<Notice>(null);
|
||||
const weather = useWeather();
|
||||
const threats = useWeather()?.warnings.filter(threatensPower) ?? [];
|
||||
if (!b) return <><Freshness offline={offline} />{!offline && <Loading />}</>;
|
||||
return (
|
||||
<>
|
||||
<Freshness offline={offline} time={b.time} stale={b.stale} />
|
||||
{weather?.warnings.some(threatensPower) && (
|
||||
<Banner tone="warn">Ostrzeżenie IMGW: rozważ trzymanie baterii pełnej (rezerwa na awarię).</Banner>
|
||||
<Freshness offline={offline} time={b.time} stale={b.stale} compact />
|
||||
{threats.length > 0 && (
|
||||
<Banner tone="warn" compact>
|
||||
<p>Zapowiadany silny wiatr lub burza – zostaw baterię naładowaną (rezerwa na awarię).</p>
|
||||
<details className="mt-1 font-normal">
|
||||
<summary className="cursor-pointer py-1 font-semibold">Szczegóły ostrzeżenia</summary>
|
||||
<ul>{threats.map((w) => <li key={`${w.event}${w.level}${w.from}`}>IMGW, stopień {w.level}: {w.event}, {dateTime(w.from)} – {dateTime(w.to)}</li>)}</ul>
|
||||
</details>
|
||||
</Banner>
|
||||
)}
|
||||
{notice && <Banner tone={notice.ok ? "ok" : "bad"}>{notice.text}</Banner>}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="lg:col-span-2"><Now b={b} /></div>
|
||||
<div className="lg:col-span-2"><Now b={b} stale={isStale(b.time, b.stale)} /></div>
|
||||
{b.settingsStale || !b.mode || !b.limits ? (
|
||||
<div className="lg:col-span-2">
|
||||
<Banner tone="warn">Ustawienia baterii są nieaktualne{b.settingsTime ? ` (ostatnio ${hour(new Date(b.settingsTime))})` : ", brak odczytu"}.</Banner>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { answer, example, serve } from "./fixtures";
|
||||
import type { Battery as BatteryData } from "./types";
|
||||
|
||||
@@ -191,6 +191,16 @@ describe("Battery schedule editing", () => {
|
||||
expect(screen.getByRole("button", { name: "Zapisz" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("lists slots that share a start without a duplicate key", async () => {
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
const schedule = example<BatteryData>("battery").schedule.map((slot) => ({ ...slot, from: "00:00" }));
|
||||
await show({ schedule });
|
||||
|
||||
expect(screen.getAllByText(/^00:00–/)).toHaveLength(6);
|
||||
expect(error).not.toHaveBeenCalled();
|
||||
error.mockRestore();
|
||||
});
|
||||
|
||||
it("requires every start to be filled in", async () => {
|
||||
await show();
|
||||
await edit();
|
||||
|
||||
@@ -3,7 +3,7 @@ import { postJson, WriteError } from "./api";
|
||||
import { percent } from "./format";
|
||||
import { reload } from "./poll";
|
||||
import type { Battery, BatteryChange, BatteryResult, BatterySlot } from "./types";
|
||||
import { Button, Card, Pill, Row, Stepper } from "./ui";
|
||||
import { Button, Card, Row, Stepper } from "./ui";
|
||||
|
||||
const URL = "api/battery";
|
||||
// The gateway polls the settings again 3 s after a write; the backend shows them from then on.
|
||||
@@ -109,7 +109,7 @@ export function Reserve({ b, onDone }: { b: Battery; onDone: (notice: Notice) =>
|
||||
<>
|
||||
<Stepper label="rezerwa na awarię" value={draft ?? reserve.pct} min={reserve.floorPct} max={100}
|
||||
format={percent} disabled={busy} onStep={step} />
|
||||
<p className="text-sm text-soft">od {percent(reserve.floorPct)} do {percent(100)}</p>
|
||||
<p className="text-base text-soft">od {percent(reserve.floorPct)} do {percent(100)}</p>
|
||||
{breaksSchedule && <p role="alert" className="mt-2 font-semibold text-alarm">{RESERVE_BREAKS_SCHEDULE}</p>}
|
||||
{draft != null && !confirming && (
|
||||
<div className="mt-3 flex gap-3">
|
||||
@@ -275,19 +275,20 @@ export function Schedule({ b, onDone }: { b: Battery; onDone: (notice: Notice) =
|
||||
}
|
||||
const edit = b.editable && b.reserve ? <Button onClick={() => setEditing(true)}>Edytuj</Button> : null;
|
||||
return (
|
||||
<Card title="Harmonogram" aside={<div className="flex items-center gap-2">{off && <Pill tone="off">wyłączony</Pill>}{edit}</div>}>
|
||||
<table className={`w-full text-[0.95rem] ${off ? "opacity-50" : ""}`}>
|
||||
<Card title="Harmonogram" aside={edit}>
|
||||
{off && <p className="mb-3 font-semibold">Harmonogram wyłączony – bateria tylko na awarię.</p>}
|
||||
<table className={`w-full text-base ${off ? "opacity-50" : ""}`}>
|
||||
<caption className="sr-only">Przedziały pracy baterii{off ? " (wyłączone)" : ""}</caption>
|
||||
<thead>
|
||||
<tr className="text-left text-sm text-soft">
|
||||
<th scope="col" className="pb-2 font-medium">Godziny</th>
|
||||
<th scope="col" className="pb-2 font-medium">Poziom</th>
|
||||
<th scope="col" className="pb-2 text-right font-medium">Ładowanie z sieci</th>
|
||||
<tr className="text-left text-soft">
|
||||
<th scope="col" className="pb-2 font-semibold">Godziny</th>
|
||||
<th scope="col" className="pb-2 font-semibold">Poziom</th>
|
||||
<th scope="col" className="pb-2 text-right font-semibold">Z sieci</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{b.schedule.map((slot) => (
|
||||
<tr key={slot.from} className="border-t border-edge">
|
||||
{b.schedule.map((slot, i) => (
|
||||
<tr key={i + 1} className="border-t border-edge">
|
||||
<th scope="row" className="py-2 text-left font-semibold tabular-nums">{slot.from}–{slot.to}</th>
|
||||
<td className="py-2 tabular-nums">{percent(slot.socPct)}</td>
|
||||
<td className="py-2 text-right font-semibold">{slot.gridCharge ? "tak" : "nie"}</td>
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("ChangeHistory", () => {
|
||||
expect(rows[1]).toHaveTextContent("automatycznie (przywrócenie)");
|
||||
expect(rows[2]).toHaveTextContent("nie → tak");
|
||||
expect(within(rows[2]).getByText("nieudana")).toBeInTheDocument();
|
||||
expect(rows[3]).toHaveTextContent("— → 22 °C");
|
||||
expect(rows[3]).toHaveTextContent("ustawiono 22 °C");
|
||||
expect(within(rows[3]).getByText("odrzucona")).toBeInTheDocument();
|
||||
expect(within(rows[0]).queryByText(/nieudana|odrzucona|konflikt|nieważna/)).toBeNull();
|
||||
});
|
||||
|
||||
@@ -6,8 +6,10 @@ import { Card, Pill } from "./ui";
|
||||
|
||||
const URL = "api/audit?range=7d";
|
||||
const PAGE = 20;
|
||||
const STATUS: Record<string, string> = {
|
||||
FAILED: "nieudana", REJECTED: "odrzucona", CONFLICT: "konflikt", INVALID: "nieważna",
|
||||
// A failure may have reached the pump, so it is red; the others changed nothing, so amber.
|
||||
const STATUS: Record<string, { text: string; tone: "bad" | "warn" }> = {
|
||||
FAILED: { text: "nieudana", tone: "bad" }, REJECTED: { text: "odrzucona", tone: "warn" },
|
||||
CONFLICT: { text: "konflikt", tone: "warn" }, INVALID: { text: "nieważna", tone: "warn" },
|
||||
};
|
||||
|
||||
// 1830 is a clock time, 0/1 is off/on.
|
||||
@@ -20,17 +22,20 @@ const SHOWN: Record<string, (value: number) => string> = {
|
||||
const shown = (value: number | null, unit: string) => (value == null ? "—" : (SHOWN[unit] ?? number)(value));
|
||||
|
||||
function Row({ entry }: { entry: AuditEntry }) {
|
||||
const status = entry.status === "APPLIED" ? null : (STATUS[entry.status] ?? entry.status);
|
||||
const status = entry.status === "APPLIED" ? null : (STATUS[entry.status] ?? { text: entry.status, tone: "warn" as const });
|
||||
return (
|
||||
<li className="border-t border-edge py-3 first:border-t-0 first:pt-0">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-3">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="font-semibold">{entry.what}</span>
|
||||
<span className="text-sm text-soft">{dateTime(entry.time)}</span>
|
||||
<span className="shrink-0 text-sm text-soft">{dateTime(entry.time)}</span>
|
||||
</div>
|
||||
<p className="my-1">{shown(entry.oldValue, entry.unit)} → <b>{shown(entry.newValue, entry.unit)}</b></p>
|
||||
<p className="my-1">
|
||||
{entry.oldValue == null ? <>ustawiono <b>{shown(entry.newValue, entry.unit)}</b></>
|
||||
: <>{shown(entry.oldValue, entry.unit)} → <b>{shown(entry.newValue, entry.unit)}</b></>}
|
||||
</p>
|
||||
<p className="flex flex-wrap items-center gap-2 text-sm text-soft">
|
||||
{entry.who}
|
||||
{status && <Pill tone="off">{status}</Pill>}
|
||||
{status && <Pill tone={status.tone}>{status.text}</Pill>}
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { celsius, degrees, number } from "./format";
|
||||
import type { HeatPump, PiSensor } from "./types";
|
||||
import { Card, Row } from "./ui";
|
||||
|
||||
// The Raspberry Pi with the BME280 stands in the living room.
|
||||
const NO_READING = "brak odczytu";
|
||||
const reading = (value: number | null | undefined, digits: number, unit: string) =>
|
||||
value == null ? NO_READING : `${number(value, digits)} ${unit}`;
|
||||
|
||||
function Group({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="mt-3 first:mt-0 [&>div]:py-1.5">
|
||||
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-soft">{title}</h3>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const difference = (a: number | null, b: number | null) => (a != null && b != null ? celsius(a - b) : "—");
|
||||
|
||||
/** Every temperature and setpoint the overview does not already show in its four key figures. */
|
||||
export function TemperatureDetails({ hp, pi }: { hp: HeatPump; pi: PiSensor | undefined }) {
|
||||
const { temperatures: t, targets, setpoints: s } = hp;
|
||||
return (
|
||||
<Card title="Szczegóły temperatur">
|
||||
<Group title="Dom">
|
||||
<Row label="Salon" value={reading(pi?.temperature, 1, "°C")} />
|
||||
<Row label="Wilgotność w salonie" value={reading(pi?.humidity, 1, "%")} />
|
||||
<Row label="Ciśnienie" value={reading(pi?.pressure, 0, "hPa")} />
|
||||
<Row label="Pokój według pompy" value={celsius(t.indoor)} />
|
||||
<Row label="Pokój, cel teraz" value={celsius(targets.room)} />
|
||||
<Row label="Na zewnątrz, średnia" value={celsius(t.outdoorAvg)} />
|
||||
</Group>
|
||||
<Group title="Woda i ogrzewanie">
|
||||
<Row label="Bufor CO" value={celsius(t.coBuffer)} />
|
||||
<Row label="Bufor CO, cel" value={celsius(targets.co)} />
|
||||
<Row label="Wyjście pompy" value={celsius(t.pumpOutlet)} />
|
||||
</Group>
|
||||
<Group title="Dolne źródło">
|
||||
<Row label="Do pompy" value={celsius(t.groundIn)} />
|
||||
<Row label="Z pompy" value={celsius(t.groundOut)} />
|
||||
<Row label="Różnica" value={difference(t.groundIn, t.groundOut)} />
|
||||
</Group>
|
||||
<Group title="Nastawy">
|
||||
<Row label="Ciepła woda – oszczędnie · komfort" value={`${degrees(s.cwuEconomy)} · ${degrees(s.cwuComfort)}`} />
|
||||
<Row label="Ogrzewanie – oszczędnie · komfort" value={`${degrees(s.coEconomy)} · ${degrees(s.coComfort)}`} />
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,21 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { quantity, when } from "./format";
|
||||
import type { EnergyDetailsDevice } from "./types";
|
||||
import { Row } from "./ui";
|
||||
import { Dot, isStale, Row } from "./ui";
|
||||
|
||||
/** The tappable row that says in words and with a chevron that a device has more to show. */
|
||||
export function DetailsButton({ device, onOpen }: { device: EnergyDetailsDevice; onOpen: () => void }) {
|
||||
/** The tappable row: status, the main current value and the read time; the chevron says there is more. */
|
||||
export function DetailsButton({ device, summary, onOpen }: { device: EnergyDetailsDevice; summary: string; onOpen: () => void }) {
|
||||
const stale = device.stale || isStale(device.time);
|
||||
const read = stale ? (device.time ? `brak odczytu od ${when(device.time)}` : "brak odczytu") : device.time && `odczyt ${when(device.time)}`;
|
||||
return (
|
||||
<button type="button" onClick={onOpen}
|
||||
className="flex min-h-14 w-full items-center justify-between gap-3 rounded-xl border border-edge px-4 py-2 text-left hover:bg-brand-soft focus-visible:outline-2 focus-visible:outline-brand">
|
||||
className="flex min-h-16 w-full items-center justify-between gap-3 rounded-xl border border-edge px-4 py-2 text-left hover:bg-brand-soft focus-visible:outline-2 focus-visible:outline-brand">
|
||||
<span>
|
||||
<span className="block font-semibold">{device.name}</span>
|
||||
{device.stale && <span className="block text-xs text-soft">brak odczytu</span>}
|
||||
<span className="flex items-center gap-2 font-semibold">
|
||||
<Dot tone={stale ? "warn" : "ok"} />{device.name}
|
||||
</span>
|
||||
{!stale && summary && <span className="block text-lg font-bold tabular-nums">{summary}</span>}
|
||||
{read && <span className="block text-sm text-soft">{read}</span>}
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1 font-semibold text-brand">
|
||||
Szczegóły <span aria-hidden="true" className="text-xl leading-none">›</span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { example, serve } from "./fixtures";
|
||||
import type { Energy as EnergyData, EnergyDetails } from "./types";
|
||||
|
||||
@@ -15,6 +15,11 @@ beforeEach(() => {
|
||||
location.hash = "";
|
||||
});
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
// The fixtures are from a fixed evening; this makes them "just read".
|
||||
const justAfterFixture = () => vi.useFakeTimers({ toFake: ["Date"], now: new Date("2026-10-23T22:28:00Z") });
|
||||
|
||||
describe("Energy views", () => {
|
||||
const tab = (name: string) => screen.getByRole("tab", { name });
|
||||
|
||||
@@ -90,6 +95,34 @@ describe("Energy details", () => {
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the main value and the read time on each row, and says when a device is silent", async () => {
|
||||
justAfterFixture();
|
||||
const details = example<EnergyDetails>("energy-details");
|
||||
serveEnergy({ devices: details.devices.map((d) => (d.id === "fronius" ? { ...d, stale: true, groups: [] } : d)) });
|
||||
location.hash = "energy/inverters";
|
||||
const Energy = await energy();
|
||||
render(<Energy />);
|
||||
|
||||
expect(await screen.findByRole("button", { name: /Falownik Deye/ })).toHaveTextContent("Bateria 99 % · PV 0 W");
|
||||
expect(screen.getByRole("button", { name: /Licznik domu/ })).toHaveTextContent("645 W pobór");
|
||||
expect(screen.getByRole("button", { name: /Falownik Fronius/ })).toHaveTextContent("brak odczytu od");
|
||||
expect(screen.getByRole("button", { name: /Falownik Fronius/ })).not.toHaveTextContent("PV");
|
||||
});
|
||||
|
||||
it("dims the values of a source that has gone quiet", async () => {
|
||||
justAfterFixture();
|
||||
const e = example<EnergyData>("energy");
|
||||
serve({ "api/energy/details": example("energy-details"), "api/battery": example("battery"),
|
||||
"api/energy": { ...e, sources: e.sources.map((s) => (s.name === "deye" ? { ...s, stale: true } : s)) } });
|
||||
const Energy = await energy();
|
||||
render(<Energy />);
|
||||
|
||||
const stats = (await screen.findByText("Zużycie domu")).closest("section")!;
|
||||
expect(screen.getByText("Produkcja PV", { selector: "p" }).closest("section")).toHaveClass("opacity-60");
|
||||
expect(screen.getAllByText("Bateria", { selector: "p" })[0].closest("section")).toHaveClass("opacity-60");
|
||||
expect(stats).not.toHaveClass("opacity-60");
|
||||
});
|
||||
|
||||
it("opens the groups of a device and closes by button, backdrop and Escape", async () => {
|
||||
serveEnergy();
|
||||
location.hash = "energy/inverters";
|
||||
|
||||
+69
-34
@@ -5,10 +5,10 @@ import { flowing } from "./flow";
|
||||
import { kwh, percent, power, when } from "./format";
|
||||
import { Icon, type IconName } from "./icons";
|
||||
import { usePoll } from "./poll";
|
||||
import type { Energy as EnergyData, EnergyDetails } from "./types";
|
||||
import type { Energy as EnergyData, EnergyDetails, EnergyDetailsDevice } from "./types";
|
||||
import { DetailsButton, DeviceDetails } from "./DeviceDetails";
|
||||
import { SubViewPanel, SubViewSwitch, useSubView } from "./SubView";
|
||||
import { Banner, Card, Freshness, Loading, PageTitle, Pill, Row } from "./ui";
|
||||
import { Banner, Card, Freshness, isStale, Loading, PageTitle, Pill, Row } from "./ui";
|
||||
|
||||
const INVERTERS = { deye: "Falownik Deye", fronius: "Falownik Fronius" } as const;
|
||||
|
||||
@@ -17,7 +17,7 @@ export function silentInverters(e: EnergyData) {
|
||||
return Object.entries(INVERTERS).flatMap(([id, label]) => {
|
||||
const source = e.sources?.find((s) => s.name === id);
|
||||
if (source && !source.stale && source.time) return [];
|
||||
return [{ label, since: source?.time ? `brak odczytu od ${when(source.time)}` : "brak odczytu" }];
|
||||
return [{ id, label, since: source?.time ? `brak odczytu od ${when(source.time)}` : "brak odczytu" }];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -38,17 +38,17 @@ function batteryText(watts: number | null) {
|
||||
|
||||
// Nodes sit around the house; dots run along a line from the source to the sink of each flow.
|
||||
const NODES: Record<"pv" | "grid" | "house" | "battery", { x: number; y: number; label: string; icon: IconName; colour: string }> = {
|
||||
pv: { x: 90, y: 70, label: "Słońce (PV)", icon: "sun", colour: "#f59e0b" },
|
||||
grid: { x: 510, y: 70, label: "Sieć", icon: "plug", colour: "#3b82f6" },
|
||||
house: { x: 300, y: 170, label: "Dom", icon: "home", colour: "#10b981" },
|
||||
battery: { x: 300, y: 300, label: "Bateria", icon: "battery", colour: "#8b5cf6" },
|
||||
pv: { x: 94, y: 56, label: "Słońce (PV)", icon: "sun", colour: "#f59e0b" },
|
||||
grid: { x: 286, y: 56, label: "Sieć", icon: "plug", colour: "#3b82f6" },
|
||||
house: { x: 190, y: 196, label: "Dom", icon: "home", colour: "#10b981" },
|
||||
battery: { x: 190, y: 336, label: "Bateria", icon: "battery", colour: "#8b5cf6" },
|
||||
};
|
||||
|
||||
// A triangle halfway along the line, pointing at the sink: the direction is readable without the moving dots.
|
||||
function Arrowhead({ from, to }: { from: { x: number; y: number; colour: string }; to: { x: number; y: number } }) {
|
||||
const angle = (Math.atan2(to.y - from.y, to.x - from.x) * 180) / Math.PI;
|
||||
const x = (from.x + to.x) / 2, y = (from.y + to.y) / 2;
|
||||
return <polygon data-arrow points="-11,-10 11,0 -11,10" fill={from.colour} transform={`translate(${x} ${y}) rotate(${angle})`} />;
|
||||
return <polygon data-arrow points="-13,-12 13,0 -13,12" fill={from.colour} transform={`translate(${x} ${y}) rotate(${angle})`} />;
|
||||
}
|
||||
|
||||
function Flow({ from, to, watts }: { from: keyof typeof NODES; to: keyof typeof NODES; watts: number | null }) {
|
||||
@@ -60,15 +60,15 @@ function Flow({ from, to, watts }: { from: keyof typeof NODES; to: keyof typeof
|
||||
const speed = on ? Math.max(0.6, 2.5 - (Math.min(Math.abs(watts!), 5000) / 5000) * 1.9) : 0;
|
||||
return (
|
||||
<g data-flow={`${from}-${to}`} data-on={on} aria-hidden="true">
|
||||
<path d={d} stroke={on ? s.colour : "var(--color-off)"} strokeWidth={on ? 5 : 3} strokeLinecap="round" opacity={on ? 0.35 : 0.6} />
|
||||
{on && <path d={d} className="flow" stroke={s.colour} strokeWidth={5} style={{ stroke: s.colour, animationDuration: `${speed}s` }} />}
|
||||
<path d={d} stroke={on ? s.colour : "var(--color-off)"} strokeWidth={on ? 7 : 4} strokeLinecap="round" opacity={on ? 0.45 : 1} />
|
||||
{on && <path d={d} className="flow" stroke={s.colour} strokeWidth={7} style={{ stroke: s.colour, animationDuration: `${speed}s` }} />}
|
||||
{on && <Arrowhead from={s} to={e} />}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
// With onOpen the node is a button: the chevron says it leads somewhere.
|
||||
function Node({ id, value, note, onOpen }: { id: keyof typeof NODES; value: string; note?: string; onOpen?: () => void }) {
|
||||
function Node({ id, value, note, dim, onOpen }: { id: keyof typeof NODES; value: string; note?: string; dim: boolean; onOpen?: () => void }) {
|
||||
const { x, y, label, icon, colour } = NODES[id];
|
||||
const button = onOpen && {
|
||||
role: "button", tabIndex: 0, "aria-label": `${label}: pokaż szczegóły`, onClick: onOpen, className: "cursor-pointer outline-offset-2 focus-visible:outline-2 focus-visible:outline-brand",
|
||||
@@ -81,12 +81,14 @@ function Node({ id, value, note, onOpen }: { id: keyof typeof NODES; value: stri
|
||||
};
|
||||
return (
|
||||
<g aria-hidden={onOpen ? undefined : true} {...button}>
|
||||
<rect x={x - 78} y={y - 40} width={156} height={80} rx={18} className="fill-panel" stroke="var(--color-edge)" strokeWidth={1.5} />
|
||||
<g transform={`translate(${x - 64} ${y - 26})`} color={colour}><Icon name={icon} size={28} /></g>
|
||||
<text x={x - 28} y={y - 10} fontSize={14} className="scene-soft">{label}</text>
|
||||
<text x={x - 28} y={y + 16} fontSize={22} fontWeight={700} className="scene-text">{value}</text>
|
||||
{note && <text x={x - 28} y={y + 33} fontSize={12} className="scene-soft">{note}</text>}
|
||||
{onOpen && <text x={x + 62} y={y + 8} fontSize={28} textAnchor="middle" className="scene-soft" aria-hidden="true">›</text>}
|
||||
<rect x={x - 86} y={y - 44} width={172} height={88} rx={18} className="fill-panel" stroke="var(--color-edge)" strokeWidth={2} />
|
||||
<g transform={`translate(${x - 76} ${y - 38})`} color={colour}><Icon name={icon} size={28} /></g>
|
||||
<g opacity={dim ? 0.45 : 1}>
|
||||
<text x={x - 40} y={y - 10} fontSize={16} className="scene-soft">{label}</text>
|
||||
<text x={x - 40} y={y + 18} fontSize={26} fontWeight={700} className="scene-text">{value}</text>
|
||||
{note && <text x={x - 40} y={y + 37} fontSize={14} className="scene-soft">{note}</text>}
|
||||
</g>
|
||||
{onOpen && <text x={x + 72} y={y + 10} fontSize={32} textAnchor="middle" className="scene-soft" aria-hidden="true">›</text>}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
@@ -103,25 +105,33 @@ export function flowSummary(e: EnergyData) {
|
||||
];
|
||||
}
|
||||
|
||||
// A source without a fresh reading keeps its last value on the screen, so the value is dimmed.
|
||||
function quietNodes(e: EnergyData): string[] {
|
||||
if (isStale(e.time)) return Object.keys(NODES);
|
||||
return silentInverters(e).flatMap(({ id }) => (id === "deye" ? ["pv", "battery"] : ["pv"]));
|
||||
}
|
||||
|
||||
function FlowPicture({ e, onBattery }: { e: EnergyData; onBattery: () => void }) {
|
||||
const quiet = quietNodes(e);
|
||||
const dim = (id: keyof typeof NODES) => quiet.includes(id);
|
||||
return (
|
||||
<>
|
||||
<ul className="sr-only">{flowSummary(e).map((t) => <li key={t}>{t}</li>)}</ul>
|
||||
<svg viewBox="0 0 600 350" className="h-auto w-full">
|
||||
<svg viewBox="0 0 380 400" className="mx-auto h-auto w-full max-w-md">
|
||||
<Flow from="pv" to="house" watts={e.pvPowerW} />
|
||||
<Flow from="grid" to="house" watts={e.gridPowerW} />
|
||||
<Flow from="battery" to="house" watts={e.batteryPowerW} />
|
||||
<Node id="pv" value={power(e.pvPowerW)} />
|
||||
<Node id="grid" value={power(magnitude(e.gridPowerW))} note={gridText(e.gridPowerW)} />
|
||||
<Node id="house" value={power(e.loadPowerW)} note="zużycie" />
|
||||
<Node id="battery" onOpen={onBattery} value={percent(e.batterySocPct)} note={flowing(e.batteryPowerW) ? (e.batteryPowerW! > 0 ? "rozładowuje" : "ładuje") : undefined} />
|
||||
<Node id="pv" dim={dim("pv")} value={power(e.pvPowerW)} />
|
||||
<Node id="grid" dim={dim("grid")} value={power(magnitude(e.gridPowerW))} note={gridText(e.gridPowerW)} />
|
||||
<Node id="house" dim={dim("house")} value={power(e.loadPowerW)} note="zużycie" />
|
||||
<Node id="battery" dim={dim("battery")} onOpen={onBattery} value={percent(e.batterySocPct)} note={flowing(e.batteryPowerW) ? (e.batteryPowerW! > 0 ? "rozładowuje" : "ładuje") : undefined} />
|
||||
</svg>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Today's use split by where it came from; the battery is charged from both, so "own" is what the grid did not give.
|
||||
export function EnergyToday({ e }: { e: EnergyData | undefined }) {
|
||||
export function EnergyToday({ e, link = false }: { e: EnergyData | undefined; link?: boolean }) {
|
||||
const today = e?.today;
|
||||
const load = today?.loadKwh ?? null;
|
||||
const bought = today?.gridBoughtKwh ?? null;
|
||||
@@ -131,19 +141,25 @@ export function EnergyToday({ e }: { e: EnergyData | undefined }) {
|
||||
{ name: "Z sieci", value: bought ?? 0, colour: "#3b82f6" },
|
||||
];
|
||||
const partial = e ? silentInverters(e).length > 0 : false;
|
||||
const aside = (
|
||||
<span className="flex items-center gap-3">
|
||||
{partial && <Pill tone="warn">Tylko część danych</Pill>}
|
||||
{link && <a href="#energy" className="font-semibold text-brand">Energia ›</a>}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<Card title="Energia – dziś" aside={partial ? <Pill tone="warn">Tylko część danych</Pill> : undefined}>
|
||||
<Card title="Energia – dziś" aside={aside}>
|
||||
{!e ? <Loading /> : (
|
||||
<div className="flex flex-wrap items-center gap-5">
|
||||
<div className="relative size-36 shrink-0">
|
||||
<div className={`relative size-36 shrink-0 ${isStale(e?.time) ? "opacity-50" : ""}`}>
|
||||
<PieChart width={144} height={144}>
|
||||
<Pie data={parts} dataKey="value" innerRadius={50} outerRadius={68} startAngle={90} endAngle={-270}
|
||||
<Pie data={parts} dataKey="value" innerRadius={54} outerRadius={68} startAngle={90} endAngle={-270}
|
||||
stroke="none" isAnimationActive={false}>
|
||||
{parts.map((p) => <Cell key={p.name} fill={p.colour} />)}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
<div className="absolute inset-0 grid place-items-center text-center">
|
||||
<div><p className="text-xl font-bold tabular-nums">{kwh(load)}</p><p className="text-xs text-soft">zużycie</p></div>
|
||||
<div><p className="text-lg font-bold tabular-nums">{kwh(load)}</p><p className="text-xs text-soft">zużycie</p></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-44 flex-1 text-sm">
|
||||
@@ -160,9 +176,9 @@ export function EnergyToday({ e }: { e: EnergyData | undefined }) {
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ icon, label, value, note }: { icon: IconName; label: string; value: string; note?: string }) {
|
||||
function Stat({ icon, label, value, note, dim }: { icon: IconName; label: string; value: string; note?: string; dim: boolean }) {
|
||||
return (
|
||||
<Card>
|
||||
<Card className={dim ? "opacity-60" : ""}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="grid size-10 place-items-center rounded-xl bg-brand-soft text-brand"><Icon name={icon} /></span>
|
||||
<div>
|
||||
@@ -175,8 +191,26 @@ function Stat({ icon, label, value, note }: { icon: IconName; label: string; val
|
||||
);
|
||||
}
|
||||
|
||||
const reading = (d: EnergyDetailsDevice, group: string, label?: string) =>
|
||||
d.groups.find((g) => g.title === group)?.items.filter((item) => !label || item.label === label) ?? [];
|
||||
|
||||
// A value wraps between its parts, never inside one.
|
||||
const unbreakable = (text: string) => text.replaceAll(" ", "\u00a0");
|
||||
|
||||
/** The main current values of a device, from the details and, for the house meter, from the flow. */
|
||||
export function deviceSummary(d: EnergyDetailsDevice, e: EnergyData | undefined): string {
|
||||
if (d.id === "deye") {
|
||||
const pv = reading(d, "Panele PV").reduce((sum, item) => sum + item.value, 0);
|
||||
return [`Bateria ${percent(reading(d, "Bateria", "Naładowanie")[0]?.value)}`, `PV ${power(pv)}`].map(unbreakable).join(" · ");
|
||||
}
|
||||
if (d.id === "fronius") return `PV ${power(reading(d, "Teraz", "Moc PV")[0]?.value)}`;
|
||||
if (d.id === "house-meter" && e) return `${power(magnitude(e.gridPowerW))} ${gridText(e.gridPowerW)}`;
|
||||
return "";
|
||||
}
|
||||
|
||||
function Devices() {
|
||||
const { data } = usePoll<EnergyDetails>("api/energy/details");
|
||||
const { data: energy } = usePoll<EnergyData>("api/energy");
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const close = useCallback(() => setOpenId(null), []);
|
||||
const open = data?.devices.find((d) => d.id === openId);
|
||||
@@ -184,7 +218,7 @@ function Devices() {
|
||||
<Card title="Urządzenia">
|
||||
{!data ? <Loading /> : (
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{data.devices.map((d) => <DetailsButton key={d.id} device={d} onOpen={() => setOpenId(d.id)} />)}
|
||||
{data.devices.map((d) => <DetailsButton key={d.id} device={d} summary={deviceSummary(d, energy)} onOpen={() => setOpenId(d.id)} />)}
|
||||
</div>
|
||||
)}
|
||||
{open && <DeviceDetails device={open} onClose={close} />}
|
||||
@@ -196,6 +230,7 @@ function Now({ onBattery }: { onBattery: () => void }) {
|
||||
const { data: e, offline } = usePoll<EnergyData>("api/energy");
|
||||
if (!e) return <><Freshness offline={offline} />{!offline && <Loading />}</>;
|
||||
const missing = silentInverters(e);
|
||||
const quiet = quietNodes(e);
|
||||
return (
|
||||
<>
|
||||
<Freshness offline={offline} time={e.time} />
|
||||
@@ -204,10 +239,10 @@ function Now({ onBattery }: { onBattery: () => void }) {
|
||||
<Card title="Energia teraz" className="lg:col-span-2"><FlowPicture e={e} onBattery={onBattery} /></Card>
|
||||
<EnergyToday e={e} />
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:col-span-3 lg:grid-cols-4">
|
||||
<Stat icon="sun" label="Produkcja PV" value={power(e.pvPowerW)} note={missing.length ? "tylko część: brakuje danych z falownika" : "Deye + Fronius"} />
|
||||
<Stat icon="home" label="Zużycie domu" value={power(e.loadPowerW)} />
|
||||
<Stat icon="plug" label="Sieć" value={power(magnitude(e.gridPowerW))} note={gridText(e.gridPowerW)} />
|
||||
<Stat icon="battery" label="Bateria" value={percent(e.batterySocPct)} note={batteryText(e.batteryPowerW)} />
|
||||
<Stat dim={quiet.includes("pv")} icon="sun" label="Produkcja PV" value={power(e.pvPowerW)} note={missing.length ? "tylko część: brakuje danych z falownika" : "Deye + Fronius"} />
|
||||
<Stat dim={quiet.includes("house")} icon="home" label="Zużycie domu" value={power(e.loadPowerW)} />
|
||||
<Stat dim={quiet.includes("grid")} icon="plug" label="Sieć" value={power(magnitude(e.gridPowerW))} note={gridText(e.gridPowerW)} />
|
||||
<Stat dim={quiet.includes("battery")} icon="battery" label="Bateria" value={percent(e.batterySocPct)} note={batteryText(e.batteryPowerW)} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -53,7 +53,7 @@ describe("Statystyki", () => {
|
||||
it("shows the totals, the starts as a minimum and the heater highlighted when it ran", async () => {
|
||||
await open();
|
||||
|
||||
expect(await screen.findByText("co najmniej 1")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Załączenia sprężarki, co najmniej")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("0,2 h").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("Grzałka").closest("[data-alert]")).toHaveAttribute("data-alert", "true");
|
||||
expect(screen.getByText("Prąd z grzałki jest drogi")).toBeInTheDocument();
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from "react";
|
||||
import { number } from "./format";
|
||||
import { usePoll } from "./poll";
|
||||
import { Stats } from "./Stats";
|
||||
import { SubViewPanel, SubViewSwitch, useSubView } from "./SubView";
|
||||
import { RangeToggle, SubViewPanel, SubViewSwitch, useSubView } from "./SubView";
|
||||
import { TimeChart, toRows, type Range } from "./TimeChart";
|
||||
import type { History as HistoryData } from "./types";
|
||||
import { Card, Freshness, Loading, PageTitle } from "./ui";
|
||||
@@ -59,14 +59,7 @@ function Charts() {
|
||||
className="min-h-12 flex-1 rounded-xl border-2 border-edge bg-panel px-3 font-semibold">
|
||||
{Object.entries(SERIES).map(([id, [name]]) => <option key={id} value={id}>{name}</option>)}
|
||||
</select>
|
||||
<div className="flex gap-2 rounded-xl bg-page p-1">
|
||||
{Object.entries(RANGES).map(([id, name]) => (
|
||||
<button key={id} onClick={() => setRange(id as Range)} aria-pressed={id === range}
|
||||
className="min-h-10 rounded-lg px-4 font-semibold text-soft aria-pressed:bg-panel aria-pressed:text-brand aria-pressed:shadow-sm">
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<RangeToggle label="Okres" options={RANGES} value={range} onSelect={setRange} />
|
||||
</div>
|
||||
<Chart series={series} range={range} />
|
||||
</Card>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { example, serve } from "./fixtures";
|
||||
import type { HeatPump } from "./types";
|
||||
|
||||
import { PI_SENSOR_TITLE } from "./Overview";
|
||||
const overview = async () => (await import("./Overview")).Overview;
|
||||
const fresh = (hp: HeatPump) => ({ ...hp, time: new Date().toISOString() });
|
||||
const history = { series: "x", unit: "°C", points: [] };
|
||||
@@ -19,29 +18,67 @@ const glowing = (container: HTMLElement) =>
|
||||
[...container.querySelectorAll("[data-glow][data-on=true]")].map((p) => p.getAttribute("data-glow"));
|
||||
|
||||
describe("Overview", () => {
|
||||
it("shows the sensor of the Raspberry Pi in the living room", async () => {
|
||||
serveHeatPump(fresh(example("heat-pump")));
|
||||
it("shows the four key figures once, with the living room as the home temperature", async () => {
|
||||
serve({ "api/heat-pump": fresh(example("heat-pump")), "api/energy": example("energy"), "api/history": history,
|
||||
"api/pi-sensor": example("pi-sensor"), "api/weather": example("weather") });
|
||||
const Overview = await overview();
|
||||
render(<Overview />);
|
||||
|
||||
const card = (await screen.findByRole("heading", { name: PI_SENSOR_TITLE })).closest("section")!;
|
||||
expect(card).toHaveTextContent("Temperatura20,2 °C");
|
||||
expect(card).toHaveTextContent("Wilgotność45,8 %");
|
||||
expect(card).toHaveTextContent("Ciśnienie986 hPa");
|
||||
const now = (await screen.findByRole("region", { name: "Teraz" }));
|
||||
const [water, home, outdoor, power] = [...now.children];
|
||||
expect(water).toHaveTextContent("Ciepła woda13,6 °Ccel 8,0 °C");
|
||||
expect(home).toHaveTextContent("W domu20,2 °Cwilgotność 46 %");
|
||||
expect(outdoor).toHaveTextContent("Na zewnątrz4,3 °CPochmurno");
|
||||
expect(power).toHaveTextContent("Prąd");
|
||||
expect(power).toHaveTextContent(/pobór|oddawanie|bez wymiany/);
|
||||
});
|
||||
|
||||
it("says there is no reading when the sensor is stale", async () => {
|
||||
it("falls back to the pump's room sensor when the living room has no reading", async () => {
|
||||
serve({ "api/heat-pump": fresh(example("heat-pump")), "api/energy": example("energy"), "api/history": history,
|
||||
"api/pi-sensor": example("pi-sensor-stale") });
|
||||
const Overview = await overview();
|
||||
render(<Overview />);
|
||||
|
||||
const card = (await screen.findByRole("heading", { name: PI_SENSOR_TITLE })).closest("section")!;
|
||||
expect(card).toHaveTextContent("Temperaturabrak odczytu");
|
||||
expect(card).toHaveTextContent("Wilgotnośćbrak odczytu");
|
||||
const home = (await screen.findByRole("region", { name: "Teraz" })).children[1];
|
||||
expect(home).toHaveTextContent("W domu21,2 °Cczujnik pompy");
|
||||
});
|
||||
|
||||
it("lists the living room and the other temperatures once in the details card", async () => {
|
||||
serveHeatPump(fresh(example("heat-pump")));
|
||||
const Overview = await overview();
|
||||
render(<Overview />);
|
||||
|
||||
const card = (await screen.findByRole("heading", { name: "Szczegóły temperatur" })).closest("section")!;
|
||||
expect(card).toHaveTextContent("Salon20,2 °C");
|
||||
expect(card).toHaveTextContent("Wilgotność w salonie45,8 %");
|
||||
expect(card).toHaveTextContent("Ciśnienie986 hPa");
|
||||
expect(card).toHaveTextContent("Różnica1,7 °C");
|
||||
expect(within(card).getAllByRole("heading", { level: 3 }).map((h) => h.textContent))
|
||||
.toEqual(["Dom", "Woda i ogrzewanie", "Dolne źródło", "Nastawy"]);
|
||||
});
|
||||
|
||||
it("says there is no reading in the details when the sensor is stale", async () => {
|
||||
serve({ "api/heat-pump": fresh(example("heat-pump")), "api/energy": example("energy"), "api/history": history,
|
||||
"api/pi-sensor": example("pi-sensor-stale") });
|
||||
const Overview = await overview();
|
||||
render(<Overview />);
|
||||
|
||||
const card = (await screen.findByRole("heading", { name: "Szczegóły temperatur" })).closest("section")!;
|
||||
expect(card).toHaveTextContent("Salonbrak odczytu");
|
||||
expect(card).toHaveTextContent("Wilgotność w saloniebrak odczytu");
|
||||
expect(card).toHaveTextContent("Ciśnieniebrak odczytu");
|
||||
});
|
||||
|
||||
it("offers the four devices and a link to the Energia screen", async () => {
|
||||
serveHeatPump(fresh(example("heat-pump")));
|
||||
const Overview = await overview();
|
||||
render(<Overview />);
|
||||
|
||||
const devices = await screen.findByRole("region", { name: "Stan urządzeń" });
|
||||
expect(devices.children).toHaveLength(4);
|
||||
expect(screen.getByRole("link", { name: "Energia ›" })).toHaveAttribute("href", "#energy");
|
||||
});
|
||||
|
||||
it("shows a running part in the red accent and a stopped one in grey", async () => {
|
||||
const hp = fresh(example<HeatPump>("heat-pump"));
|
||||
serveHeatPump({ ...hp, state: "HEATING_CWU", compressorPercent: 60, pumps: { ...hp.pumps, cwu: true } });
|
||||
|
||||
+81
-107
@@ -1,13 +1,15 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { celsius, degrees, hour, longDate, number, percent, power } from "./format";
|
||||
import { celsius, hour, longDate, number, percent, power } from "./format";
|
||||
import { Icon, type IconName } from "./icons";
|
||||
import { flowing } from "./flow";
|
||||
import { usePoll } from "./poll";
|
||||
import { Schematic } from "./Schematic";
|
||||
import { Legend, TimeChart, toRows, type Line } from "./TimeChart";
|
||||
import type { Energy, HeatPump, History, PiSensor, PumpState } from "./types";
|
||||
import { Card, Dot, Freshness, Loading, PageTitle, Pill, Row } from "./ui";
|
||||
import type { Energy, HeatPump, History, PiSensor, PumpState, Weather } from "./types";
|
||||
import { Card, Dot, Freshness, Loading, PageTitle, Pill } from "./ui";
|
||||
import { EnergyToday, gridText } from "./Energy";
|
||||
import { useWeather, WeatherCard, WeatherWarnings } from "./Weather";
|
||||
import { TemperatureDetails } from "./Details";
|
||||
import { useWeather, WeatherCard, WeatherIcon, WeatherWarnings } from "./Weather";
|
||||
|
||||
const HEADLINES: Record<PumpState, string> = {
|
||||
IDLE: "Pompa odpoczywa",
|
||||
@@ -38,49 +40,76 @@ function StatusPill({ hp }: { hp: HeatPump }) {
|
||||
return hp.state === "IDLE" ? <Pill tone="off">Praca prawidłowa</Pill> : <Pill tone="run" live>{HEADLINES[hp.state]}</Pill>;
|
||||
}
|
||||
|
||||
function Tile({ icon, title, value, note, status, children }: {
|
||||
icon: IconName; title: string; value: ReactNode; note: string; status: ReactNode; children: ReactNode;
|
||||
}) {
|
||||
const running = (on: boolean | null, yes = "Pracuje") =>
|
||||
on == null ? <Pill tone="off">Brak odczytu</Pill> : on ? <Pill tone="run" live>{yes}</Pill> : <Pill tone="off">Stoi</Pill>;
|
||||
|
||||
// One key figure; the number never wraps, so it is sized to fit two across a phone.
|
||||
function Figure({ icon, label, value, note }: { icon: IconName; label: string; value: string; note: ReactNode }) {
|
||||
return (
|
||||
<Card className="flex flex-col">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="grid size-11 shrink-0 place-items-center rounded-xl bg-brand-soft text-brand">
|
||||
<Icon name={icon} className="size-6" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold">{title}</p>
|
||||
<p className="text-2xl font-bold tabular-nums tracking-tight">{value}</p>
|
||||
<p className="text-xs text-soft">{note}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="my-3">{status}</div>
|
||||
<div className="mt-auto text-sm [&>div]:text-sm">{children}</div>
|
||||
<Card className="min-w-0 p-3! sm:p-5!">
|
||||
<p className="flex items-center gap-1.5 text-sm font-semibold text-soft"><Icon name={icon} className="size-4 shrink-0" />{label}</p>
|
||||
<p className="mt-1 whitespace-nowrap text-[1.7rem] font-bold leading-tight tabular-nums tracking-tight sm:text-3xl">{value}</p>
|
||||
<p className="mt-0.5 flex min-h-5 items-center gap-1.5 text-xs text-soft sm:text-sm">{note}</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const running = (on: boolean | null, yes = "Pracuje") =>
|
||||
on == null ? <Pill tone="off">Brak odczytu</Pill> : on ? <Pill tone="run" live>{yes}</Pill> : <Pill tone="off">Stoi</Pill>;
|
||||
const gridNote = (watts: number | null | undefined) =>
|
||||
watts == null ? "brak danych o sieci"
|
||||
: flowing(watts) ? `${gridText(watts)} ${power(Math.abs(watts))}` : gridText(watts);
|
||||
|
||||
function Temperatures({ hp }: { hp: HeatPump }) {
|
||||
const t = hp.temperatures;
|
||||
const rows: [IconName, string, number | null, string][] = [
|
||||
["thermometer", "Na zewnątrz", t.outdoor, "text-cold"],
|
||||
["thermometer", "Średnia zewnętrzna", t.outdoorAvg, "text-cold"],
|
||||
["home", "W domu", t.indoor, "text-brand"],
|
||||
["heat", "Bufor CO", t.coBuffer, "text-hot"],
|
||||
["drop", "Ciepła woda (CWU)", t.cwu, "text-hot"],
|
||||
["fan", "Wyjście pompy", t.pumpOutlet, "text-hot"],
|
||||
];
|
||||
// The four figures the family checks first; each value is shown here and nowhere else on the screen.
|
||||
function Now({ hp, e, pi, weather }: { hp: HeatPump; e: Energy | undefined; pi: PiSensor | undefined; weather: Weather | undefined }) {
|
||||
const home = pi?.temperature ?? hp.temperatures.indoor;
|
||||
const humidity = pi?.temperature == null ? null : pi.humidity;
|
||||
return (
|
||||
<Card title="Temperatury">
|
||||
{rows.map(([icon, label, value, colour]) => (
|
||||
<Row key={label} label={label} value={celsius(value)} icon={<span className={colour}><Icon name={icon} /></span>} />
|
||||
))}
|
||||
<section aria-label="Teraz" className="grid grid-cols-2 gap-3 sm:gap-4 lg:col-span-3 lg:grid-cols-4">
|
||||
<Figure icon="drop" label="Ciepła woda" value={celsius(hp.temperatures.cwu)} note={`cel ${celsius(hp.targets.cwu)}`} />
|
||||
<Figure icon="home" label="W domu" value={celsius(home)}
|
||||
note={humidity == null ? "czujnik pompy" : `wilgotność ${number(humidity)} %`} />
|
||||
<Figure icon="thermometer" label="Na zewnątrz" value={celsius(hp.temperatures.outdoor)}
|
||||
note={weather && <><WeatherIcon code={weather.today.weatherCode} className="size-5" />{weather.today.description}</>} />
|
||||
<Figure icon="sun" label="Prąd" value={power(e?.pvPowerW)} note={gridNote(e?.gridPowerW)} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// A device on one row: what it is, what it does now, and the one thing only it knows.
|
||||
function Tile({ icon, title, detail, value, status }: {
|
||||
icon: IconName; title: string; detail: string; value?: string; status: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card className="flex items-center gap-3 p-3! sm:p-5!">
|
||||
<span className="grid size-11 shrink-0 place-items-center rounded-xl bg-brand-soft text-brand">
|
||||
<Icon name={icon} className="size-6" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-semibold">{title}</p>
|
||||
<p className="text-xs text-soft sm:text-sm">{detail}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
{value && <p className="text-xl font-bold tabular-nums">{value}</p>}
|
||||
{status}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceTiles({ hp }: { hp: HeatPump }) {
|
||||
return (
|
||||
<section aria-label="Stan urządzeń" className="grid gap-3 sm:grid-cols-2 sm:gap-4">
|
||||
<Tile icon="fan" title="Pompa ciepła" detail={`sprężarka, grzałka ${onOff(hp.heaterOn, "włączona", "wyłączona")}`}
|
||||
value={hp.compressorPercent == null ? "—" : `${hp.compressorPercent} %`}
|
||||
status={running(hp.compressorPercent == null ? null : hp.compressorPercent > 0)} />
|
||||
<Tile icon="tank" title="Zbiornik CWU" detail={`cyrkulacja ${onOff(hp.pumps.circulation, "pracuje", "stoi")}`}
|
||||
status={running(hp.pumps.cwu, "Grzeje wodę")} />
|
||||
<Tile icon="heat" title="Ogrzewanie CO" detail={`pompa bufora ${onOff(hp.pumps.buffer, "pracuje", "stoi")}`}
|
||||
status={running(hp.pumps.co, "Grzeje dom")} />
|
||||
<Tile icon="borehole" title="Odwierty" detail="dolne źródło ciepła" status={running(hp.pumps.ground)} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// Every moving part as a chip under the picture: what runs now is red and pulses.
|
||||
function Devices({ hp }: { hp: HeatPump }) {
|
||||
const compressor = hp.compressorPercent == null ? null : hp.compressorPercent > 0;
|
||||
@@ -107,37 +136,6 @@ function Devices({ hp }: { hp: HeatPump }) {
|
||||
);
|
||||
}
|
||||
|
||||
function Targets({ hp }: { hp: HeatPump }) {
|
||||
const s = hp.setpoints;
|
||||
return (
|
||||
<Card title="Cele i nastawy">
|
||||
<Row label="Pokój, cel teraz" value={celsius(hp.targets.room)} />
|
||||
<Row label="Woda grzewcza CO, cel" value={celsius(hp.targets.co)} />
|
||||
<Row label="Ciepła woda CWU, cel" value={celsius(hp.targets.cwu)} />
|
||||
<Row label="CWU oszczędnie · komfort" value={`${degrees(s.cwuEconomy)} · ${degrees(s.cwuComfort)}`} />
|
||||
<Row label="CO oszczędnie · komfort" value={`${degrees(s.coEconomy)} · ${degrees(s.coComfort)}`} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// The Raspberry Pi with the BME280 stands in the living room.
|
||||
export const PI_SENSOR_TITLE = "Salon";
|
||||
|
||||
const NO_READING = "brak odczytu";
|
||||
const reading = (value: number | null | undefined, digits: number, unit: string) =>
|
||||
value == null ? NO_READING : `${number(value, digits)} ${unit}`;
|
||||
|
||||
function PiSensorCard() {
|
||||
const { data } = usePoll<PiSensor>("api/pi-sensor");
|
||||
return (
|
||||
<Card title={PI_SENSOR_TITLE}>
|
||||
<Row label="Temperatura" value={reading(data?.temperature, 1, "°C")} />
|
||||
<Row label="Wilgotność" value={reading(data?.humidity, 1, "%")} />
|
||||
<Row label="Ciśnienie" value={reading(data?.pressure, 0, "hPa")} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function useTemperatureHistory() {
|
||||
const cwu = usePoll<History>("api/history?series=cwu_temp&range=24h", HISTORY_MS);
|
||||
const co = usePoll<History>("api/history?series=co_buffer_temp&range=24h", HISTORY_MS);
|
||||
@@ -172,6 +170,7 @@ function NoReading() {
|
||||
export function Overview() {
|
||||
const { data: hp, offline } = usePoll<HeatPump>("api/heat-pump");
|
||||
const { data: e } = usePoll<Energy>("api/energy");
|
||||
const { data: pi } = usePoll<PiSensor>("api/pi-sensor");
|
||||
const weather = useWeather();
|
||||
const now = hp?.time ? new Date(hp.time) : null;
|
||||
const title = (
|
||||
@@ -193,48 +192,23 @@ export function Overview() {
|
||||
{weather && <WeatherWarnings warnings={weather.warnings} />}
|
||||
{hp.time == null ? <NoReading /> : (
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<Card title="Schemat instalacji" aside={<StatusPill hp={hp} />} className="lg:col-span-2 lg:row-span-2">
|
||||
<Schematic hp={hp} pvPowerW={e?.pvPowerW} />
|
||||
<Devices hp={hp} />
|
||||
<p className="mt-3 text-xs text-soft">
|
||||
Rury płyną, gdy pracuje pompa obiegu, a stojące są szare. Kolektor słoneczny nie ma czujnika, więc nie wiemy, czy jego obieg pracuje.
|
||||
</p>
|
||||
</Card>
|
||||
<Temperatures hp={hp} />
|
||||
<Targets hp={hp} />
|
||||
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:col-span-3 xl:grid-cols-5">
|
||||
<Tile icon="fan" title="Pompa ciepła" note="sprężarka"
|
||||
value={hp.compressorPercent == null ? "—" : `${hp.compressorPercent} %`}
|
||||
status={running(hp.compressorPercent == null ? null : hp.compressorPercent > 0)}>
|
||||
<Row label="Wyjście" value={celsius(hp.temperatures.pumpOutlet)} />
|
||||
<Row label="Grzałka" value={onOff(hp.heaterOn, "włączona", "wyłączona")} />
|
||||
</Tile>
|
||||
<Tile icon="drop" title="Zbiornik CWU" note="ciepła woda" value={celsius(hp.temperatures.cwu)}
|
||||
status={running(hp.pumps.cwu, "Grzeje wodę")}>
|
||||
<Row label="Cel teraz" value={celsius(hp.targets.cwu)} />
|
||||
<Row label="Cyrkulacja" value={onOff(hp.pumps.circulation, "pracuje", "stoi")} />
|
||||
</Tile>
|
||||
<Tile icon="heat" title="Ogrzewanie CO" note="bufor" value={celsius(hp.temperatures.coBuffer)}
|
||||
status={running(hp.pumps.co, "Grzeje dom")}>
|
||||
<Row label="Cel bufora" value={celsius(hp.targets.co)} />
|
||||
<Row label="Pokój" value={celsius(hp.temperatures.indoor)} />
|
||||
</Tile>
|
||||
<Tile icon="borehole" title="Odwierty" note="do pompy" value={celsius(hp.temperatures.groundIn)}
|
||||
status={running(hp.pumps.ground)}>
|
||||
<Row label="Z pompy" value={celsius(hp.temperatures.groundOut)} />
|
||||
<Row label="Różnica" value={hp.temperatures.groundIn != null && hp.temperatures.groundOut != null
|
||||
? celsius(hp.temperatures.groundIn - hp.temperatures.groundOut) : "—"} />
|
||||
</Tile>
|
||||
<Tile icon="sun" title="Prąd teraz" note="produkcja PV" value={power(e?.pvPowerW)}
|
||||
status={e ? <Pill tone={(e.gridPowerW ?? 0) < 0 ? "ok" : "off"}>Sieć: {gridText(e.gridPowerW)}</Pill> : <Pill tone="off">Brak danych</Pill>}>
|
||||
<Row label="Zużycie domu" value={power(e?.loadPowerW)} />
|
||||
<Row label="Bateria" value={percent(e?.batterySocPct)} />
|
||||
</Tile>
|
||||
<Now hp={hp} e={e} pi={pi} weather={weather} />
|
||||
<div className="grid content-start gap-4 lg:col-span-2">
|
||||
<Card title="Schemat instalacji" aside={<StatusPill hp={hp} />}>
|
||||
<Schematic hp={hp} pvPowerW={e?.pvPowerW} />
|
||||
<Devices hp={hp} />
|
||||
<p className="mt-3 text-xs text-soft">
|
||||
Rury płyną, gdy pracuje pompa obiegu, a stojące są szare. Kolektor słoneczny nie ma czujnika, więc nie wiemy, czy jego obieg pracuje.
|
||||
</p>
|
||||
</Card>
|
||||
<DeviceTiles hp={hp} />
|
||||
</div>
|
||||
{weather && <WeatherCard weather={weather} />}
|
||||
<div className="grid content-start gap-4">
|
||||
{weather && <WeatherCard weather={weather} />}
|
||||
<TemperatureDetails hp={hp} pi={pi} />
|
||||
</div>
|
||||
<EnergyToday e={e} link />
|
||||
<TemperatureChart />
|
||||
<PiSensorCard />
|
||||
<EnergyToday e={e} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { answer, example, serve } from "./fixtures";
|
||||
|
||||
const button = () => screen.getByRole("button", { name: "Odśwież" });
|
||||
const status = () => screen.getByRole("status");
|
||||
const noStatus = () => screen.queryByRole("status");
|
||||
|
||||
// The button and a poller that shows api/heat-pump, as a screen would.
|
||||
async function shown(onPost: () => Promise<Response>) {
|
||||
const fetch = serve({ "api/heat-pump": example("heat-pump") }, onPost);
|
||||
const { RefreshButton } = await import("./Refresh");
|
||||
const { usePoll } = await import("./poll");
|
||||
function Screen() {
|
||||
usePoll("api/heat-pump");
|
||||
return <RefreshButton />;
|
||||
}
|
||||
render(<Screen />);
|
||||
await act(async () => {});
|
||||
return fetch;
|
||||
}
|
||||
|
||||
const posts = (fetch: ReturnType<typeof serve>) => fetch.mock.calls.filter(([, init]) => init?.method === "POST");
|
||||
const gets = (fetch: ReturnType<typeof serve>) => fetch.mock.calls.filter(([, init]) => init?.method !== "POST");
|
||||
|
||||
describe("RefreshButton", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "setInterval", "Date"] });
|
||||
vi.setSystemTime(new Date("2026-10-24T10:00:00Z"));
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("asks once, shows the progress, then fetches again and says when it updated", async () => {
|
||||
const fetch = await shown(() => answer({ requested: ["deye"], skipped: [] }));
|
||||
const before = gets(fetch).length;
|
||||
|
||||
fireEvent.click(button());
|
||||
fireEvent.click(button());
|
||||
await act(async () => {});
|
||||
|
||||
expect(posts(fetch)).toHaveLength(1);
|
||||
expect(posts(fetch)[0][1]!.headers).toMatchObject({ "X-Requested-With": "home-pump" });
|
||||
expect(status()).toHaveTextContent("Odświeżanie…");
|
||||
expect(button()).toBeDisabled();
|
||||
expect(gets(fetch)).toHaveLength(before);
|
||||
|
||||
await act(() => vi.advanceTimersByTimeAsync(5000));
|
||||
|
||||
expect(gets(fetch)).toHaveLength(before + 1);
|
||||
expect(status()).toHaveTextContent("Zaktualizowano 12:00:05");
|
||||
expect(button()).toBeEnabled();
|
||||
});
|
||||
|
||||
it("says the data is fresh when every device was skipped and waits out the cooldown", async () => {
|
||||
const fetch = await shown(() => answer({
|
||||
requested: [],
|
||||
skipped: [{ device: "heat-pump", retryInSeconds: 41 }, { device: "deye", retryInSeconds: 30 }],
|
||||
}));
|
||||
const before = gets(fetch).length;
|
||||
|
||||
fireEvent.click(button());
|
||||
await act(async () => {});
|
||||
|
||||
expect(status()).toHaveTextContent("Dane są świeże – spróbuj za 30 s");
|
||||
expect(button()).toBeDisabled();
|
||||
expect(gets(fetch)).toHaveLength(before);
|
||||
|
||||
await act(() => vi.advanceTimersByTimeAsync(10_000));
|
||||
expect(status()).toHaveTextContent("Dane są świeże – spróbuj za 20 s");
|
||||
|
||||
await act(() => vi.advanceTimersByTimeAsync(20_000));
|
||||
expect(noStatus()).toBeNull();
|
||||
expect(button()).toBeEnabled();
|
||||
});
|
||||
|
||||
it("shows a short error when the backend has no gateway, and allows another try", async () => {
|
||||
const fetch = await shown(() => answer({ error: "gateway unreachable" }, 503));
|
||||
|
||||
fireEvent.click(button());
|
||||
await act(async () => {});
|
||||
|
||||
expect(status()).toHaveTextContent("Nie udało się odświeżyć danych");
|
||||
expect(button()).toBeEnabled();
|
||||
fireEvent.click(button());
|
||||
await act(async () => {});
|
||||
expect(posts(fetch)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("says so when the network is down", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(() => Promise.reject(new TypeError("offline"))));
|
||||
const { RefreshButton } = await import("./Refresh");
|
||||
render(<RefreshButton />);
|
||||
|
||||
fireEvent.click(button());
|
||||
await act(async () => {});
|
||||
|
||||
expect(status()).toHaveTextContent("Brak połączenia z siecią");
|
||||
});
|
||||
|
||||
it("sits in the heading row of a screen, next to the title", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(() => Promise.reject(new TypeError("offline"))));
|
||||
const { PageTitle } = await import("./ui");
|
||||
render(<PageTitle title="Stan" />);
|
||||
|
||||
expect(screen.getByRole("banner")).toContainElement(button());
|
||||
expect(button()).toHaveTextContent("Odśwież");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { postRefresh, WriteError } from "./api";
|
||||
import { hourWithSeconds } from "./format";
|
||||
import { Icon } from "./icons";
|
||||
import { refreshAll } from "./poll";
|
||||
|
||||
// The gateway polls the devices about 3 s after the request; the answers are fetched after this.
|
||||
export const SETTLE_MS = 5000;
|
||||
|
||||
type State =
|
||||
| { kind: "idle" }
|
||||
| { kind: "busy" }
|
||||
| { kind: "done"; at: Date }
|
||||
| { kind: "fresh"; until: number }
|
||||
| { kind: "error"; text: string };
|
||||
|
||||
const failure = (error: unknown) =>
|
||||
error instanceof WriteError && error.status === 0 ? "Brak połączenia z siecią" : "Nie udało się odświeżyć danych";
|
||||
|
||||
// Asks the gateway for current readings; it skips devices it polled less than a minute ago.
|
||||
export function RefreshButton() {
|
||||
const [state, setState] = useState<State>({ kind: "idle" });
|
||||
const [now, setNow] = useState(Date.now());
|
||||
const waiting = state.kind === "fresh" ? Math.ceil((state.until - now) / 1000) : 0;
|
||||
useEffect(() => {
|
||||
if (state.kind !== "fresh") return;
|
||||
const timer = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [state.kind]);
|
||||
useEffect(() => {
|
||||
if (state.kind === "fresh" && waiting <= 0) setState({ kind: "idle" });
|
||||
}, [state.kind, waiting]);
|
||||
|
||||
const tap = async () => {
|
||||
setState({ kind: "busy" });
|
||||
try {
|
||||
const { requested, skipped } = await postRefresh();
|
||||
if (requested.length > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, SETTLE_MS));
|
||||
refreshAll();
|
||||
setState({ kind: "done", at: new Date() });
|
||||
} else {
|
||||
const retry = Math.min(...skipped.map((s) => s.retryInSeconds));
|
||||
setNow(Date.now());
|
||||
setState({ kind: "fresh", until: Date.now() + retry * 1000 });
|
||||
}
|
||||
} catch (error) {
|
||||
setState({ kind: "error", text: failure(error) });
|
||||
}
|
||||
};
|
||||
|
||||
const text = {
|
||||
idle: "", busy: "Odświeżanie…", error: state.kind === "error" ? state.text : "",
|
||||
done: state.kind === "done" ? `Zaktualizowano ${hourWithSeconds(state.at)}` : "",
|
||||
fresh: `Dane są świeże – spróbuj za ${waiting} s`,
|
||||
}[state.kind];
|
||||
const busy = state.kind === "busy";
|
||||
return (
|
||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||
<button onClick={tap} disabled={busy || state.kind === "fresh"}
|
||||
className="flex min-h-11 items-center justify-center gap-2 rounded-xl border-2 border-brand bg-panel px-3 font-bold text-brand transition active:scale-[0.98] disabled:opacity-40">
|
||||
<Icon name="refresh" className={`size-5 ${busy ? "animate-spin" : ""}`} />
|
||||
Odśwież
|
||||
</button>
|
||||
{text && <p role="status" className={`max-w-44 text-right text-xs ${state.kind === "error" ? "text-bad" : "text-soft"}`}>{text}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,12 +14,21 @@ const paragraph = (text: string) => (_: string, el: Element | null) => el?.tagNa
|
||||
const posts = (fetch: ReturnType<typeof serve>) => fetch.mock.calls.filter(([, init]) => init?.method === "POST");
|
||||
|
||||
describe("Settings", () => {
|
||||
it("describes each quick action in one line", async () => {
|
||||
serve({ "api/setpoints": example("setpoints") });
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
expect(await screen.findByText("Ciepła woda – oszczędnie: 45 °C na 2 godz.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Obniża temperatury na czas nieobecności, do dnia powrotu")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("writes only after the summary is confirmed", async () => {
|
||||
const fetch = serve({ "api/setpoints": example("setpoints") }, () => answer(example("setpoints-applied")));
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
await userEvent.click(await screen.findByLabelText("Więcej: Ciepła woda, oszczędnie"));
|
||||
await userEvent.click(await screen.findByLabelText("Więcej: Ciepła woda – oszczędnie"));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Zapisz" }));
|
||||
expect(posts(fetch)).toHaveLength(0);
|
||||
expect(screen.getByText("Ustawię 9 °C. Za 2 godz. wrócimy do 8 °C.")).toBeInTheDocument();
|
||||
@@ -35,7 +44,7 @@ describe("Settings", () => {
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
await userEvent.click(await screen.findByLabelText("Mniej: Ogrzewanie domu, komfort"));
|
||||
await userEvent.click(await screen.findByLabelText("Mniej: Ogrzewanie – komfort"));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Zapisz" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Tak, zapisz" }));
|
||||
|
||||
@@ -48,7 +57,7 @@ describe("Settings", () => {
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
await userEvent.click(await screen.findByLabelText("Więcej: Ciepła woda, komfort"));
|
||||
await userEvent.click(await screen.findByLabelText("Więcej: Ciepła woda – komfort"));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Zapisz" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Tak, zapisz" }));
|
||||
|
||||
@@ -61,15 +70,15 @@ describe("Settings", () => {
|
||||
render(<Settings />);
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Wyjazd" }));
|
||||
expect(screen.getByText(paragraph("Ciepła woda, komfort: 10 °C do powrotu, potem wróci 45 °C."))).toBeInTheDocument();
|
||||
expect(screen.getByText(paragraph("Ciepła woda – komfort: 10 °C do powrotu, potem wróci 45 °C."))).toBeInTheDocument();
|
||||
expect(posts(fetch)).toHaveLength(0);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Ustaw wyjazd" }));
|
||||
expect(posts(fetch)[0][0]).toBe("api/presets/away");
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert).toHaveTextContent("Tylko część zmian jest zapisana.");
|
||||
expect(alert).toHaveTextContent("Ciepła woda, komfort: zapisano");
|
||||
expect(alert).toHaveTextContent("Ogrzewanie domu, komfort: brak odpowiedzi, nie wiadomo, czy doszło do pompy");
|
||||
expect(alert).toHaveTextContent("Ciepła woda – komfort: zapisano");
|
||||
expect(alert).toHaveTextContent("Ogrzewanie – komfort: brak odpowiedzi, nie wiadomo, czy doszło do pompy");
|
||||
});
|
||||
|
||||
it("asks for the return time of a trip and sends the chosen time with the local offset", async () => {
|
||||
@@ -107,10 +116,10 @@ describe("Settings", () => {
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Wyjazd" }));
|
||||
expect(screen.getAllByRole("status").map((o) => o.textContent)).toEqual(["10 °C", "15 °C"]);
|
||||
|
||||
await userEvent.click(screen.getByLabelText("Więcej: Ciepła woda, komfort"));
|
||||
await userEvent.click(screen.getByLabelText("Więcej: Ciepła woda, komfort"));
|
||||
await userEvent.click(screen.getByLabelText("Mniej: Ogrzewanie domu, komfort"));
|
||||
expect(screen.getByText(paragraph("Ciepła woda, komfort: 12 °C do powrotu, potem wróci 45 °C."))).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByLabelText("Więcej: Ciepła woda – komfort"));
|
||||
await userEvent.click(screen.getByLabelText("Więcej: Ciepła woda – komfort"));
|
||||
await userEvent.click(screen.getByLabelText("Mniej: Ogrzewanie – komfort"));
|
||||
expect(screen.getByText(paragraph("Ciepła woda – komfort: 12 °C do powrotu, potem wróci 45 °C."))).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Ustaw wyjazd" }));
|
||||
|
||||
const body = JSON.parse(String(posts(fetch)[0][1]!.body));
|
||||
@@ -125,8 +134,8 @@ describe("Settings", () => {
|
||||
render(<Settings />);
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Wyjazd" }));
|
||||
expect(screen.getByLabelText("Więcej: Ogrzewanie domu, komfort")).toBeDisabled();
|
||||
expect(screen.getByText(paragraph("Ogrzewanie domu, komfort: 14 °C do powrotu, potem wróci 15 °C."))).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Więcej: Ogrzewanie – komfort")).toBeDisabled();
|
||||
expect(screen.getByText(paragraph("Ogrzewanie – komfort: 14 °C do powrotu, potem wróci 15 °C."))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables a trip temperature that cannot be lowered and the confirmation with it", async () => {
|
||||
@@ -137,8 +146,8 @@ describe("Settings", () => {
|
||||
render(<Settings />);
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Wyjazd" }));
|
||||
expect(screen.getByLabelText("Mniej: Ogrzewanie domu, komfort")).toBeDisabled();
|
||||
expect(screen.getByLabelText("Więcej: Ogrzewanie domu, komfort")).toBeDisabled();
|
||||
expect(screen.getByLabelText("Mniej: Ogrzewanie – komfort")).toBeDisabled();
|
||||
expect(screen.getByLabelText("Więcej: Ogrzewanie – komfort")).toBeDisabled();
|
||||
expect(screen.getByText("Już na minimum (5 °C), nie da się obniżyć.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Ustaw wyjazd" })).toBeDisabled();
|
||||
});
|
||||
@@ -189,7 +198,7 @@ describe("Settings", () => {
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
expect(await screen.findByText(paragraph("Ciepła woda, komfort: 10 °C do 2 lis, 18:00, potem 43 °C."))).toBeInTheDocument();
|
||||
expect(await screen.findByText(paragraph("Ciepła woda – komfort: 10 °C do 2 lis, 18:00, potem 43 °C."))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("labels a change without an end time as lasting while the server runs", async () => {
|
||||
@@ -200,7 +209,7 @@ describe("Settings", () => {
|
||||
render(<Settings />);
|
||||
|
||||
expect(
|
||||
await screen.findByText(paragraph("Ciepła woda, komfort: 10 °C do odwołania (dopóki serwer działa), potem 45 °C.")),
|
||||
await screen.findByText(paragraph("Ciepła woda – komfort: 10 °C do odwołania (dopóki serwer działa), potem 45 °C.")),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -211,13 +220,13 @@ describe("Settings", () => {
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
expect(await screen.findByLabelText("Więcej: Ciepła woda, oszczędnie")).toBeDisabled();
|
||||
expect(screen.getByLabelText("Mniej: Ciepła woda, oszczędnie")).toBeDisabled();
|
||||
expect(await screen.findByLabelText("Więcej: Ciepła woda – oszczędnie")).toBeDisabled();
|
||||
expect(screen.getByLabelText("Mniej: Ciepła woda – oszczędnie")).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Ciepła woda teraz (brak odczytu)" })).toBeDisabled();
|
||||
});
|
||||
|
||||
describe("restore now", () => {
|
||||
const restoreButton = () => screen.findByRole("button", { name: "Przywróć teraz: Ciepła woda, oszczędnie" });
|
||||
const restoreButton = () => screen.findByRole("button", { name: "Przywróć teraz: Ciepła woda – oszczędnie" });
|
||||
const restoreAnswer = (outcome: string, status: number) => () => answer({ outcome, message: "x" }, status);
|
||||
|
||||
it("asks first, then restores and reloads the state", async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { postJson, WriteError } from "./api";
|
||||
import { Accounts } from "./Accounts";
|
||||
import { ChangeHistory } from "./ChangeHistory";
|
||||
import { dateTime, degrees, duration, localInput, withOffset } from "./format";
|
||||
import { reload, usePoll } from "./poll";
|
||||
@@ -8,10 +9,10 @@ import { Button, Card, Freshness, Loading, PageTitle, Stepper } from "./ui";
|
||||
|
||||
const URL = "api/setpoints";
|
||||
const NAMES: Record<string, string> = {
|
||||
cwu_economy: "Ciepła woda, oszczędnie",
|
||||
cwu_comfort: "Ciepła woda, komfort",
|
||||
co_economy: "Ogrzewanie domu, oszczędnie",
|
||||
co_comfort: "Ogrzewanie domu, komfort",
|
||||
cwu_economy: "Ciepła woda – oszczędnie",
|
||||
cwu_comfort: "Ciepła woda – komfort",
|
||||
co_economy: "Ogrzewanie – oszczędnie",
|
||||
co_comfort: "Ogrzewanie – komfort",
|
||||
};
|
||||
const DURATIONS = [
|
||||
{ hours: 1, label: "1 godz." }, { hours: 2, label: "2 godz." },
|
||||
@@ -144,6 +145,11 @@ function ReturnForm({ data, preset, busy, onCancel, onConfirm }: {
|
||||
);
|
||||
}
|
||||
|
||||
// What a preset does, in one line under its button; a held preset has no fixed values (the user picks them).
|
||||
const presetHint = (p: Preset) => p.until
|
||||
? "Obniża temperatury na czas nieobecności, do dnia powrotu"
|
||||
: p.changes.map((c) => `${name(c.setpoint)}: ${degrees(c.value)}${c.hours == null ? " do odwołania" : ` na ${duration(c.hours)}`}`).join(", ");
|
||||
|
||||
function changeSentence(data: Setpoints, c: Change, held: boolean) {
|
||||
const back = degrees(returnValue(data, c.setpoint));
|
||||
const tail = held ? `do powrotu, potem wróci ${back}`
|
||||
@@ -284,15 +290,21 @@ export function Settings() {
|
||||
{data.presets.map((p) => {
|
||||
const ready = presetReady(data, p.changes);
|
||||
return (
|
||||
<Button key={p.id} big onClick={() => { setResult(null); setPresetId(p.id); }} disabled={busy || !ready}>
|
||||
{p.name}{ready ? "" : " (brak odczytu)"}
|
||||
</Button>
|
||||
<div key={p.id} className="flex flex-col gap-1.5">
|
||||
<Button big onClick={() => { setResult(null); setPresetId(p.id); }} disabled={busy || !ready}>
|
||||
{p.name}{ready ? "" : " (brak odczytu)"}
|
||||
</Button>
|
||||
<p className="text-center text-sm text-soft">{presetHint(p)}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<Card title="Nastawy">
|
||||
<p className="mb-3 text-sm text-soft">
|
||||
„Komfort” i „oszczędnie” to dwie temperatury, między którymi pompa przełącza się sama według tygodniowego programu na sterowniku.
|
||||
</p>
|
||||
{data.setpoints.map((s) => {
|
||||
const shown = draft?.name === s.name ? draft.value : s.value;
|
||||
const label = name(s.name);
|
||||
@@ -332,6 +344,7 @@ export function Settings() {
|
||||
</Card>
|
||||
</div>
|
||||
<ChangeHistory />
|
||||
<Accounts />
|
||||
</>
|
||||
);
|
||||
}
|
||||
+32
-19
@@ -3,11 +3,13 @@ import { Bar, CartesianGrid, ComposedChart, Line, ResponsiveContainer, Tooltip,
|
||||
import { DASH, degrees, number } from "./format";
|
||||
import { usePoll } from "./poll";
|
||||
import type { Stats as StatsData, StatsDay } from "./types";
|
||||
import { RangeToggle } from "./SubView";
|
||||
import { Card, Freshness, Loading } from "./ui";
|
||||
|
||||
const RANGES = { "7d": "7 dni", "30d": "30 dni" } as const;
|
||||
type RangeId = keyof typeof RANGES;
|
||||
|
||||
const STATS_MS = 5 * 60000; // the backend reads every raw heat pump row of the range for each request
|
||||
const TAKTING_STARTS_PER_DAY = 24;
|
||||
const BAR = "#0f8a5f";
|
||||
const TEMPERATURE = "#3b82f6";
|
||||
@@ -26,12 +28,14 @@ export function takting(days: StatsDay[]) {
|
||||
return days.some((d) => (d.compressorStarts ?? 0) > TAKTING_STARTS_PER_DAY);
|
||||
}
|
||||
|
||||
function Tile({ label, value, hint, alert = false }: { label: string; value: string; hint?: string; alert?: boolean }) {
|
||||
function Tile({ label, value, hint, alert = false, wide = false }: {
|
||||
label: string; value: string; hint?: string; alert?: boolean; wide?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div data-alert={alert} className={`rounded-xl border p-3 ${alert ? "border-alarm bg-run-soft" : "border-edge bg-page"}`}>
|
||||
<div data-alert={alert} className={`rounded-xl border p-3 ${wide ? "col-span-2 lg:col-span-1" : ""} ${alert ? "border-alarm bg-run-soft" : "border-edge bg-page"}`}>
|
||||
<p className="text-2xl font-bold tabular-nums">{value}</p>
|
||||
<p className="text-sm text-soft">{label}</p>
|
||||
{hint && <p className="text-xs text-soft">{hint}</p>}
|
||||
{hint && <p className="text-sm text-soft">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -41,27 +45,43 @@ function Totals({ stats }: { stats: StatsData }) {
|
||||
return (
|
||||
<div className="mb-4 grid grid-cols-2 gap-3 lg:grid-cols-5">
|
||||
<Tile label="Sprężarka" value={hours(t.compressorHours)} />
|
||||
<Tile label="Załączenia sprężarki" value={`co najmniej ${number(t.compressorStarts)}`} />
|
||||
<Tile label="Załączenia sprężarki, co najmniej" value={number(t.compressorStarts)} />
|
||||
<Tile label="Ciepła woda (CWU)" value={hours(t.cwuHours)} />
|
||||
<Tile label="Ogrzewanie (CO)" value={hours(t.coHours)} />
|
||||
<Tile label="Grzałka" value={hours(t.heaterHours)} alert={t.heaterHours > 0}
|
||||
<Tile label="Grzałka" value={hours(t.heaterHours)} wide alert={t.heaterHours > 0}
|
||||
hint={t.heaterHours > 0 ? "Prąd z grzałki jest drogi" : undefined} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartLegend() {
|
||||
return (
|
||||
<ul className="mb-2 flex flex-wrap gap-x-4 gap-y-1 text-sm text-soft">
|
||||
<li className="flex items-center gap-1.5">
|
||||
<span className="size-3 rounded-sm" style={{ background: BAR }} aria-hidden="true" />słupki: godziny pracy sprężarki
|
||||
</li>
|
||||
<li className="flex items-center gap-1.5">
|
||||
<span className="h-1 w-4 rounded-full" style={{ background: TEMPERATURE }} aria-hidden="true" />linia: średnia temp. zewnętrzna
|
||||
</li>
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function DailyChart({ days }: { days: StatsDay[] }) {
|
||||
const rows = days.map((d) => ({ label: dayLabel(d.date), hours: d.compressorHours, outdoor: d.avgOutdoorTemp }));
|
||||
return (
|
||||
<div style={{ height: 280 }} className="w-full" role="img" aria-label="Godziny pracy sprężarki w kolejnych dniach">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={rows} margin={{ top: 8, right: 0, bottom: 0, left: -8 }}>
|
||||
<ComposedChart data={rows} margin={{ top: 8, right: 0, bottom: 0, left: 0 }}>
|
||||
<CartesianGrid stroke="var(--color-edge)" vertical={false} />
|
||||
<XAxis dataKey="label" stroke="var(--color-soft)" fontSize={12} tickLine={false} minTickGap={12} />
|
||||
<YAxis yAxisId="hours" stroke="var(--color-soft)" fontSize={12} tickLine={false} axisLine={false} width={40}
|
||||
domain={[0, "auto"]} tickFormatter={(v: number) => number(v, 0)} />
|
||||
<YAxis yAxisId="hours" stroke="var(--color-soft)" fontSize={12} tickLine={false} axisLine={false} width={44}
|
||||
domain={[0, (max: number) => Math.ceil(max / 2) * 2]} tickCount={5} allowDecimals={false}
|
||||
tickFormatter={(v: number) => number(v, 0)}
|
||||
label={{ value: "godz. pracy (h)", angle: -90, position: "insideLeft", offset: 4, fill: "var(--color-soft)", fontSize: 12 }} />
|
||||
<YAxis yAxisId="temperature" orientation="right" stroke="var(--color-soft)" fontSize={12} tickLine={false}
|
||||
axisLine={false} width={40} tickFormatter={(v: number) => number(v, 0)} />
|
||||
axisLine={false} width={44} allowDecimals={false} tickFormatter={(v: number) => number(v, 0)}
|
||||
label={{ value: "°C", angle: 90, position: "insideRight", offset: 4, fill: "var(--color-soft)", fontSize: 12 }} />
|
||||
<Tooltip
|
||||
formatter={(v, name) => [name === "Średnia temperatura zewnętrzna" ? degrees(Number(v)) : hours(Number(v)), name]}
|
||||
contentStyle={{ background: "var(--color-panel)", border: "1px solid var(--color-edge)", borderRadius: 12 }}
|
||||
@@ -77,7 +97,7 @@ function DailyChart({ days }: { days: StatsDay[] }) {
|
||||
|
||||
function StatsFor({ range }: { range: RangeId }) {
|
||||
// One poller per range, so an answer is never shown for the other one.
|
||||
const { data, offline } = usePoll<StatsData>(`api/stats?range=${range}`);
|
||||
const { data, offline } = usePoll<StatsData>(`api/stats?range=${range}`, STATS_MS);
|
||||
if (!data) return <><Freshness offline={offline} />{!offline && <Loading />}</>;
|
||||
const withData = data.days.filter((d) => d.compressorHours != null);
|
||||
return (
|
||||
@@ -91,9 +111,9 @@ function StatsFor({ range }: { range: RangeId }) {
|
||||
Dużo załączeń – sprężarka może taktować, czyli często się włączać i wyłączać na krótko. To zużywa ją szybciej.
|
||||
</p>
|
||||
)}
|
||||
<ChartLegend />
|
||||
<DailyChart days={data.days} />
|
||||
<p className="mt-2 text-sm text-soft">
|
||||
Zielone słupki: godziny pracy sprężarki, niebieska linia: średnia temperatura na zewnątrz.
|
||||
Wartości liczone z pomiarów co 5 minut, więc załączenia są policzone „co najmniej”: krótkie cykle między pomiarami są niewidoczne.
|
||||
</p>
|
||||
</>
|
||||
@@ -106,14 +126,7 @@ export function Stats() {
|
||||
const [range, setRange] = useState<RangeId>("7d");
|
||||
return (
|
||||
<Card>
|
||||
<div className="mb-4 flex gap-2 rounded-xl bg-page p-1 sm:w-fit">
|
||||
{(Object.entries(RANGES) as [RangeId, string][]).map(([id, name]) => (
|
||||
<button key={id} onClick={() => setRange(id)} aria-pressed={id === range}
|
||||
className="min-h-10 flex-1 rounded-lg px-4 font-semibold text-soft aria-pressed:bg-panel aria-pressed:text-brand aria-pressed:shadow-sm">
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mb-4"><RangeToggle label="Okres" options={RANGES} value={range} onSelect={setRange} /></div>
|
||||
<StatsFor range={range} />
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -27,4 +27,27 @@ describe("Status", () => {
|
||||
expect(versions()).toHaveTextContent("Bramkanieznana");
|
||||
expect(versions()).toHaveTextContent("Serwerv1.3.0");
|
||||
});
|
||||
|
||||
it("does not call a device working when its last reading is old", async () => {
|
||||
const health = example<Health>("health");
|
||||
const pump = health.devices[0];
|
||||
health.devices[0] = { ...pump, lastSuccess: new Date(Date.now() - 17 * 3600_000).toISOString() };
|
||||
serve({ "api/health": health });
|
||||
const Status = await status();
|
||||
render(<Status />);
|
||||
|
||||
expect(await screen.findByText("brak świeżych danych")).toBeInTheDocument();
|
||||
expect(screen.queryByText("działa")).toBeNull();
|
||||
});
|
||||
|
||||
it("says a device works when it answered a moment ago", async () => {
|
||||
const health = example<Health>("health");
|
||||
health.devices[0] = { ...health.devices[0], lastSuccess: new Date().toISOString() };
|
||||
serve({ "api/health": health });
|
||||
const Status = await status();
|
||||
render(<Status />);
|
||||
|
||||
expect(await screen.findByText("działa")).toBeInTheDocument();
|
||||
expect(screen.queryByText("brak świeżych danych")).toBeNull();
|
||||
});
|
||||
});
|
||||
+10
-3
@@ -1,7 +1,9 @@
|
||||
import { Account } from "./Auth";
|
||||
import { ago } from "./format";
|
||||
import { Icon } from "./icons";
|
||||
import { usePoll } from "./poll";
|
||||
import type { Health } from "./types";
|
||||
import { Card, Dot, Freshness, Loading, PageTitle, Row } from "./ui";
|
||||
import { Card, Dot, Freshness, isStale, Loading, PageTitle, Row } from "./ui";
|
||||
|
||||
const DEVICE_NAMES: Record<string, string> = {
|
||||
"heat-pump": "Pompa ciepła",
|
||||
@@ -30,14 +32,19 @@ export function Status() {
|
||||
{data.devices.length === 0 ? <p className="text-soft">Lista pojawi się, gdy dom odpowie.</p>
|
||||
: data.devices.map((d) => {
|
||||
const last = d.lastSuccess ? `ostatni odczyt ${ago(d.lastSuccess)}` : "brak odczytu";
|
||||
// "Up" says the last try worked; a reading that old is no longer news.
|
||||
const old = d.up && d.lastSuccess != null && isStale(d.lastSuccess);
|
||||
return (
|
||||
<Row key={d.name} icon={<Dot tone={d.up ? "ok" : "bad"} />}
|
||||
<Row key={d.name} icon={<Dot tone={d.up ? (old ? "warn" : "ok") : "bad"} />}
|
||||
label={<span><span className="text-ink">{DEVICE_NAMES[d.name] ?? d.name}</span><br />
|
||||
<span className="text-xs">{d.up ? last : `nie odpowiada, ${last}`}</span></span>}
|
||||
value={<span className={d.up ? "text-brand" : "text-alarm"}>{d.up ? "działa" : "brak"}</span>} />
|
||||
value={d.up ? (old ? <span className="inline-flex items-center gap-1 text-amber-700 dark:text-amber-300">
|
||||
<Icon name="alert" className="size-4" />brak świeżych danych</span> : <span className="text-brand">działa</span>)
|
||||
: <span className="text-alarm">brak</span>} />
|
||||
);
|
||||
})}
|
||||
</Card>
|
||||
<Account card />
|
||||
<Card title="Wersje">
|
||||
<Row label="Aplikacja" value={__APP_VERSION__} />
|
||||
<Row label="Serwer" value={data.backendVersion} />
|
||||
|
||||
@@ -41,3 +41,19 @@ export function SubViewSwitch<T extends string>({ screen, label, views, view, on
|
||||
export function SubViewPanel({ screen, view, children }: { screen: string; view: string; children: ReactNode }) {
|
||||
return <div role="tabpanel" id={`${screen}-view`} aria-labelledby={`${screen}-tab-${view}`}>{children}</div>;
|
||||
}
|
||||
|
||||
/** Two or three choices of one setting (a range); the chosen one is filled so it reads at a glance. */
|
||||
export function RangeToggle<T extends string>({ label, options, value, onSelect }: {
|
||||
label: string; options: Record<T, string>; value: T; onSelect: (id: T) => void;
|
||||
}) {
|
||||
return (
|
||||
<div role="group" aria-label={label} className="flex w-full gap-1 rounded-xl border border-edge bg-page p-1 sm:w-fit">
|
||||
{(Object.entries(options) as [T, string][]).map(([id, name]) => (
|
||||
<button key={id} onClick={() => onSelect(id)} aria-pressed={id === value}
|
||||
className="min-h-11 min-w-28 flex-1 whitespace-nowrap rounded-lg px-4 font-semibold text-soft focus-visible:outline-2 focus-visible:outline-brand aria-pressed:bg-brand aria-pressed:text-white dark:aria-pressed:text-black">
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { timeTicks, valueTicks } from "./TimeChart";
|
||||
|
||||
const HOUR = 3600000;
|
||||
const start = Date.parse("2026-10-23T00:00:00+02:00");
|
||||
|
||||
describe("timeTicks", () => {
|
||||
it("puts 4 to 6 ticks on a day, at round hours", () => {
|
||||
const ticks = timeTicks(start + HOUR / 2, start + 24 * HOUR, "24h");
|
||||
expect(ticks.length).toBeGreaterThanOrEqual(4);
|
||||
expect(ticks.length).toBeLessThanOrEqual(6);
|
||||
expect(ticks.map((t) => ((t - start) / HOUR) % 6)).toEqual(ticks.map(() => 0));
|
||||
});
|
||||
|
||||
it("uses finer steps for a short span", () => {
|
||||
expect(timeTicks(start, start + 2 * HOUR, "24h")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("puts a tick on every midnight of 7 days (the clocks change on the 25th)", () => {
|
||||
expect(timeTicks(start + HOUR, start + 7 * 24 * HOUR, "7d")).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("valueTicks", () => {
|
||||
it("hugs the data with round steps", () => {
|
||||
expect(valueTicks(14.1, 44.2)).toEqual([10, 20, 30, 40, 50]);
|
||||
});
|
||||
|
||||
it("copes with flat data", () => {
|
||||
expect(valueTicks(5, 5)).toEqual([4, 6]);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,6 @@ import type { Point } from "./types";
|
||||
export type Range = "24h" | "7d";
|
||||
|
||||
const HOUR_MS = 3600000;
|
||||
const HOURS_BETWEEN_TICKS = 6;
|
||||
const GAP_FACTOR = 3; // of the median step between points
|
||||
const DAY_NAMES = ["nd", "pn", "wt", "śr", "cz", "pt", "sb"];
|
||||
|
||||
@@ -34,14 +33,28 @@ export function toRows(series: Record<string, Point[]>): Row[] {
|
||||
return sorted.flatMap((row, i) => (i && row.t - sorted[i - 1].t > limit ? [{ t: row.t - 1 }, row] : [row]));
|
||||
}
|
||||
|
||||
// Warsaw midnights (or every 6th full Warsaw hour) inside the range, so the axis reads like a clock.
|
||||
const HOUR_STEPS = [1, 2, 3, 6, 12];
|
||||
const MAX_TICKS = 6;
|
||||
|
||||
// Warsaw midnights (7 days) or the finest whole-hour step with at most 6 ticks, so the axis reads like a clock.
|
||||
export function timeTicks(t0: number, t1: number, range: Range): number[] {
|
||||
const ticks = [];
|
||||
for (let t = Math.ceil(t0 / HOUR_MS) * HOUR_MS; t <= t1; t += HOUR_MS) {
|
||||
const h = warsaw(t).hour;
|
||||
if (range === "7d" ? h === 0 : h % HOURS_BETWEEN_TICKS === 0) ticks.push(t);
|
||||
}
|
||||
return ticks;
|
||||
const wholeHours: { t: number; h: number }[] = [];
|
||||
for (let t = Math.ceil(t0 / HOUR_MS) * HOUR_MS; t <= t1; t += HOUR_MS) wholeHours.push({ t, h: warsaw(t).hour });
|
||||
if (range === "7d") return wholeHours.filter(({ h }) => h === 0).map(({ t }) => t);
|
||||
const ticksAt = (step: number) => wholeHours.filter(({ h }) => h % step === 0).map(({ t }) => t);
|
||||
const step = HOUR_STEPS.find((s) => ticksAt(s).length <= MAX_TICKS) ?? HOUR_STEPS[HOUR_STEPS.length - 1];
|
||||
return ticksAt(step);
|
||||
}
|
||||
|
||||
/** Round values around the data (steps of 1, 2 or 5 times a power of ten), about 4 intervals. */
|
||||
export function valueTicks(min: number, max: number): number[] {
|
||||
const span = max - min || Math.abs(max) || 1;
|
||||
const rough = span / 4;
|
||||
const magnitude = 10 ** Math.floor(Math.log10(rough));
|
||||
const step = ([1, 2, 5, 10].find((m) => m * magnitude >= rough) ?? 10) * magnitude;
|
||||
const first = Math.floor(min / step);
|
||||
const last = Math.ceil(max / step);
|
||||
return Array.from({ length: last - first + 1 }, (_, i) => Number(((first + i) * step).toPrecision(12)));
|
||||
}
|
||||
|
||||
export interface Line {
|
||||
@@ -56,6 +69,8 @@ export function TimeChart({ rows, lines, unit, range, height = 260, area = false
|
||||
}) {
|
||||
const times = rows.map((r) => r.t);
|
||||
const ticks = timeTicks(Math.min(...times), Math.max(...times), range);
|
||||
const values = rows.flatMap((r) => lines.map((l) => r[l.key])).filter((v): v is number => v != null);
|
||||
const valueAxis = valueTicks(Math.min(...values), Math.max(...values));
|
||||
const digits = unit === "°C" ? 1 : 0;
|
||||
const tick = (t: number) => (range === "7d" ? DAY_NAMES[warsaw(t + 1).weekday] : hour(new Date(t)));
|
||||
return (
|
||||
@@ -74,7 +89,8 @@ export function TimeChart({ rows, lines, unit, range, height = 260, area = false
|
||||
<XAxis dataKey="t" type="number" scale="time" domain={["dataMin", "dataMax"]} ticks={ticks}
|
||||
tickFormatter={tick} stroke="var(--color-soft)" fontSize={12} tickLine={false} />
|
||||
<YAxis stroke="var(--color-soft)" fontSize={12} tickLine={false} axisLine={false} width={48}
|
||||
domain={["auto", "auto"]} tickFormatter={(v: number) => number(v, 0)} />
|
||||
ticks={valueAxis} domain={[valueAxis[0], valueAxis[valueAxis.length - 1]]}
|
||||
tickFormatter={(v: number) => number(v, valueAxis.every(Number.isInteger) ? 0 : 1)} />
|
||||
<Tooltip
|
||||
labelFormatter={(t) => dayAndHour(new Date(Number(t)))}
|
||||
formatter={(v, name) => [`${number(Number(v), digits)} ${unit}`, name]}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { example, serve } from "./fixtures";
|
||||
import type { HeatPump, Weather } from "./types";
|
||||
@@ -25,7 +26,6 @@ describe("Pogoda card", () => {
|
||||
|
||||
const card = (await screen.findByRole("heading", { name: "Pogoda" })).closest("section")!;
|
||||
const [today, tomorrow, dayAfter] = within(card).getAllByRole("listitem");
|
||||
expect(card).toHaveTextContent("teraz 10,6 °C");
|
||||
expect(today).toHaveTextContent("Dziś");
|
||||
expect(today).toHaveTextContent("Pochmurno");
|
||||
expect(today).toHaveTextContent("10,9 °C / 3,8 °C");
|
||||
@@ -64,27 +64,39 @@ describe("Pogoda card", () => {
|
||||
const { Overview } = await import("./Overview");
|
||||
render(<Overview />);
|
||||
|
||||
await screen.findByRole("heading", { name: "Salon" });
|
||||
await screen.findByRole("heading", { name: "Szczegóły temperatur" });
|
||||
expect(screen.queryByRole("heading", { name: "Pogoda" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("IMGW warnings on the overview", () => {
|
||||
it("shows each warning as a banner with its degree, period and text", async () => {
|
||||
it("shows each warning as one line with its degree and period, and the text on a tap", async () => {
|
||||
await overview();
|
||||
|
||||
const banner = await screen.findByText(/Ostrzeżenie IMGW, stopień 2: Silny wiatr/);
|
||||
expect(banner.closest("[role=alert]")).toHaveTextContent("Prognozuje się wystąpienie silnego wiatru");
|
||||
expect(banner.closest("[role=alert]")).toHaveClass("border-orange-500");
|
||||
const line = await screen.findByText(/Ostrzeżenie IMGW, stopień 2: Silny wiatr/);
|
||||
const banner = line.closest("details")!;
|
||||
expect(banner).toHaveClass("border-orange-500");
|
||||
expect(banner).not.toHaveAttribute("open");
|
||||
await userEvent.click(line);
|
||||
expect(banner).toHaveAttribute("open");
|
||||
expect(banner).toHaveTextContent("Prognozuje się wystąpienie silnego wiatru");
|
||||
});
|
||||
|
||||
it("colours the degrees yellow, orange and red", async () => {
|
||||
await overview({ warnings: [warning("Mróz", 1), warning("Upał", 2), warning("Burza", 3)] });
|
||||
|
||||
await screen.findByText(/stopień 3: Burza/);
|
||||
expect(screen.getByText(/stopień 1: Mróz/).closest("[role=alert]")).toHaveClass("border-yellow-500");
|
||||
expect(screen.getByText(/stopień 2: Upał/).closest("[role=alert]")).toHaveClass("border-orange-500");
|
||||
expect(screen.getByText(/stopień 3: Burza/).closest("[role=alert]")).toHaveClass("border-alarm");
|
||||
expect(screen.getByText(/stopień 1: Mróz/).closest("details")).toHaveClass("border-yellow-500");
|
||||
expect(screen.getByText(/stopień 2: Upał/).closest("details")).toHaveClass("border-orange-500");
|
||||
expect(screen.getByText(/stopień 3: Burza/).closest("details")).toHaveClass("border-alarm");
|
||||
});
|
||||
|
||||
it("shows one banner for identical warnings and keeps ones that differ only in their end", async () => {
|
||||
const w = warning("Silny wiatr", 1);
|
||||
await overview({ warnings: [w, { ...w }, { ...w, to: "2026-10-26T18:00:00+01:00" }] });
|
||||
|
||||
await screen.findByRole("heading", { name: "Pogoda" });
|
||||
expect(screen.getAllByText(/stopień 1: Silny wiatr/)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("shows no banner without warnings", async () => {
|
||||
@@ -103,7 +115,7 @@ describe("IMGW hint on the battery", () => {
|
||||
await screen.findByText("Bateria teraz");
|
||||
}
|
||||
|
||||
const HINT = "Ostrzeżenie IMGW: rozważ trzymanie baterii pełnej (rezerwa na awarię).";
|
||||
const HINT = "Zapowiadany silny wiatr lub burza – zostaw baterię naładowaną (rezerwa na awarię).";
|
||||
|
||||
it("suggests keeping the battery full for strong wind", async () => {
|
||||
await battery([warning("Silny wiatr", 1)]);
|
||||
|
||||
+31
-26
@@ -35,12 +35,12 @@ const PICTURES: Record<Kind, { label: string; paths: string[]; colour: string }>
|
||||
storm: { label: "burza", paths: [CLOUD, "M12.5 14l-2.5 4h3l-1.5 4"], colour: "text-alarm" },
|
||||
};
|
||||
|
||||
export function WeatherIcon({ code }: { code: number | null }) {
|
||||
export function WeatherIcon({ code, className = "size-10" }: { code: number | null; className?: string }) {
|
||||
const kind = kindOf(code);
|
||||
if (!kind) return <span className="grid size-10 place-items-center text-soft">—</span>;
|
||||
if (!kind) return <span className={`grid shrink-0 place-items-center text-soft ${className}`}>—</span>;
|
||||
const { label, paths, colour } = PICTURES[kind];
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" role="img" aria-label={label} className={`size-10 ${colour}`} fill="none"
|
||||
<svg viewBox="0 0 24 24" role="img" aria-label={label} className={`shrink-0 ${className} ${colour}`} fill="none"
|
||||
stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round">
|
||||
{paths.map((d) => <path key={d} d={d} />)}
|
||||
</svg>
|
||||
@@ -52,15 +52,16 @@ const DAY_NAMES = ["Dziś", "Jutro", "Pojutrze"];
|
||||
function Day({ name, day }: { name: string; day: WeatherDay }) {
|
||||
const rain = day.precipitationMm;
|
||||
return (
|
||||
<li className="flex flex-col items-center gap-1 text-center">
|
||||
<b>{name}</b>
|
||||
<WeatherIcon code={day.weatherCode} />
|
||||
<span className="text-sm text-soft">{day.description}</span>
|
||||
<span className="tabular-nums"><b>{celsius(day.tMax)}</b> / {celsius(day.tMin)}</span>
|
||||
<span className="text-sm text-soft">Opady: {rain == null ? "—" : `${number(rain, 1)} mm`}</span>
|
||||
{day.pvForecastKwh != null && (
|
||||
<span className="text-sm font-semibold text-brand">PV ok. {number(day.pvForecastKwh)} kWh</span>
|
||||
)}
|
||||
<li className="flex items-center gap-3 border-t border-edge py-2.5 first:border-t-0 first:pt-0 last:pb-0">
|
||||
<WeatherIcon code={day.weatherCode} className="size-9" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p><b>{name}</b> <span className="text-soft">{day.description}</span></p>
|
||||
<p className="flex flex-wrap gap-x-3 text-xs text-soft">
|
||||
<span className="whitespace-nowrap">Opady: {rain == null ? "—" : `${number(rain, 1)} mm`}</span>
|
||||
{day.pvForecastKwh != null && <b className="whitespace-nowrap font-semibold text-brand">PV ok. {number(day.pvForecastKwh)} kWh</b>}
|
||||
</p>
|
||||
</div>
|
||||
<p className="whitespace-nowrap tabular-nums"><b>{celsius(day.tMax)}</b> / {celsius(day.tMin)}</p>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -68,9 +69,8 @@ function Day({ name, day }: { name: string; day: WeatherDay }) {
|
||||
export function WeatherCard({ weather }: { weather: Weather }) {
|
||||
const days = [weather.today, weather.tomorrow, weather.dayAfter];
|
||||
return (
|
||||
<Card title="Pogoda" aside={<span className="text-sm text-soft">teraz {celsius(weather.now.temperature)}</span>}
|
||||
className="lg:col-span-2">
|
||||
<ul className="grid grid-cols-3 gap-2">
|
||||
<Card title="Pogoda">
|
||||
<ul>
|
||||
{days.map((day, i) => <Day key={day.date} name={DAY_NAMES[i]} day={day} />)}
|
||||
</ul>
|
||||
{weather.stale && <p className="mt-3 text-xs text-soft">Nie udało się odświeżyć pogody, pokazuję starsze dane.</p>}
|
||||
@@ -84,20 +84,25 @@ const LEVELS: Record<number, string> = {
|
||||
3: "border-alarm bg-red-50 text-alarm dark:bg-red-950/60 dark:text-red-200",
|
||||
};
|
||||
|
||||
/** The IMGW warnings, one banner each, coloured by degree. The text is IMGW's own, shown as text. */
|
||||
/** The IMGW warnings, one line each (degree colour, event, period); the text is IMGW's own and opens on a tap. */
|
||||
export function WeatherWarnings({ warnings }: { warnings: WeatherWarning[] }) {
|
||||
// IMGW sends one warning per area, so the same one can arrive twice.
|
||||
const key = (w: WeatherWarning) => `${w.event}|${w.level}|${w.from}|${w.to}`;
|
||||
const distinct = warnings.filter((w, i) => warnings.findIndex((o) => key(o) === key(w)) === i);
|
||||
return (
|
||||
<>
|
||||
{warnings.map((w) => (
|
||||
<div key={`${w.event}${w.level}${w.from}`} role="alert"
|
||||
className={`mb-4 flex items-start gap-3 rounded-xl border-2 px-4 py-3 ${LEVELS[w.level] ?? LEVELS[1]}`}>
|
||||
<Icon name="alert" className="size-6 shrink-0" />
|
||||
<div>
|
||||
<p className="font-semibold">Ostrzeżenie IMGW, stopień {w.level}: {w.event}</p>
|
||||
<p className="text-sm">{dateTime(w.from)} – {dateTime(w.to)}</p>
|
||||
<p className="mt-1 text-sm">{w.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
{distinct.map((w) => (
|
||||
<details key={key(w)} role="alert"
|
||||
className={`mb-3 rounded-xl border-2 px-4 py-2 ${LEVELS[w.level] ?? LEVELS[1]}`}>
|
||||
<summary className="flex cursor-pointer items-center gap-3">
|
||||
<Icon name="alert" className="size-5 shrink-0" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<b>Ostrzeżenie IMGW, stopień {w.level}: {w.event}</b>
|
||||
<span className="block text-sm">{dateTime(w.from)} – {dateTime(w.to)}</span>
|
||||
</span>
|
||||
</summary>
|
||||
<p className="mt-2 text-sm">{w.text}</p>
|
||||
</details>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
+51
-6
@@ -1,15 +1,47 @@
|
||||
import type { CommandResult } from "./types";
|
||||
import type { CommandResult, RefreshAnswer } from "./types";
|
||||
|
||||
// A request that gets no answer in time is given up, so a screen never waits for ever.
|
||||
const READ_TIMEOUT_MS = 15000;
|
||||
const WRITE_TIMEOUT_MS = 20000;
|
||||
|
||||
// The custom header is what the backend uses to tell this page from another site's form.
|
||||
const POST_HEADERS = { "Content-Type": "application/json", "X-Requested-With": "home-pump" };
|
||||
|
||||
export class HttpError extends Error {
|
||||
constructor(url: string, readonly status: number) {
|
||||
super(`${url}: ${status}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Every call below reports a 401 here, so the app can go back to the login screen when the session ends.
|
||||
let onSessionExpired: () => void = () => undefined;
|
||||
export const whenSessionExpires = (listener: () => void) => {
|
||||
onSessionExpired = listener;
|
||||
};
|
||||
const checked = (response: Response) => {
|
||||
if (response.status === 401) onSessionExpired();
|
||||
return response;
|
||||
};
|
||||
|
||||
export async function getJson<T>(url: string): Promise<T> {
|
||||
const response = await fetch(url, { cache: "no-store", signal: AbortSignal.timeout(READ_TIMEOUT_MS) });
|
||||
if (!response.ok) throw new Error(`${url}: ${response.status}`);
|
||||
const response = checked(await fetch(url, { cache: "no-store", signal: AbortSignal.timeout(READ_TIMEOUT_MS) }));
|
||||
if (!response.ok) throw new HttpError(url, response.status);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// The login calls: the HTTP status and the JSON body (null when there is none), status 0 when the network fails.
|
||||
// A 401 here is a wrong password, not an expired session.
|
||||
export async function postAuth(url: string, body?: unknown): Promise<{ status: number; body: any }> {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST", headers: POST_HEADERS, body: JSON.stringify(body ?? {}), signal: AbortSignal.timeout(WRITE_TIMEOUT_MS),
|
||||
});
|
||||
return { status: response.status, body: await response.json().catch(() => null) };
|
||||
} catch {
|
||||
return { status: 0, body: null };
|
||||
}
|
||||
}
|
||||
|
||||
export class WriteError extends Error {
|
||||
constructor(readonly status: number) {
|
||||
super(`write failed: ${status}`);
|
||||
@@ -24,14 +56,27 @@ export async function postJson<T extends { status?: string; outcome?: string } =
|
||||
): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
// The custom header is what the backend uses to tell this page from another site's form.
|
||||
headers: { "Content-Type": "application/json", "X-Requested-With": "home-pump" },
|
||||
headers: POST_HEADERS,
|
||||
body: JSON.stringify(body ?? {}),
|
||||
signal: AbortSignal.timeout(WRITE_TIMEOUT_MS),
|
||||
}).catch(() => {
|
||||
}).then(checked, () => {
|
||||
throw new WriteError(0);
|
||||
});
|
||||
const answer = await response.json().catch(() => null);
|
||||
if (!answer?.status && !answer?.outcome) throw new WriteError(response.status);
|
||||
return answer;
|
||||
}
|
||||
|
||||
// Asks the gateway for fresh readings. Rejects with the HTTP status (503: no gateway), 0 when the network fails.
|
||||
export async function postRefresh(): Promise<RefreshAnswer> {
|
||||
const response = await fetch("api/refresh", {
|
||||
method: "POST",
|
||||
headers: POST_HEADERS,
|
||||
body: "{}",
|
||||
signal: AbortSignal.timeout(WRITE_TIMEOUT_MS),
|
||||
}).then(checked, () => {
|
||||
throw new WriteError(0);
|
||||
});
|
||||
if (!response.ok) throw new WriteError(response.status);
|
||||
return response.json();
|
||||
}
|
||||
@@ -57,6 +57,8 @@ export function warsaw(ms: number) {
|
||||
|
||||
export const hour = (date: Date) =>
|
||||
date.toLocaleTimeString("pl-PL", { hour: "2-digit", minute: "2-digit", timeZone: ZONE });
|
||||
export const hourWithSeconds = (date: Date) =>
|
||||
date.toLocaleTimeString("pl-PL", { hour: "2-digit", minute: "2-digit", second: "2-digit", timeZone: ZONE });
|
||||
export const weekday = (date: Date) => date.toLocaleDateString("pl-PL", { weekday: "short", timeZone: ZONE });
|
||||
export const dayAndHour = (date: Date) => `${weekday(date)} ${hour(date)}`;
|
||||
export const longDate = (date: Date) =>
|
||||
|
||||
@@ -16,6 +16,7 @@ const PATHS = {
|
||||
battery: "M3 7h15a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V8a1 1 0 0 1 1-1zM22 11v2M6 10v4M10 10v4",
|
||||
plug: "M9 2v6M15 2v6M6 8h12v4a6 6 0 0 1-12 0zM12 18v4",
|
||||
alert: "M12 3 2 21h20zM12 10v5M12 18v.5",
|
||||
refresh: "M20 11a8 8 0 1 0-2.3 6M20 4v7h-7",
|
||||
clock: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 7v5l3 2",
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
--color-page: #f2f6f3;
|
||||
--color-panel: #ffffff;
|
||||
--color-ink: #13201a;
|
||||
--color-soft: #5b6b63;
|
||||
--color-soft: #4f5f57;
|
||||
--color-edge: #e2e9e5;
|
||||
--color-brand: #0f8a5f;
|
||||
--color-brand-soft: #e3f4ec;
|
||||
@@ -29,10 +29,10 @@
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--color-page: #0b1310;
|
||||
--color-panel: #142019;
|
||||
--color-panel: #1a2a21;
|
||||
--color-ink: #eef5f1;
|
||||
--color-soft: #a9bab1;
|
||||
--color-edge: #24352c;
|
||||
--color-edge: #35493d;
|
||||
--color-brand: #3fd197;
|
||||
--color-brand-soft: #143628;
|
||||
--color-run: #ff7a8a;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import { AuthGate } from "./Auth";
|
||||
import "./index.css";
|
||||
import { applyTheme, storedTheme } from "./theme";
|
||||
|
||||
applyTheme(storedTheme()); // before the first paint, so a dark choice does not flash white
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<StrictMode><App /></StrictMode>);
|
||||
createRoot(document.getElementById("root")!).render(<StrictMode><AuthGate><App /></AuthGate></StrictMode>);
|
||||
|
||||
// Offline shell (public/manifest.webmanifest, src/sw.js); not in dev, where it would serve stale modules.
|
||||
if (import.meta.env.PROD && "serviceWorker" in navigator) {
|
||||
|
||||
@@ -67,3 +67,8 @@ export function usePoll<T>(url: string, intervalMs = REFRESH_MS): Polled<T> {
|
||||
|
||||
/** Asks again at once, e.g. after a write. */
|
||||
export const reload = (url: string) => refresh(url);
|
||||
|
||||
/** Asks again at once for everything that is on screen, e.g. after the gateway polled the devices. */
|
||||
export function refreshAll() {
|
||||
entries.forEach((entry, url) => entry.listeners.size > 0 && refresh(url));
|
||||
}
|
||||
+10
-1
@@ -8,11 +8,20 @@ function isShellRequest(request) {
|
||||
return request.method === "GET" && url.origin === self.location.origin && !url.pathname.includes("/api/");
|
||||
}
|
||||
|
||||
const NAVIGATION_TIMEOUT_MS = 4000;
|
||||
|
||||
// A stalled connection must not hang the app: after the timeout the cached shell opens instead.
|
||||
function fetchWithin(request, ms) {
|
||||
let timer;
|
||||
const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("timeout")), ms); });
|
||||
return Promise.race([fetch(request), timeout]).finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
||||
async function answer(request) {
|
||||
const cache = await caches.open(CACHE);
|
||||
if (request.mode === "navigate") {
|
||||
try {
|
||||
return await fetch(request); // network first, so an update shows at once
|
||||
return await fetchWithin(request, NAVIGATION_TIMEOUT_MS); // network first, so an update shows at once
|
||||
} catch {
|
||||
return cache.match("./index.html");
|
||||
}
|
||||
|
||||
+13
-1
@@ -40,7 +40,7 @@ async function run(type: "install" | "activate") {
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers(); });
|
||||
|
||||
describe("service worker", () => {
|
||||
it("leaves /api/ to the network", () => {
|
||||
@@ -70,6 +70,18 @@ describe("service worker", () => {
|
||||
expect(await respondWith.mock.calls[0][0]).toBe(index);
|
||||
});
|
||||
|
||||
it("opens the cached index.html when a navigation stalls", async () => {
|
||||
vi.useFakeTimers();
|
||||
const index = new Response("shell");
|
||||
cacheStore.set("./index.html", index);
|
||||
vi.stubGlobal("fetch", vi.fn(() => new Promise(() => undefined)));
|
||||
const respondWith = fetchEvent("https://pompa.test/", { mode: "navigate" });
|
||||
const answer = respondWith.mock.calls[0][0];
|
||||
await vi.advanceTimersByTimeAsync(4000);
|
||||
expect(await answer).toBe(index);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("precaches the shell on install", async () => {
|
||||
await run("install");
|
||||
expect(cache.addAll).toHaveBeenCalledWith(["./", "./index.html"]);
|
||||
|
||||
@@ -232,6 +232,11 @@ export interface RestoreResult {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface RefreshAnswer {
|
||||
requested: string[];
|
||||
skipped: { device: string; retryInSeconds: number }[];
|
||||
}
|
||||
|
||||
export interface CommandResult {
|
||||
status: "APPLIED" | "REJECTED" | "FAILED" | "PARTIAL";
|
||||
message?: string;
|
||||
|
||||
+18
-10
@@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { ageMinutes, ago } from "./format";
|
||||
import { RefreshButton } from "./Refresh";
|
||||
|
||||
export const STALE_MINUTES = 10;
|
||||
|
||||
@@ -36,7 +37,7 @@ const DOT: Record<Tone, string> = {
|
||||
|
||||
export function Pill({ tone, live = false, children }: { tone: Tone; live?: boolean; children: ReactNode }) {
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold ${PILL[tone]}`}>
|
||||
<span className={`inline-flex items-center gap-1.5 whitespace-nowrap rounded-full px-2.5 py-1 text-xs font-semibold ${PILL[tone]}`}>
|
||||
<Dot tone={tone} live={live} />
|
||||
{children}
|
||||
</span>
|
||||
@@ -58,17 +59,24 @@ const BANNER = {
|
||||
ok: "border-brand bg-brand-soft text-brand",
|
||||
};
|
||||
|
||||
export function Banner({ tone, children }: { tone: keyof typeof BANNER; children: ReactNode }) {
|
||||
/** Compact is for a note above a screen that must still show its content on the first page of a phone. */
|
||||
export function Banner({ tone, compact = false, children }: { tone: keyof typeof BANNER; compact?: boolean; children: ReactNode }) {
|
||||
return (
|
||||
<div role={tone === "ok" ? "status" : "alert"}
|
||||
className={`mb-4 rounded-xl border-2 px-4 py-3 font-semibold ${BANNER[tone]}`}>
|
||||
className={`rounded-xl border-2 font-semibold ${compact ? "mb-3 px-3 py-2 text-sm" : "mb-4 px-4 py-3"} ${BANNER[tone]}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The backend knows best when a source has gone quiet; the age check covers a slow gateway. */
|
||||
export const isStale = (time: string | null | undefined, stale = false): boolean =>
|
||||
!!time && (stale || ageMinutes(time) >= STALE_MINUTES);
|
||||
|
||||
/** Offline or old data, in words; nothing when the data is fresh. */
|
||||
export function Freshness({ offline, time, stale = false }: { offline: boolean; time?: string | null; stale?: boolean }) {
|
||||
export function Freshness({ offline, time, stale = false, compact = false }: {
|
||||
offline: boolean; time?: string | null; stale?: boolean; compact?: boolean;
|
||||
}) {
|
||||
if (offline) {
|
||||
return (
|
||||
<Banner tone="bad">
|
||||
@@ -77,9 +85,8 @@ export function Freshness({ offline, time, stale = false }: { offline: boolean;
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
// The backend knows best when a source has gone quiet; the age check covers a slow gateway.
|
||||
if (time && (stale || ageMinutes(time) >= STALE_MINUTES)) {
|
||||
return <Banner tone="warn">Dane są nieaktualne: {ago(time)}.</Banner>;
|
||||
if (isStale(time, stale)) {
|
||||
return <Banner tone="warn" compact={compact}>Dane są nieaktualne: {ago(time ?? null)}.</Banner>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -97,14 +104,15 @@ export function Row({ label, value, icon }: { label: ReactNode; value: ReactNode
|
||||
);
|
||||
}
|
||||
|
||||
export function PageTitle({ title, subtitle, aside }: { title: string; subtitle?: ReactNode; aside?: ReactNode }) {
|
||||
// Every screen starts with this, so the refresh button sits beside each title.
|
||||
export function PageTitle({ title, subtitle }: { title: string; subtitle?: ReactNode }) {
|
||||
return (
|
||||
<header className="mb-5 flex flex-wrap items-end justify-between gap-3">
|
||||
<header className="mb-5 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">{title}</h1>
|
||||
{subtitle && <p className="mt-1 text-sm text-soft">{subtitle}</p>}
|
||||
</div>
|
||||
{aside}
|
||||
<RefreshButton />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,9 @@ import { createHash } from "node:crypto";
|
||||
import { readFileSync, readdirSync, writeFileSync } from "node:fs";
|
||||
import type { Plugin } from "vite";
|
||||
|
||||
// Writes dist/sw.js from src/sw.js with the built files to precache; the cache name carries a hash
|
||||
// of that list, so a new build gets a new cache and the old one is removed on activate.
|
||||
// Writes dist/sw.js from src/sw.js with the built files to precache; the cache name carries a hash of
|
||||
// that list and of the public files' contents, so a new build gets a new cache and the old one is
|
||||
// removed on activate.
|
||||
function precachePlugin(): Plugin {
|
||||
return {
|
||||
name: "precache",
|
||||
@@ -14,7 +15,9 @@ function precachePlugin(): Plugin {
|
||||
writeBundle(options, bundle) {
|
||||
const files = [...Object.keys(bundle), ...readdirSync("public")].sort();
|
||||
const shell = ["./", ...files.map((file) => `./${file}`)];
|
||||
const version = createHash("sha256").update(shell.join()).digest("hex").slice(0, 12);
|
||||
const hash = createHash("sha256").update(shell.join());
|
||||
readdirSync("public").sort().forEach((file) => hash.update(readFileSync(`public/${file}`)));
|
||||
const version = hash.digest("hex").slice(0, 12);
|
||||
const source = readFileSync("src/sw.js", "utf8")
|
||||
.replace("__SHELL__", JSON.stringify(shell))
|
||||
.replace("__VERSION__", JSON.stringify(version));
|
||||
|
||||
Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user