From fddd44d948c392f7fd9c88856a719bc15e54e34c Mon Sep 17 00:00:00 2001 From: Sujoy Das Date: Wed, 26 Aug 2026 11:58:48 +0300 Subject: [PATCH 1/3] build(dev): turn Vaadin Copilot off from a Spring property, since spring-boot:run forks its JVM --- src/main/resources/application.properties | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 1701791..6130fab 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -55,4 +55,15 @@ spring.jpa.open-in-view=false vaadin.launch-browser=false +# Copilot off. The application is started to be looked at, not edited in the browser: +# its overlay sits on top of the running UI, and its edits write back into the sources +# behind whatever is building them. +# +# It has to be a Spring property and not a -D on the mvn command line. Copilot asks the +# running application's Environment for this key, and `spring-boot:run` forks a second +# JVM for that application — so a -D passed to Maven lands in the wrong process and the +# overlay comes up anyway. Dev mode only; a production build ships no Copilot to +# disable, and the key is inert there. +vaadin.copilot.enable=false + logging.level.io.binarycodes.whichday=DEBUG From b99fb5e0d1dc2c6ef99e4c6f8e983724e9d65f7c Mon Sep 17 00:00:00 2001 From: Sujoy Das Date: Wed, 26 Aug 2026 11:59:05 +0300 Subject: [PATCH 2/3] feat(access)!: choose login or anonymous at deploy time, and default to anonymous --- .claude/launch.json | 6 + CONTRIBUTING.md | 24 +- README.md | 65 ++- docs/REQUIREMENTS.md | 177 ++++++- .../0018-an-admin-code-can-be-guessed.md | 44 ++ ...0019-anonymous-names-accumulate-forever.md | 44 ++ environment/dev/README.md | 22 +- run.conf | 9 +- .../whichday/base/config/AccessMode.java | 55 +++ .../base/config/AccessModeConfiguration.java | 22 + .../security/AnonymousSecurityConfig.java | 43 ++ ...tyConfig.java => LoginSecurityConfig.java} | 41 +- .../base/security/SecurityHeadersConfig.java | 32 ++ .../whichday/base/ui/AppHeader.java | 6 +- .../binarycodes/whichday/base/ui/Counts.java | 11 + .../io/binarycodes/whichday/base/ui/Home.java | 30 ++ .../whichday/base/ui/IdentityGuard.java | 68 +++ .../binarycodes/whichday/base/ui/Toast.java | 32 ++ .../whichday/people/ui/AccountLabels.java | 23 + .../whichday/people/ui/AccountMenu.java | 10 +- .../whichday/people/ui/NameChips.java | 38 ++ .../ui/presenter/AnonymousViewerSession.java | 106 +++++ .../presenter/AuthenticatedViewerSession.java | 23 + .../people/ui/presenter/ViewerSession.java | 35 +- .../whichday/people/ui/view/IdentityView.java | 128 ++++++ .../whichday/poll/domain/Caller.java | 31 ++ .../whichday/poll/domain/DayTally.java | 12 +- .../whichday/poll/domain/Poll.java | 19 +- .../whichday/poll/service/PollService.java | 153 ++++-- .../whichday/poll/service/StoredPoll.java | 16 + .../whichday/poll/ui/component/DayBallot.java | 63 ++- .../whichday/poll/ui/component/DayChoice.java | 103 +++++ .../poll/ui/presenter/PollPresenter.java | 70 ++- .../poll/ui/share/CalendarInvite.java | 21 +- .../whichday/poll/ui/share/VotingLink.java | 6 +- .../whichday/poll/ui/view/BallotView.java | 14 +- .../poll/ui/view/CandidateDaysView.java | 12 +- .../poll/ui/view/InviteeSearchView.java | 14 +- .../whichday/poll/ui/view/LockedView.java | 40 +- .../whichday/poll/ui/view/NewPollView.java | 25 +- .../whichday/poll/ui/view/NoDayWorksView.java | 7 +- .../whichday/poll/ui/view/NotFoundView.java | 10 +- .../whichday/poll/ui/view/PollScreen.java | 5 +- .../whichday/poll/ui/view/PollsView.java | 17 +- .../whichday/poll/ui/view/ReceiptView.java | 2 +- .../whichday/poll/ui/view/ResultsView.java | 85 +++- .../whichday/poll/ui/view/SettleView.java | 109 +++++ .../whichday/poll/ui/view/ShareView.java | 76 ++- .../META-INF/resources/styles/colors.css | 2 +- .../META-INF/resources/styles/day-ballot.css | 13 + .../META-INF/resources/styles/poster.css | 9 + .../META-INF/resources/styles/screen.css | 17 + .../META-INF/resources/styles/share.css | 14 + .../META-INF/resources/styles/shell.css | 24 + .../META-INF/resources/styles/tally.css | 1 + .../application-anonymous.properties | 11 + .../resources/application-login.properties | 24 + src/main/resources/application.properties | 34 +- .../db/migration/V2__anonymous_admin_code.sql | 11 + .../vaadin-i18n/translations.properties | 57 ++- .../whichday/AnonymousWhichdayTest.java | 23 + .../java/io/binarycodes/whichday/Sample.java | 15 +- .../io/binarycodes/whichday/TestDatabase.java | 9 + .../io/binarycodes/whichday/WhichdayTest.java | 10 +- .../security/AnonymousSecurityConfigTest.java | 96 ++++ ...Test.java => LoginSecurityConfigTest.java} | 10 +- .../presenter/AnonymousViewerSessionTest.java | 126 +++++ .../service/AnonymousPollServiceTest.java | 168 +++++++ .../poll/service/PollServiceTest.java | 164 ++++--- .../poll/ui/presenter/PollPresenterTest.java | 19 +- .../poll/ui/share/CalendarInviteTest.java | 20 +- .../ui/view/AnonymousPollJourneyTest.java | 434 ++++++++++++++++++ .../poll/ui/view/PollJourneyTest.java | 141 +++++- .../resources/application-test.properties | 7 +- 74 files changed, 3168 insertions(+), 295 deletions(-) create mode 100644 docs/issues/0018-an-admin-code-can-be-guessed.md create mode 100644 docs/issues/0019-anonymous-names-accumulate-forever.md create mode 100644 src/main/java/io/binarycodes/whichday/base/config/AccessMode.java create mode 100644 src/main/java/io/binarycodes/whichday/base/config/AccessModeConfiguration.java create mode 100644 src/main/java/io/binarycodes/whichday/base/security/AnonymousSecurityConfig.java rename src/main/java/io/binarycodes/whichday/base/security/{SecurityConfig.java => LoginSecurityConfig.java} (75%) create mode 100644 src/main/java/io/binarycodes/whichday/base/security/SecurityHeadersConfig.java create mode 100644 src/main/java/io/binarycodes/whichday/base/ui/Home.java create mode 100644 src/main/java/io/binarycodes/whichday/base/ui/IdentityGuard.java create mode 100644 src/main/java/io/binarycodes/whichday/base/ui/Toast.java create mode 100644 src/main/java/io/binarycodes/whichday/people/ui/AccountLabels.java create mode 100644 src/main/java/io/binarycodes/whichday/people/ui/NameChips.java create mode 100644 src/main/java/io/binarycodes/whichday/people/ui/presenter/AnonymousViewerSession.java create mode 100644 src/main/java/io/binarycodes/whichday/people/ui/view/IdentityView.java create mode 100644 src/main/java/io/binarycodes/whichday/poll/domain/Caller.java create mode 100644 src/main/java/io/binarycodes/whichday/poll/ui/component/DayChoice.java create mode 100644 src/main/java/io/binarycodes/whichday/poll/ui/view/SettleView.java create mode 100644 src/main/resources/application-anonymous.properties create mode 100644 src/main/resources/application-login.properties create mode 100644 src/main/resources/db/migration/V2__anonymous_admin_code.sql create mode 100644 src/test/java/io/binarycodes/whichday/AnonymousWhichdayTest.java create mode 100644 src/test/java/io/binarycodes/whichday/base/security/AnonymousSecurityConfigTest.java rename src/test/java/io/binarycodes/whichday/base/security/{SecurityConfigTest.java => LoginSecurityConfigTest.java} (91%) create mode 100644 src/test/java/io/binarycodes/whichday/people/ui/presenter/AnonymousViewerSessionTest.java create mode 100644 src/test/java/io/binarycodes/whichday/poll/service/AnonymousPollServiceTest.java create mode 100644 src/test/java/io/binarycodes/whichday/poll/ui/view/AnonymousPollJourneyTest.java diff --git a/.claude/launch.json b/.claude/launch.json index ed7283a..8dcb183 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -6,6 +6,12 @@ "runtimeExecutable": "./run.sh", "runtimeArgs": ["preview"], "port": 8080 + }, + { + "name": "whichday-login", + "runtimeExecutable": "env", + "runtimeArgs": ["WHICHDAY_ACCESS_MODE=login", "./run.sh", "preview"], + "port": 8080 } ] } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5e5240b..118decf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,12 +24,24 @@ client and two people already in it — and `application.properties` defaults to that, so nothing needs configuring: ```bash -./run.sh env up ./run.sh run ``` -Open and it redirects you to Keycloak. Sign in as **`ada` / -`ada`**; `miro` / `miro` is the second person, which is who you invite. See +That is anonymous mode, which is the default and needs nothing brought up — no +Keycloak, no configuration. Open , type a name, and you are in. + +Login mode is the other half of the application and wants the development Keycloak: + +```bash +./run.sh env up +``` + +```bash +WHICHDAY_ACCESS_MODE=login ./run.sh run +``` + +It now redirects you to Keycloak. Sign in as **`ada` / `ada`**; `miro` / `miro` is the +second person, which is who you invite. See [`environment/dev/README.md`](environment/dev/README.md) for the realm, the admin console and how to add a third. @@ -37,6 +49,7 @@ To point at a provider of your own instead, set all three — the defaults are a laptop's and none of them belongs anywhere else: ```bash +export WHICHDAY_ACCESS_MODE=login export WHICHDAY_OIDC_ISSUER_URI=https://accounts.example.com export WHICHDAY_OIDC_CLIENT_ID=... export WHICHDAY_OIDC_CLIENT_SECRET=... @@ -44,6 +57,9 @@ export WHICHDAY_OIDC_CLIENT_SECRET=... Register `http://localhost:8080/login/oauth2/code/oidc` with that client. +Both modes share one database, so a poll made in one is visible in the other's tables — +which is a development convenience and nothing more. A deployment picks a mode once. + The database appears at `./data/whichday.mv.db` on first start and survives every restart, devtools included. `./run.sh resetdb` deletes it and the app creates an empty one next time. The tests never touch it — they run against an in-memory database. @@ -54,7 +70,7 @@ one next time. The tests never touch it — they run against an in-memory databa | Task | What it does | | --------- | ---------------------------------------------------------------- | -| `env` | The development Keycloak: `up` (default), `down`, `logs`, `reset` | +| `env` | The development Keycloak, for login mode only: `up` (default), `down`, `logs`, `reset` | | `run` | Start the app in dev mode | | `test` | Unit and browserless tests, with the JaCoCo gate | | `verify` | The same against a production build | diff --git a/README.md b/README.md index 0159d48..3ae7334 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,32 @@ only, multi-select voting, and the day with the most votes wins. A mobile-first Vaadin application on the Aura theme. +## Two ways in + +A deployment picks one, once, with `WHICHDAY_ACCESS_MODE`. It cannot be both, and it +cannot be switched at runtime. + +| | `anonymous` (the default) | `login` | +| --- | --- | --- | +| Getting in | type a name on the way past | an OIDC provider you run | +| Configuration | none | `WHICHDAY_OIDC_*`, and it will not start without them | +| Seeing a poll | anybody holding its link | the organizer or somebody invited | +| Answering one | anybody holding its link | the invitee, signed in at the address they were invited at | +| Changing one | the session that called it, or anybody with its six-digit admin code | the organizer | +| Home (`/`) | where a poll starts — there is no list | your polls, drafts and settled ones | +| Invitations | none; the link is the invitation | typed in by address, searched by account | + +**Anonymous mode is Doodle's bargain, and it is worth reading twice.** Anybody who has +the link can open the poll and answer it — that is the point of not having accounts. +The organizer gets a six-digit code on the share screen, and it is the only way back to +changing a poll once the browser tab is gone: identity lives in the session and nothing +else. Nobody is emailed, nobody is reminded, and nothing knows who has not answered, +because nothing knows who was asked. + +Choose `login` when the polls are a company's and it already has a provider. Choose +`anonymous` when the point is that a group can pick a day without anybody signing up +for anything. + ## Run it (self-hosting) A prebuilt, multi-architecture image (`linux/amd64` + `linux/arm64`) is published to @@ -22,10 +48,15 @@ It is one container with its database inside it, and nothing else to bring up. you want it. - **Run one container, not two.** The database is a file, and only the process holding it can open it — a second container on the same volume will not start. -- Signing in needs an OIDC client: `WHICHDAY_OIDC_ISSUER_URI`, - `WHICHDAY_OIDC_CLIENT_ID` and `WHICHDAY_OIDC_CLIENT_SECRET`. The application refuses - to start without the id and the secret. -- Behind a reverse proxy, see *Behind a proxy*. +- `WHICHDAY_ACCESS_MODE` is `anonymous` or `login`, and defaults to `anonymous`. + Anything else is a startup failure naming both. +- **`login` mode needs an OIDC client**: `WHICHDAY_OIDC_ISSUER_URI`, + `WHICHDAY_OIDC_CLIENT_ID` and `WHICHDAY_OIDC_CLIENT_SECRET`. It refuses to start + without the id and the secret, and it resolves the issuer as it starts — so an + unreachable provider is a container that will not come up. `anonymous` mode reads + none of the three, and does not need the provider to exist. +- Behind a reverse proxy, see *Behind a proxy*. It matters for `login` mode and for + the share links both modes hand out. ### docker compose @@ -38,6 +69,7 @@ services: volumes: - whichday-data:/app/data environment: + - WHICHDAY_ACCESS_MODE=login - WHICHDAY_OIDC_ISSUER_URI=https://accounts.example.com - WHICHDAY_OIDC_CLIENT_ID=change-me - WHICHDAY_OIDC_CLIENT_SECRET=change-me @@ -48,6 +80,14 @@ volumes: whichday-data: ``` +Anonymous mode is the same file with the four `WHICHDAY_OIDC_*` and `ACCESS_MODE` lines +dropped — it is the default, and it configures nothing: + +```yaml + environment: + - FORWARD_HEADERS_STRATEGY=native +``` + The left side of the mount is yours — the named volume above, or any host path. The right side has to be whatever `WHICHDAY_DATA_DIR` says. @@ -72,6 +112,7 @@ ContainerName=whichday Image=docker.io/binarycodes/whichday:latest PublishPort=8080:8080 Volume=whichday-data:/app/data +Environment=WHICHDAY_ACCESS_MODE=login Environment=WHICHDAY_OIDC_ISSUER_URI=https://accounts.example.com Environment=WHICHDAY_OIDC_CLIENT_ID=change-me Environment=WHICHDAY_OIDC_CLIENT_SECRET=change-me @@ -98,6 +139,9 @@ systemctl start whichday.service `AutoUpdate=registry` is safe here because systemd stops the old container before starting the new one, and only one process at a time may hold the database file. +Anonymous mode drops the same four lines here, leaving `FORWARD_HEADERS_STRATEGY` as +the only `Environment=` the unit needs. + ### Behind a proxy Anything that terminates TLS in front of this leaves the application seeing plain @@ -110,20 +154,25 @@ and the redirect URI comes out as the address readers typed. It is off by defaul because those headers are spoofable with nothing in front, so set it when there is a proxy and only then. -The redirect URI to register with your OIDC client is +In `login` mode, the redirect URI to register with your OIDC client is `https://whichday.example.com/login/oauth2/code/oidc`: the path is fixed, the origin is whatever readers type. ## The screens +Two of these are `login` mode's alone. In `anonymous` mode `/` is where a poll starts, +`/new/invitees` leads back there, and `/who` stands in front of everything. + | Route | What it is | | ------------------ | ----------------------------------------------------- | -| `/` | Your polls, your drafts, and the settled ones | +| `/` | Your polls, your drafts, and the settled ones — `login` mode | +| `/who` | Your name, and an admin code if you have one — `anonymous` mode | | `/new` | Name a poll and say who decides it | -| `/new/invitees` | Find people by email address | +| `/new/invitees` | Find people by email address — `login` mode | | `/poll/:id/days` | Put candidate days on the table | | `/poll/:id/share` | The voting link, the invite list, the closing date | -| `/poll/:id` | The standings, live, and the button that locks a date | +| `/poll/:id` | The standings, live, and the way to settle them | +| `/poll/:id/settle` | Confirm the day, or pick between tied ones | | `/poll/:id/locked` | The settled date | | `/vote/:id` | Tap every day that works | | `/vote/:id/none` | None of them work, and a day forward instead | diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index 6faa141..fb396ee 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -13,7 +13,27 @@ poll shared by everybody who was asked. Mobile first. --- -## 1. Signing in is the only way in +## 1. Two ways in, chosen at deploy time + +`WHICHDAY_ACCESS_MODE` picks one, once. It is `anonymous` unless a deployment says +otherwise, because that is the mode that needs nothing configured: the image runs, the +link works, and a deployment that wants accounts opts into them. + +The variable names a Spring profile outright, so `application-login.properties` and +`application-anonymous.properties` are where each mode's configuration lives and +nothing maps one to the other. Each of those files sets `whichday.access.mode`, and +that property — not `@Profile` — is what the code branches on, so a test composing +`@ActiveProfiles` cannot end up with both modes' beans or neither. An unknown value is +a startup failure naming both. + +**The OIDC block had to move out of `application.properties`, and that is +load-bearing.** Boot resolves an issuer by fetching its discovery document as the +context starts, so an anonymous deployment that inherited `issuer-uri` would hang on a +provider it has no reason to reach. Blanking the key is not the same thing — that fails +as "issuer cannot be empty". The key has to be genuinely absent, which is the same +lesson `src/test/resources/application-test.properties` already records. + +### 1a. Login mode: signing in is the only way in OIDC, and nothing else. There is no login view — `oauth2LoginPage` points straight at the registration, so an unauthenticated request redirects to the provider rather than @@ -45,6 +65,73 @@ An account whose provider withholds an email falls back to the OIDC subject, whi stable. Such a person can create polls; they cannot be invited to anybody else's, because a subject is not something an organizer can type into an invite field. +### 1b. Anonymous mode: a name, and the link + +There is no provider, so there is nobody to send anybody to. `AnonymousSecurityConfig` +turns Vaadin's navigation access control **off** rather than changing every route to +`@AnonymousAllowed`: Vaadin reads `@PermitAll` as *authenticated*, and nobody is, so +leaving the checker on would refuse every screen. Turning it off leaves the annotations +meaning what they mean in login mode, where they are still consulted. With the checker +off, Vaadin's request rules can no longer classify a URL by which view it reaches, so +the `permitAll` is stated in an explicit `authorizeHttpRequests` and the configurer is +told not to add one — otherwise every navigation logs that it could not tell whether +the URL was public. + +**A name is the whole of identity.** `IdentityGuard` stands in front of every route, +the shared voting link included, and forwards to `/who` — remembering where the browser +was going, because a guard that dropped the destination would turn every shared link +into a trip to the create screen. The screen asks two things: a name, and optionally the +six digits that say you called the poll you are heading for. + +**The address is minted, never typed.** `-@whichday.anonymous`, +once per session, from the injected `Clock`. An address anybody could type is an address +anybody could type twice, and every poll, ballot and invitee row the session writes is +keyed on it — so a second one would make the same person a stranger to their own +answers. The timestamp is for reading a database row or a log line; the UUID is what +tells two people apart. + +**The name goes into the `account` table**, which is not a claim that anybody +authenticated. It is the one place a name lives — a poll stores nothing but addresses +(§10) — so skipping the write means every screen reads the minted address back out +wherever a name belongs, including on other people's ballots. What the table holds in +this mode is a session's chosen name, and nothing reads it beyond rendering: the invitee +search is its only other reader and that screen is not part of this mode. The rows are +written when somebody says who they are rather than when they do anything, so they +accumulate with visitors and nothing removes them — +[`issues/0019-anonymous-names-accumulate-forever.md`](issues/0019-anonymous-names-accumulate-forever.md). + +**Identity does not outlive the session.** Close the tab and you are a new person. That +is the cost of having no accounts, and the admin code is what buys the organizer a way +back; everyone else simply votes again under a new name, which is Doodle's behaviour +too. + +### 1c. What anonymous mode does not have + +Not omissions — things it cannot honestly offer. + +- **No polls list.** Nothing outlives the session, so there is nothing to list. `/` is + where a poll starts, and `PollsView` hands straight over rather than being + unregistered, so every existing way home keeps working and `/` means something in + both modes. +- **No invitees.** There is no directory to search and no address to invite anybody at. + The create screen is a title and a button; `/new/invitees` leads home. +- **No reminders, no nudges, no "tell the team".** Every one of those promises a + message, and there is nobody to send one to. +- **No "waiting on", and no "everyone but Ada".** Both are claims about who was asked, + and nobody was asked. An anonymous poll starts with nobody on it — the organizer + included — and membership is having answered; see §2's anonymous rules. +- **No denominator.** "3 of 5" needs an invited list. The screens read "3" instead. +- **No faces.** An avatar is initials, and initials identify somebody only when the + names behind them were settled in advance. A visitor types their own name minutes + before answering, so one letter is as likely to be a stranger's as a colleague's. The + three screens that show other people name them instead — `NameChips` on the ballot + rows, the standings header and the locked date, six deep on the first and last and + four on the header, with the tail as a count. Login mode keeps the avatars, where an + account's initials were settled long before the poll and do identify somebody. + + The one avatar anonymous mode keeps is `AccountMenu`, top right — that one is *your* + initials, and you know who you are. Tapping it says the name back in full. + --- ## 2. Who may see a poll, and who may change it @@ -65,6 +152,38 @@ to be the person who called it — and nothing changes at all once voting is ove Enforced in `PollService`, never on the screens. The screens do hide what is not yours, and that is a courtesy; a hidden button is not a check. +### The same three permissions in anonymous mode + +The table above is login mode's. Anonymous mode has no invitations to check, so the +link stands in for one, and `PollService` branches in exactly three places — all of +them there, all of them marked, and nothing else in the application knows there are two +modes of access. + +| | | +| --- | --- | +| `poll(id, viewer)` | anybody holding the id: the link is the credential. A draft stays the organizer's alone — it has been shown to nobody, so nobody has a link | +| `castVote`, `decline` | anybody holding the id, **and answering is what puts them on the poll** | +| the eight organizer-gated writes | the address on the poll, or the poll's own six-digit admin code | + +**Joining on the answer is not a formality.** The tallies, the avatar stacks and +`Poll.awaiting` all read the invitee list, so a ballot from somebody off it would be +counted nowhere. It also means the invitee list *is* the list of people who answered, +which is why `awaiting` is always empty and the screens say nothing about who is +missing. + +**The code is compared with the poll being changed, never looked up.** So six digits are +worth nothing without the link they go with, two polls sharing a code means nothing, and +no unique index is needed. Login-mode polls have no code at all, and the null check is +what stops an absent one matching an absent one. `Caller` carries it from the presenter +into the service, so the check stays in `PollService` and the service stays free of +session state. What is not in front of it is a limit on guesses: +[`issues/0018-an-admin-code-can-be-guessed.md`](issues/0018-an-admin-code-can-be-guessed.md). + +**The refusal has two answers here, not three.** Login mode withholds a poll's existence +from a stranger, because the refusal itself must not reveal it. Anonymous mode does not: +anybody who reached the call is holding the link, and the link already showed them the +poll. Denying its existence to somebody looking at it would only read as a bug. + **The match is on the address.** You sign in with the address you were invited at and nothing else works: not another address of yours, not an alias, not a colleague's account at the same company. `bob+team@example.com` and `bob@example.com` are two @@ -131,8 +250,11 @@ already was: ## 3. Who a poll goes to -There is no team and no directory. The only way anybody gets onto a poll is the -organizer typing their email address. +In anonymous mode, whoever has the link — and that is the whole of it. There is no +invitee list to be on until somebody answers, and §1c says what follows from that. + +The rest of this section is login mode's. There is no team and no directory. The only +way anybody gets onto a poll is the organizer typing their email address. `AccountDirectory` has no method that hands a screen everybody. `matching` is the only way in and it answers nothing at all below three characters, so nobody is listed until @@ -316,6 +438,47 @@ of the share screen showed all three faults at once: date and the fallback was `now()`, which is why the note read "Thursday 3:37 PM". A minute value in a product with no minutes was the tell. +### A tie is not a result + +Days are ordered by count and, where two share one, by date — a list needs a stable +order. The rank used to be the position in that list, so the earlier of two tied days +got rank 1, and rank 1 meant *winner* everywhere it was read: the dark bar on the +standings, "most popular" on every ballot, and the single "Lock in Tue 25" button the +organizer was given. + +All three were the application inventing a result the group had not reached, and the +last was worse than a wording problem — the other tied days had no affordance at all, +so an organizer who wanted Wednesday could not choose it. + +Now the rank is a **competition rank** (1, 1, 1, 4), so tied days are painted alike; +their bars were already the same length, and two identical bars in different shades read +as an order that is not there. And `DayTally.leading` is handed in by the service rather +than derived from the rank: it is false for every day when the top count is shared, +because then no day leads. `Poll.leader()` is empty for a tie and `Poll.tiedAtTheTop()` +is what has something to say. + +The organizer settles it, on a screen of its own. Nobody voting at all is not a tie: +`tiedAtTheTop` is empty, and there is nothing to settle yet. + +**Locking gets its own screen, because it cannot be undone.** `/poll/:id/settle` is the +only way a day is locked. Nothing is final on the standings — a screen the organizer +came to *read* should not settle the poll on one tap — so the button there leads here +and this screen says what is about to happen: no more answers, no different day, +nothing to undo. Cancel goes back and changes nothing. + +It is also where a tie is resolved, which is why the choice and the confirmation are one +screen rather than two. The standings can say three days are level; only a person can +say which one the team goes with, and `DayChoice` is that question — the voting screen's +rows, single-select, nothing chosen until somebody chooses. Confirming without a choice +asks again rather than guessing. + +It is the organizer's and only while the poll is open, the same rule the button that +leads here follows. Anybody else who follows the URL lands on the standings. + +A consequence worth naming: a tied poll has no headline day, so the list screen shows it +as "3 days on the table" rather than putting one of the tied dates in the numeral. That +is the same honesty one screen further out. + ### Closing has to mean something A label that said closed while answers still landed would be worse than no label. @@ -611,6 +774,14 @@ generated iCalendar file — all-day events, since the whole product is whole da are anchors wearing the button's clothes. "Copy" uses the clipboard API and says so when it works. +**But only the settled day gets a calendar file.** The share screen offered one too, as +TENTATIVE events for every day on the table. That is a calendar entry per maybe, put +there before anybody has answered and left for the reader to delete once the poll picks +one of them — so the days on the table are days on the table, and only "Add to calendar" +on the locked screen writes anything a reader wants to keep. Message is login mode's +alone for a different reason: its copy tells the reader which address to sign in with, +and anonymous mode has neither. + ### Added Each of these exists because the design's flow is unreachable or unusable without it. diff --git a/docs/issues/0018-an-admin-code-can-be-guessed.md b/docs/issues/0018-an-admin-code-can-be-guessed.md new file mode 100644 index 0000000..83e00c2 --- /dev/null +++ b/docs/issues/0018-an-admin-code-can-be-guessed.md @@ -0,0 +1,44 @@ +# An admin code can be guessed, and nothing counts the guesses + +**Severity:** medium — six digits are the whole of "may change this poll" in anonymous +mode, and there is no limit on how many times a caller may try them. + +## What happens + +`PollService.create` mints a six-digit code for every anonymous poll, and +`requireOrganizer` accepts it as a second way of being the organizer. Nothing else +stands in the way: `PollPresenter.lock`, `chooseDays`, `closeOn`, `send`, +`acceptProposal`, `allowAlternatives`, `addInvitee` and `deleteDraft` all reach it, and +a caller may put a code on their session as often as they like — the who-are-you screen +takes a new one every time it is submitted. + +The space is 10^6. A script that already holds a poll's link can walk it in an +afternoon and settle somebody else's poll on a day of its choosing, or rewrite the days +under answers already given. + +## Why it is not worse than it sounds + +The code is compared with the poll being changed, never looked up across the table +(`carriesAdminCode`). So the guesser needs the link first, which means the poll was +shared with them or with somebody who passed it on. Anonymous mode already grants a +link-holder every read and a vote; what the code adds is the organizer's writes. + +Six digits are also a decision rather than an accident: the code has to survive being +read down a phone line or typed from a screenshot, and that is what makes it short. + +## What would fix it + +Counting the attempts, not lengthening the code. A per-session and per-poll failure +counter with a delay after a handful of wrong answers takes the attack from an +afternoon to a geological age and costs the honest organizer nothing — they type their +code once. + +`docs/issues/0015-response-hardening-stops-at-the-defaults.md` is the natural place for +it to live: both are about what an unauthenticated caller may do to this application in +volume, and neither has anything in front of it today. + +## Where + +- `src/main/java/io/binarycodes/whichday/poll/service/PollService.java` — `carriesAdminCode`, `requireOrganizer` +- `src/main/java/io/binarycodes/whichday/people/ui/view/IdentityView.java` — takes a code on every submit +- `src/main/java/io/binarycodes/whichday/people/ui/presenter/AnonymousViewerSession.java` — holds it for the session diff --git a/docs/issues/0019-anonymous-names-accumulate-forever.md b/docs/issues/0019-anonymous-names-accumulate-forever.md new file mode 100644 index 0000000..f78847b --- /dev/null +++ b/docs/issues/0019-anonymous-names-accumulate-forever.md @@ -0,0 +1,44 @@ +# Anonymous names accumulate in the account table and nothing removes them + +**Severity:** low — it costs disk and nothing else, but it grows with visitors rather +than with polls and there is no sweep. + +## What happens + +`AnonymousViewerSession.identify` calls `AccountDirectory.remember`, which inserts a row +keyed on the minted `-@whichday.anonymous` address. It has to: a poll +stores addresses and reads names from that table ([`../REQUIREMENTS.md`](../REQUIREMENTS.md) +§10), so a name that is not written there is a name nobody else on the poll ever sees. + +But it is written when somebody says who they are, not when they do anything. Somebody +who opens a shared link, types a name and closes the tab leaves a row behind. So does a +crawler that fills the field. Nothing deletes it, and nothing ever matches it again — +the address belonged to a session that no longer exists. + +A poll deleted as a draft takes its own rows with it (`on delete cascade`); the account +rows are not among them. + +## What it does not do + +It is not a leak. `AccountDirectory.matching` is the only reader that could list these, +and the screen that calls it — `/new/invitees` — is not part of anonymous mode and +forwards home. `forInvites` only resolves addresses already on a poll. So a minted +address surfaces exactly where its owner answered something, and nowhere else. + +## What would fix it + +Either write later or sweep. Writing later means remembering the name at the first +thing the session actually stores — creating a poll, casting a vote, declining one — +which bounds the table to people who took part. That is three call sites in +`PollPresenter` and a new dependency there, which is why it was not done in the change +that introduced the mode. + +Sweeping means deleting `@whichday.anonymous` accounts that no poll, invitee row or +ballot refers to. Cheap as a query, and it needs somewhere to run from — this +application has no scheduled work at all today, which is the same gap +`0005-closing-happens-on-read.md` records. + +## Where + +- `src/main/java/io/binarycodes/whichday/people/ui/presenter/AnonymousViewerSession.java` — `identify` +- `src/main/java/io/binarycodes/whichday/people/service/AccountDirectory.java` — `remember` diff --git a/environment/dev/README.md b/environment/dev/README.md index 436df8f..bcacebd 100644 --- a/environment/dev/README.md +++ b/environment/dev/README.md @@ -1,26 +1,28 @@ # Development environment -The one service Whichday talks to while you are working on it. The polls live in an -H2 file the application opens itself (`docs/REQUIREMENTS.md` §9), so there is no -database to bring up — but signing in is the only way in and OIDC is the only way to -sign in, so this is not optional either. +The one service Whichday talks to while you are working on it, and **only in login +mode**. The polls live in an H2 file the application opens itself +(`docs/REQUIREMENTS.md` §9), so there is no database to bring up; anonymous mode — the +default — has no provider either, so `./run.sh run` on its own needs none of this. ```bash ./run.sh env up ``` -Then start the app as usual; `application.properties` defaults to exactly this -Keycloak, so no configuration is needed. +Then start the app in login mode; `application-login.properties` defaults to exactly +this Keycloak, so no configuration is needed beyond naming the mode. ```bash -./run.sh run +WHICHDAY_ACCESS_MODE=login ./run.sh run ``` Open and sign in as **`ada` / `ada`**. -The application fetches the issuer's discovery document at startup, so `./run.sh run` -against a stopped stack fails immediately with a connection error rather than at the -first sign-in. That is the right order to find out in. +Login mode fetches the issuer's discovery document at startup, so it fails immediately +with a connection error against a stopped stack rather than at the first sign-in. That +is the right order to find out in — and it is why the OIDC keys live in the login +profile's file rather than in `application.properties`, where anonymous mode would +inherit them and try the same fetch for nothing. One task for the whole stack rather than one per service: which containers it brings up is the stack definition's decision, so a service added there needs no change to diff --git a/run.conf b/run.conf index 1214eaa..6892f9e 100644 --- a/run.conf +++ b/run.conf @@ -13,10 +13,11 @@ PROJECT_NAME="Whichday" JAVA_VERSION="21" # The polls live in an embedded H2 file under WHICHDAY_DATA_DIR, so the development -# stack is one container and it is not the database: signing in is the only way in, -# and the Keycloak in environment/dev is what answers. ENV_DIR and CONTAINER_PREFIX -# are left out because the defaults — environment/dev and whichday-dev — are already -# what this project uses. +# stack is one container and it is not the database: it is the Keycloak in +# environment/dev, which login mode signs in against. Anonymous mode — the default — +# needs nothing brought up, and this stays true because `env` is still the task that +# offers the other mode a provider. ENV_DIR and CONTAINER_PREFIX are left out because +# the defaults — environment/dev and whichday-dev — are already what this project uses. CONTAINER_REQUIRED="true" # The profile that puts the integration tests in production mode. diff --git a/src/main/java/io/binarycodes/whichday/base/config/AccessMode.java b/src/main/java/io/binarycodes/whichday/base/config/AccessMode.java new file mode 100644 index 0000000..4fa0b42 --- /dev/null +++ b/src/main/java/io/binarycodes/whichday/base/config/AccessMode.java @@ -0,0 +1,55 @@ +package io.binarycodes.whichday.base.config; + +import java.util.Arrays; +import java.util.Locale; +import java.util.stream.Collectors; + +/** + * Which of the two ways into Whichday a deployment chose, set once by + * {@code WHICHDAY_ACCESS_MODE} and never again. + * + *

{@link #LOGIN} is an identity provider and nothing else: an account is how you + * are known, an address is how you are invited, and no screen is reachable without + * signing in. {@link #ANONYMOUS} has no provider at all: a session says who it is by + * typing a name, the link is what lets anybody see a poll and answer it, and a + * six-digit code is what lets anybody change one. + * + *

The mode is a bean rather than a scattering of {@code @Value} reads so that the + * few places which genuinely branch — the security chain, the viewer, three checks in + * {@code PollService} — all ask the same question of the same object. + */ +public enum AccessMode { + + LOGIN, + ANONYMOUS; + + private static final String UNKNOWN = """ + WHICHDAY_ACCESS_MODE is "%s", which is not a mode Whichday has. It is one of: %s. + + login an OIDC provider decides who you are. Set WHICHDAY_OIDC_CLIENT_ID, + WHICHDAY_OIDC_CLIENT_SECRET and WHICHDAY_OIDC_ISSUER_URI as well. + anonymous no provider and no accounts. Nothing else to configure."""; + + public boolean isAnonymous() { + return this == ANONYMOUS; + } + + /** + * The property as written, or a startup failure naming both modes. Spring would + * refuse an unknown profile silently — it simply reads no file for it — so + * {@code WHICHDAY_ACCESS_MODE=annonymous} would otherwise boot with neither mode's + * configuration and fail much further along, on a missing bean. + */ + public static AccessMode named(String mode) { + return Arrays.stream(values()) + .filter(candidate -> candidate.name().equalsIgnoreCase(mode)) + .findFirst() + .orElseThrow(() -> new IllegalStateException(UNKNOWN.formatted(mode, listed()))); + } + + private static String listed() { + return Arrays.stream(values()) + .map(mode -> mode.name().toLowerCase(Locale.ROOT)) + .collect(Collectors.joining(", ")); + } +} diff --git a/src/main/java/io/binarycodes/whichday/base/config/AccessModeConfiguration.java b/src/main/java/io/binarycodes/whichday/base/config/AccessModeConfiguration.java new file mode 100644 index 0000000..34aaa49 --- /dev/null +++ b/src/main/java/io/binarycodes/whichday/base/config/AccessModeConfiguration.java @@ -0,0 +1,22 @@ +package io.binarycodes.whichday.base.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Turns the mode a deployment named into the bean everything else asks. + * + *

The property is written by the profile's own file, so a mode nobody has a file + * for leaves it unset — which is exactly the case worth catching. The environment + * variable is the fallback purely so the failure can quote what the operator actually + * typed rather than the empty string it resolved to. + */ +@Configuration(proxyBeanMethods = false) +public class AccessModeConfiguration { + + @Bean + AccessMode accessMode(@Value("${whichday.access.mode:${WHICHDAY_ACCESS_MODE:}}") String mode) { + return AccessMode.named(mode); + } +} diff --git a/src/main/java/io/binarycodes/whichday/base/security/AnonymousSecurityConfig.java b/src/main/java/io/binarycodes/whichday/base/security/AnonymousSecurityConfig.java new file mode 100644 index 0000000..9b9245e --- /dev/null +++ b/src/main/java/io/binarycodes/whichday/base/security/AnonymousSecurityConfig.java @@ -0,0 +1,43 @@ +package io.binarycodes.whichday.base.security; + +import static com.vaadin.flow.spring.security.VaadinSecurityConfigurer.vaadin; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.SecurityFilterChain; + +/** + * Anonymous mode: there is no provider, so there is nobody to send anybody to. + * + *

Every screen is reachable, and what a visitor may do is decided further in — a + * link is what lets somebody see a poll and answer it, a six-digit code is what lets + * somebody change one, and {@code PollService} is where both are checked. Nothing here + * is a permission model; this only gets requests past the filter chain. + * + *

Navigation access control is turned off rather than every route being changed to + * {@code @AnonymousAllowed}. Vaadin reads {@code @PermitAll} as authenticated, + * and in this mode nobody is — so leaving the checker on would refuse every route. + * Turning it off leaves the annotations meaning what they mean in + * {@link LoginSecurityConfig}, where they are still consulted. + * + *

With the checker off, Vaadin's own request rules have nothing left to decide with: + * they classify a URL by asking which view it reaches and what that view allows. So the + * rule is stated here instead and the configurer is told not to add one — otherwise + * every navigation logs that it could not tell whether the URL was public. + */ +@Configuration +@ConditionalOnProperty(name = "whichday.access.mode", havingValue = "anonymous") +public class AnonymousSecurityConfig { + + @Bean + SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + return http + .authorizeHttpRequests(requests -> requests.anyRequest().permitAll()) + .with(vaadin(), configurer -> configurer + .enableNavigationAccessControl(false) + .enableAuthorizedRequestsConfiguration(false)) + .build(); + } +} diff --git a/src/main/java/io/binarycodes/whichday/base/security/SecurityConfig.java b/src/main/java/io/binarycodes/whichday/base/security/LoginSecurityConfig.java similarity index 75% rename from src/main/java/io/binarycodes/whichday/base/security/SecurityConfig.java rename to src/main/java/io/binarycodes/whichday/base/security/LoginSecurityConfig.java index 4661abc..939a460 100644 --- a/src/main/java/io/binarycodes/whichday/base/security/SecurityConfig.java +++ b/src/main/java/io/binarycodes/whichday/base/security/LoginSecurityConfig.java @@ -2,24 +2,28 @@ import static com.vaadin.flow.spring.security.VaadinSecurityConfigurer.vaadin; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.security.config.ObjectPostProcessor; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.client.oidc.web.logout.OidcClientInitiatedLogoutSuccessHandler; import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.header.HeaderWriterFilter; /** - * Signing in is the only way in, and OIDC is the only way to sign in. + * Login mode: signing in is the only way in, and OIDC is the only way to sign in. * *

There is no login view: {@code oauth2LoginPage} points at the registration, so * an unauthenticated request redirects to the provider rather than to a form of ours. * The application collects no credentials and never sees one. + * + *

The other mode is {@link AnonymousSecurityConfig}, and only one of the two is + * ever in the context. The gate is the property rather than {@code @Profile} so that + * a test composing profiles cannot end up with both chains or neither. */ @Configuration -public class SecurityConfig { +@ConditionalOnProperty(name = "whichday.access.mode", havingValue = "login") +public class LoginSecurityConfig { private static final String REGISTRATION_ID = "oidc"; @@ -35,11 +39,13 @@ public class SecurityConfig { private static final String AUTHORIZATION_ENDPOINT = "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/oauth2/authorization/" + REGISTRATION_ID; private static final String MISCONFIGURED = """ - Signing in is the only way into Whichday, and it is not configured: %s. + WHICHDAY_ACCESS_MODE is "login", so signing in is the only way into + Whichday — and it is not configured: %s. Set WHICHDAY_OIDC_CLIENT_ID and WHICHDAY_OIDC_CLIENT_SECRET from an OAuth client whose redirect URI is /login/oauth2/code/oidc, and - WHICHDAY_OIDC_ISSUER_URI if the provider is not the default."""; + WHICHDAY_OIDC_ISSUER_URI if the provider is not the default. A deployment + that wants no provider at all wants WHICHDAY_ACCESS_MODE=anonymous."""; /** * A logout that only drops our own session leaves the provider's intact, and the @@ -67,8 +73,9 @@ SecurityFilterChain filterChain(HttpSecurity http, ClientRegistrationRepository * properties file binds as the literal string, so the application starts, fetches * the provider's discovery document, and redirects to a real authorization endpoint * carrying a client id of "${WHICHDAY_OIDC_CLIENT_ID}". The first person to try - * signing in meets the provider's error page. Signing in is the only way into this - * application, so a missing client is a startup failure and not a surprise later. + * signing in meets the provider's error page. A deployment that asked for login + * mode gets a startup failure rather than that surprise later; one that wants no + * provider at all asks for anonymous mode instead. */ private static void requireCredentials(ClientRegistrationRepository registrations) { var registration = registrations.findByRegistrationId(REGISTRATION_ID); @@ -85,22 +92,4 @@ private static void requireCredentials(ClientRegistrationRepository registration private static boolean isUnset(String value) { return value == null || value.isBlank() || value.contains("${"); } - - /** - * Spring Security writes its headers as the response commits, and the response - * Vaadin renders a page into never commits that way — so the application's own - * routes would come out with no headers at all while static resources got the - * full set. Verify a header change by curling a route, never only a static file - * (CODING_CONVENTIONS.md §10a). - */ - @Bean - ObjectPostProcessor headersWrittenEagerly() { - return new ObjectPostProcessor<>() { - @Override - public O postProcess(O filter) { - filter.setShouldWriteHeadersEagerly(true); - return filter; - } - }; - } } diff --git a/src/main/java/io/binarycodes/whichday/base/security/SecurityHeadersConfig.java b/src/main/java/io/binarycodes/whichday/base/security/SecurityHeadersConfig.java new file mode 100644 index 0000000..e6f9feb --- /dev/null +++ b/src/main/java/io/binarycodes/whichday/base/security/SecurityHeadersConfig.java @@ -0,0 +1,32 @@ +package io.binarycodes.whichday.base.security; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.ObjectPostProcessor; +import org.springframework.security.web.header.HeaderWriterFilter; + +/** + * The security headers, which are the same whichever way a deployment lets people in + * — so they are configured once here rather than twice in the two chains. + */ +@Configuration +public class SecurityHeadersConfig { + + /** + * Spring Security writes its headers as the response commits, and the response + * Vaadin renders a page into never commits that way — so the application's own + * routes would come out with no headers at all while static resources got the + * full set. Verify a header change by curling a route, never only a static file + * (CODING_CONVENTIONS.md §10a). + */ + @Bean + ObjectPostProcessor headersWrittenEagerly() { + return new ObjectPostProcessor<>() { + @Override + public O postProcess(O filter) { + filter.setShouldWriteHeadersEagerly(true); + return filter; + } + }; + } +} diff --git a/src/main/java/io/binarycodes/whichday/base/ui/AppHeader.java b/src/main/java/io/binarycodes/whichday/base/ui/AppHeader.java index 015905f..9efe45a 100644 --- a/src/main/java/io/binarycodes/whichday/base/ui/AppHeader.java +++ b/src/main/java/io/binarycodes/whichday/base/ui/AppHeader.java @@ -3,6 +3,7 @@ import com.vaadin.flow.component.html.Div; import io.binarycodes.whichday.people.domain.Person; +import io.binarycodes.whichday.people.ui.AccountLabels; import io.binarycodes.whichday.people.ui.AccountMenu; /** @@ -12,13 +13,14 @@ */ public class AppHeader extends Div { - public AppHeader(String wordmark, Person viewer, Runnable onSignOut, Runnable onHome) { + public AppHeader(String wordmark, Person viewer, AccountLabels labels, + Runnable onSignOut, Runnable onHome) { addClassName("app-header"); // The wordmark is the way home on the screens that have no back chevron. var name = Actions.link(wordmark, ignored -> onHome.run()); name.addClassNames("wordmark", Actions.HOME_CLASS); - add(name, new AccountMenu(viewer, onSignOut)); + add(name, new AccountMenu(viewer, labels, onSignOut)); } } diff --git a/src/main/java/io/binarycodes/whichday/base/ui/Counts.java b/src/main/java/io/binarycodes/whichday/base/ui/Counts.java index b0c1af6..0406ebf 100644 --- a/src/main/java/io/binarycodes/whichday/base/ui/Counts.java +++ b/src/main/java/io/binarycodes/whichday/base/ui/Counts.java @@ -18,4 +18,15 @@ public static String days(Component owner, int count) { public static String progress(Component owner, int answered, int invited) { return owner.getTranslation("count.progress", answered, invited); } + + /** + * The same figure without a denominator, for a poll anybody with the link may + * answer. There is no invited list to be out of there — the number below the + * fraction would be whoever happened to have joined so far, which says nothing + * about how many answers are still coming. + */ + public static String progress(Component owner, int answered, int invited, boolean anonymous) { + return anonymous ? owner.getTranslation("count.answered", answered) + : progress(owner, answered, invited); + } } diff --git a/src/main/java/io/binarycodes/whichday/base/ui/Home.java b/src/main/java/io/binarycodes/whichday/base/ui/Home.java new file mode 100644 index 0000000..d080fd4 --- /dev/null +++ b/src/main/java/io/binarycodes/whichday/base/ui/Home.java @@ -0,0 +1,30 @@ +package io.binarycodes.whichday.base.ui; + +import com.vaadin.flow.component.Component; + +import io.binarycodes.whichday.poll.ui.presenter.PollPresenter; +import io.binarycodes.whichday.poll.ui.view.NewPollView; +import io.binarycodes.whichday.poll.ui.view.PollsView; + +/** + * Where the way home goes, which is not the same screen in both modes. + * + *

Login mode has a list of the polls you are part of. Anonymous mode has no way to + * know what you are part of — no account, no invitations, nothing that outlives the + * session — so there is nothing to list, and starting a new poll is what home means + * there instead. + */ +public final class Home { + + private Home() { + } + + public static Class viewFor(PollPresenter presenter) { + return presenter.anonymous() ? NewPollView.class : PollsView.class; + } + + /** What the way home is called, since it does not go to the same place. */ + public static String labelFor(Component owner, PollPresenter presenter) { + return owner.getTranslation(presenter.anonymous() ? "nav.home.anonymous" : "nav.home"); + } +} diff --git a/src/main/java/io/binarycodes/whichday/base/ui/IdentityGuard.java b/src/main/java/io/binarycodes/whichday/base/ui/IdentityGuard.java new file mode 100644 index 0000000..e6077bc --- /dev/null +++ b/src/main/java/io/binarycodes/whichday/base/ui/IdentityGuard.java @@ -0,0 +1,68 @@ +package io.binarycodes.whichday.base.ui; + +import java.util.Optional; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import com.vaadin.flow.router.BeforeEnterEvent; +import com.vaadin.flow.server.ServiceInitEvent; +import com.vaadin.flow.server.VaadinServiceInitListener; +import com.vaadin.flow.server.VaadinSession; + +import io.binarycodes.whichday.people.ui.presenter.ViewerSession; +import io.binarycodes.whichday.people.ui.view.IdentityView; + +/** + * Nobody reaches a screen without saying who they are — anonymous mode's answer to the + * redirect login mode's filter chain does. + * + *

It stands in front of every route, the shared voting link included: following one + * asks for a name first and lands on the ballot after. The location is kept so that the + * link survives the detour, which is the whole reason this is a guard and not a step in + * the create flow. + */ +@Component +@ConditionalOnProperty(name = "whichday.access.mode", havingValue = "anonymous") +public class IdentityGuard implements VaadinServiceInitListener { + + private static final String WANTED = IdentityGuard.class.getName() + ".wanted"; + + /** + * Asked for one navigation at a time, not held. The session it hands back is scoped + * to the Vaadin session, and this listener is a singleton the service builds long + * before any browser has one — injecting the bean itself fails the context outright. + */ + private final ObjectProvider sessions; + + public IdentityGuard(ObjectProvider sessions) { + this.sessions = sessions; + } + + @Override + public void serviceInit(ServiceInitEvent event) { + event.getSource().addUIInitListener(initialised -> + initialised.getUI().addBeforeEnterListener(this::askWhoIsAsking)); + } + + private void askWhoIsAsking(BeforeEnterEvent event) { + if (sessions.getObject().isIdentified() || event.getNavigationTarget() == IdentityView.class) { + return; + } + VaadinSession.getCurrent().setAttribute(WANTED, event.getLocation().getPathWithQueryParameters()); + event.forwardTo(IdentityView.class); + } + + /** + * Where the browser was going before it was asked, taken rather than read: a + * remembered destination that outlived the trip to it would send the next + * navigation somewhere nobody asked for. + */ + public static Optional take() { + var session = VaadinSession.getCurrent(); + var wanted = (String) session.getAttribute(WANTED); + session.setAttribute(WANTED, null); + return Optional.ofNullable(wanted).filter(path -> !path.isBlank()); + } +} diff --git a/src/main/java/io/binarycodes/whichday/base/ui/Toast.java b/src/main/java/io/binarycodes/whichday/base/ui/Toast.java new file mode 100644 index 0000000..7193b5d --- /dev/null +++ b/src/main/java/io/binarycodes/whichday/base/ui/Toast.java @@ -0,0 +1,32 @@ +package io.binarycodes.whichday.base.ui; + +import com.vaadin.flow.component.notification.Notification; + +/** + * What the application says back, at the top of the screen rather than over the button + * that was just pressed. + * + *

Vaadin puts a notification bottom-left, which on a phone is exactly where the + * primary action is — so for the five seconds a message showed, the button it was about + * could not be pressed. Nothing said here is worth blocking the way forward, so it goes + * up into the header band instead, where the worst it covers is a back chevron. + * + *

Five seconds is Vaadin's own default and is restated here because the position is + * not: a reader who looks up a moment late should still catch it, and nothing here is + * long enough to need longer. + */ +public final class Toast { + + private static final int VISIBLE_MILLIS = 5000; + + private Toast() { + } + + public static void show(String message) { + var toast = new Notification(message); + toast.setPosition(Notification.Position.TOP_CENTER); + toast.setDuration(VISIBLE_MILLIS); + toast.addClassName("toast"); + toast.open(); + } +} diff --git a/src/main/java/io/binarycodes/whichday/people/ui/AccountLabels.java b/src/main/java/io/binarycodes/whichday/people/ui/AccountLabels.java new file mode 100644 index 0000000..907ed4a --- /dev/null +++ b/src/main/java/io/binarycodes/whichday/people/ui/AccountLabels.java @@ -0,0 +1,23 @@ +package io.binarycodes.whichday.people.ui; + +import com.vaadin.flow.component.Component; + +import io.binarycodes.whichday.people.domain.Person; + +/** + * What the account menu says, which is not the same in both modes: one is a provider + * you are signed in to, the other is a name you typed. The choice is made here once + * rather than at every screen that draws the menu — the same reason {@code Counts} + * exists. + */ +public record AccountLabels(String signedInAs, String signOut) { + + public static AccountLabels of(Component owner, Person viewer, boolean anonymous) { + if (anonymous) { + return new AccountLabels(owner.getTranslation("nav.youAre", viewer.displayName()), + owner.getTranslation("nav.startOver")); + } + return new AccountLabels(owner.getTranslation("nav.signedInAs", viewer.displayName()), + owner.getTranslation("nav.signOut")); + } +} diff --git a/src/main/java/io/binarycodes/whichday/people/ui/AccountMenu.java b/src/main/java/io/binarycodes/whichday/people/ui/AccountMenu.java index efb29fc..08760bb 100644 --- a/src/main/java/io/binarycodes/whichday/people/ui/AccountMenu.java +++ b/src/main/java/io/binarycodes/whichday/people/ui/AccountMenu.java @@ -11,17 +11,21 @@ *

A context menu on a bare avatar rather than a {@code MenuBar}: a menu bar brings * a button and an overflow arrow the design does not draw, and suppressing both means * styling into its shadow root. + * + *

The labels are handed in. Signing out of a provider and dropping a name you typed + * are not the same act and do not read the same, and the component has no business + * knowing which of the two it is offering. */ public class AccountMenu extends PersonAvatar { - public AccountMenu(Person viewer, Runnable onSignOut) { + public AccountMenu(Person viewer, AccountLabels labels, Runnable onSignOut) { super(viewer); addClassName("account-avatar"); getElement().setAttribute("title", viewer.displayName()); var menu = new ContextMenu(this); menu.setOpenOnClick(true); - menu.addItem(getTranslation("nav.signedInAs", viewer.displayName())).setEnabled(false); - menu.addItem(getTranslation("nav.signOut"), ignored -> onSignOut.run()); + menu.addItem(labels.signedInAs()).setEnabled(false); + menu.addItem(labels.signOut(), ignored -> onSignOut.run()); } } diff --git a/src/main/java/io/binarycodes/whichday/people/ui/NameChips.java b/src/main/java/io/binarycodes/whichday/people/ui/NameChips.java new file mode 100644 index 0000000..260e1c0 --- /dev/null +++ b/src/main/java/io/binarycodes/whichday/people/ui/NameChips.java @@ -0,0 +1,38 @@ +package io.binarycodes.whichday.people.ui; + +import java.util.List; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.html.Div; + +import io.binarycodes.whichday.base.ui.Chip; +import io.binarycodes.whichday.people.domain.Person; + +/** + * People named rather than shown, for where an avatar cannot identify anybody. + * + *

Initials only work when the names behind them were settled in advance. Where a + * visitor types their own name minutes before answering, one letter is as likely to be + * a stranger's as a colleague's — so the name goes on the screen in full, and the tail + * beyond {@code limit} becomes a count rather than pushing the row off the edge. + * + *

First names only: the rows that draw these are narrow, and a surname wraps them + * onto another line without saying anything the first name did not. + */ +public final class NameChips { + + private NameChips() { + } + + public static Div of(Component owner, List people, int limit) { + var row = new Div(); + row.addClassName("name-chips"); + people.stream().limit(limit) + .forEach(person -> row.add(new Chip(person.firstName(), Chip.Tone.OUTLINE))); + var hidden = people.size() - limit; + if (hidden > 0) { + row.add(new Chip(owner.getTranslation("count.more", hidden), Chip.Tone.OUTLINE)); + } + return row; + } +} diff --git a/src/main/java/io/binarycodes/whichday/people/ui/presenter/AnonymousViewerSession.java b/src/main/java/io/binarycodes/whichday/people/ui/presenter/AnonymousViewerSession.java new file mode 100644 index 0000000..772867b --- /dev/null +++ b/src/main/java/io/binarycodes/whichday/people/ui/presenter/AnonymousViewerSession.java @@ -0,0 +1,106 @@ +package io.binarycodes.whichday.people.ui.presenter; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Optional; +import java.util.UUID; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import com.vaadin.flow.component.UI; +import com.vaadin.flow.server.VaadinSession; +import com.vaadin.flow.spring.annotation.VaadinSessionScope; + +import io.binarycodes.whichday.people.domain.Person; +import io.binarycodes.whichday.people.service.AccountDirectory; + +/** + * Somebody who typed a name. That is the whole of identity in anonymous mode: there + * is no provider to vouch for anybody and no account to look anybody up in. + * + *

The name is written to the {@code account} table, which is not a claim that + * anybody authenticated — it is the one place a name lives, and a poll stores nothing + * but addresses. Skip the write and every screen reads the minted address back out + * wherever a name belongs, including on other people's ballots. What the table holds + * in this mode is a session's chosen name, and nothing consults it beyond rendering: + * the invitee search is the only reader and it is not part of anonymous mode. + */ +@Component +@VaadinSessionScope +@ConditionalOnProperty(name = "whichday.access.mode", havingValue = "anonymous") +public class AnonymousViewerSession implements ViewerSession { + + /** + * The domain the minted addresses sit under. Not a domain anybody can receive mail + * at, and not one anybody could register either — which is the point: an address + * here identifies a session and promises nothing else. + */ + private static final String DOMAIN = "@whichday.anonymous"; + + private static final DateTimeFormatter MINTED_AT = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss"); + + private final Clock clock; + private final AccountDirectory directory; + + private Person viewer; + private String adminCode = ""; + + public AnonymousViewerSession(Clock clock, AccountDirectory directory) { + this.clock = clock; + this.directory = directory; + } + + @Override + public Person viewer() { + if (viewer == null) { + throw new IllegalStateException("Nobody has said who they are"); + } + return viewer; + } + + /** + * Closes the session outright, because there is no provider to log out of and + * nothing else holding the identity — the minted address only ever existed here. + * The reload is what puts the browser back in front of the who-are-you screen. + */ + @Override + public void signOut() { + var page = UI.getCurrent().getPage(); + VaadinSession.getCurrent().getSession().invalidate(); + VaadinSession.getCurrent().close(); + page.setLocation("/"); + } + + @Override + public boolean isIdentified() { + return viewer != null; + } + + @Override + public Optional adminCode() { + return adminCode.isBlank() ? Optional.empty() : Optional.of(adminCode); + } + + /** + * The address is minted once and then kept, because it is what every poll, ballot + * and invitee row this session writes will be keyed on — a second one would make + * the same person a stranger to their own answers. + */ + @Override + public void identify(String name, String adminCode) { + this.adminCode = adminCode == null ? "" : adminCode.trim(); + viewer = Person.signedIn(viewer == null ? mintedAddress() : viewer.email(), name); + directory.remember(viewer); + } + + /** + * A UUID for uniqueness and the moment for legibility: an address that turns up in + * a database row or a log line says when the session behind it started, which is + * the only thing anybody can usefully know about it. + */ + private String mintedAddress() { + return UUID.randomUUID() + "-" + MINTED_AT.format(LocalDateTime.now(clock)) + DOMAIN; + } +} diff --git a/src/main/java/io/binarycodes/whichday/people/ui/presenter/AuthenticatedViewerSession.java b/src/main/java/io/binarycodes/whichday/people/ui/presenter/AuthenticatedViewerSession.java index 7b74208..3d8689c 100644 --- a/src/main/java/io/binarycodes/whichday/people/ui/presenter/AuthenticatedViewerSession.java +++ b/src/main/java/io/binarycodes/whichday/people/ui/presenter/AuthenticatedViewerSession.java @@ -1,5 +1,8 @@ package io.binarycodes.whichday.people.ui.presenter; +import java.util.Optional; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.security.oauth2.core.oidc.StandardClaimNames; import org.springframework.security.oauth2.core.oidc.user.OidcUser; import org.springframework.stereotype.Component; @@ -14,6 +17,7 @@ * else: the provider decides, and every screen asks here. */ @Component +@ConditionalOnProperty(name = "whichday.access.mode", havingValue = "login") public class AuthenticatedViewerSession implements ViewerSession { private final AuthenticationContext authentication; @@ -42,6 +46,25 @@ public void signOut() { authentication.logout(); } + /** + * Always. Nothing reaches a screen in this mode without a token behind it, so + * there is never a moment where somebody is here but unnamed. + */ + @Override + public boolean isIdentified() { + return true; + } + + @Override + public Optional adminCode() { + return Optional.empty(); + } + + @Override + public void identify(String name, String adminCode) { + throw new UnsupportedOperationException("The provider decides who you are here"); + } + /** * An address is how everybody is identified here, so an account without one falls * back to the subject — which is stable even when the provider withholds an email. diff --git a/src/main/java/io/binarycodes/whichday/people/ui/presenter/ViewerSession.java b/src/main/java/io/binarycodes/whichday/people/ui/presenter/ViewerSession.java index b5da807..ffd09a4 100644 --- a/src/main/java/io/binarycodes/whichday/people/ui/presenter/ViewerSession.java +++ b/src/main/java/io/binarycodes/whichday/people/ui/presenter/ViewerSession.java @@ -1,20 +1,43 @@ package io.binarycodes.whichday.people.ui.presenter; +import java.util.Optional; + import io.binarycodes.whichday.people.domain.Person; /** - * Who is signed in. An interface with one implementation, because the seam is what - * lets a test say who the browser is — every route requires an authenticated user and - * the real one comes from the identity provider. + * Who is looking. One implementation per access mode, because the two answer the + * question in ways that have nothing in common: login mode reads a token the provider + * signed, anonymous mode remembers a name somebody typed. + * + *

The seam is also what lets a test say who the browser is. */ public interface ViewerSession { /** - * The signed-in person. Throws rather than defaulting when nobody is: a fallback - * viewer would put an unauthenticated path back into a store scoped by who you - * are, which is the whole reason every route requires a login. + * The person looking. Throws rather than defaulting when there is nobody: a + * fallback viewer would put an unattributed path into a store scoped by who you + * are, and both modes have something standing in front of every screen to make + * sure it cannot happen — the provider in one, the who-are-you screen in the other. */ Person viewer(); void signOut(); + + /** + * Whether anybody has said who they are yet. Always true in login mode, where the + * filter chain has already turned away everybody who had not. + */ + boolean isIdentified(); + + /** + * The admin code this session typed, if it typed one. Empty in login mode, where + * being the organizer is decided by the address on the poll and by nothing else. + */ + Optional adminCode(); + + /** + * Names the session, and takes the admin code offered alongside the name. Login + * mode has no use for either: the token said both. + */ + void identify(String name, String adminCode); } diff --git a/src/main/java/io/binarycodes/whichday/people/ui/view/IdentityView.java b/src/main/java/io/binarycodes/whichday/people/ui/view/IdentityView.java new file mode 100644 index 0000000..38a3a36 --- /dev/null +++ b/src/main/java/io/binarycodes/whichday/people/ui/view/IdentityView.java @@ -0,0 +1,128 @@ +package io.binarycodes.whichday.people.ui.view; + +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.icon.Icon; +import com.vaadin.flow.component.icon.VaadinIcon; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.router.BeforeEnterEvent; +import com.vaadin.flow.router.BeforeEnterObserver; +import com.vaadin.flow.router.HasDynamicTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.server.auth.AnonymousAllowed; + +import io.binarycodes.whichday.base.ui.Actions; +import io.binarycodes.whichday.base.ui.Home; +import io.binarycodes.whichday.base.ui.IdentityGuard; +import io.binarycodes.whichday.base.ui.Screen; +import io.binarycodes.whichday.base.ui.Toast; +import io.binarycodes.whichday.base.ui.Typography; +import io.binarycodes.whichday.people.ui.presenter.ViewerSession; +import io.binarycodes.whichday.poll.ui.presenter.PollPresenter; + +/** + * Anonymous mode's front door, and the only screen that stands in front of a shared + * link. It asks the two things nobody else can supply: what to call you, and — if you + * are coming back to a poll you called — the six digits that say so. + * + *

The name is the whole of identity here. The address behind it is minted, never + * typed: an address anybody could type is an address anybody could type twice. + * + *

Login mode has a provider for this and reaches the screen through no path of its + * own, so it is turned away at the door rather than left to render a form that would + * mean nothing. + */ +@AnonymousAllowed +@Route("who") +public class IdentityView extends Screen implements BeforeEnterObserver, HasDynamicTitle { + + /** Long enough to be a name, short enough that the column it lands in holds it. */ + private static final int NAME_LIMIT = 60; + private static final int CODE_LENGTH = 6; + + private final PollPresenter presenter; + private final ViewerSession session; + private final TextField name = new TextField(); + private final TextField code = new TextField(); + + public IdentityView(PollPresenter presenter, ViewerSession session) { + this.presenter = presenter; + this.session = session; + } + + @Override + public void beforeEnter(BeforeEnterEvent event) { + if (!presenter.anonymous()) { + event.forwardTo(Home.viewFor(presenter)); + return; + } + render(); + } + + private void render() { + clearBody(); + clearFooter(); + + var headline = Typography.hero(getTranslation("identity.headline")); + headline.addClassName("push-3xl"); + var lede = Typography.lede(getTranslation("identity.lede")); + lede.addClassName("push-l"); + body(headline, lede); + + var fields = new Div(nameField(), codeField()); + fields.addClassNames("field-column", "push-2xl"); + body(fields); + + var next = Actions.primary(getTranslation("identity.next"), ignored -> identify()); + next.setIcon(new Icon(VaadinIcon.ARROW_RIGHT)); + next.setIconAfterText(true); + var footnote = Typography.meta(getTranslation("identity.footnote")); + footnote.addClassNames("meta-faint", "meta-centred"); + footer(next, footnote); + } + + private Div nameField() { + name.setPlaceholder(getTranslation("identity.name.placeholder")); + name.setMaxLength(NAME_LIMIT); + name.addClassName("field-emphasis"); + name.setWidthFull(); + name.focus(); + + var group = new Div(Typography.fieldLabel(getTranslation("identity.name")), name); + group.addClassName("stack-xs"); + return group; + } + + /** + * Optional, and the hint says why: somebody arriving on a link has no code and + * needs none, and an empty field that looks required is a field people invent an + * answer for. + */ + private Div codeField() { + code.setPlaceholder(getTranslation("identity.code.placeholder")); + code.setMaxLength(CODE_LENGTH); + code.setAllowedCharPattern("[0-9]"); + code.setWidthFull(); + + var hint = Typography.meta(getTranslation("identity.code.hint")); + hint.addClassName("meta-faint"); + + var group = new Div(Typography.fieldLabel(getTranslation("identity.code")), code, hint); + group.addClassName("stack-xs"); + return group; + } + + private void identify() { + if (name.getValue().isBlank()) { + Toast.show(getTranslation("identity.name.required")); + return; + } + session.identify(name.getValue(), code.getValue()); + getUI().ifPresent(ui -> IdentityGuard.take() + .ifPresentOrElse(ui::navigate, () -> ui.navigate(Home.viewFor(presenter)))); + } + + @Override + public String getPageTitle() { + return getTranslation("identity.title"); + } +} diff --git a/src/main/java/io/binarycodes/whichday/poll/domain/Caller.java b/src/main/java/io/binarycodes/whichday/poll/domain/Caller.java new file mode 100644 index 0000000..ef03ece --- /dev/null +++ b/src/main/java/io/binarycodes/whichday/poll/domain/Caller.java @@ -0,0 +1,31 @@ +package io.binarycodes.whichday.poll.domain; + +import java.util.Optional; + +import io.binarycodes.whichday.people.domain.Person; + +/** + * Somebody asking to change a poll, and whatever they have to show for it. + * + *

Only the writing side of {@code PollService} takes one. Reading still takes a + * {@link Person}, because a code never widens what anybody may see — the link + * already decided that — only what they may change. + * + *

The code travels with the call rather than being read from the session inside the + * service: who may do what is decided in the service (see {@code docs/REQUIREMENTS.md} + * §2), and the service stays free of session state. + * + * @param adminCode empty in login mode, and empty in anonymous mode until somebody + * types one on the who-are-you screen + */ +public record Caller(Person person, Optional adminCode) { + + /** Somebody with nothing to show but who they are, which is login mode's only case. */ + public static Caller of(Person person) { + return new Caller(person, Optional.empty()); + } + + public static Caller of(Person person, Optional adminCode) { + return new Caller(person, adminCode); + } +} diff --git a/src/main/java/io/binarycodes/whichday/poll/domain/DayTally.java b/src/main/java/io/binarycodes/whichday/poll/domain/DayTally.java index 4c3c60e..e7007cb 100644 --- a/src/main/java/io/binarycodes/whichday/poll/domain/DayTally.java +++ b/src/main/java/io/binarycodes/whichday/poll/domain/DayTally.java @@ -9,15 +9,23 @@ * One candidate day and everybody who said it works. The rank is what the bars * fade by, so it is settled here — alongside the ordering that produced it — * rather than counted again by each screen that draws them. + * + * @param rank shared by days on the same count, so three days tied at the top are + * all rank 1 and their bars are painted alike. Two bars of identical + * length in different shades read as an order that is not there. + * @param leading whether this day is the day in front — false for every day + * when the top count is shared, because then no day is. Handed in + * rather than derived from the rank: a tally cannot see its siblings, + * and "rank 1" and "won" stopped meaning the same thing. */ -public record DayTally(LocalDate day, List voters, int rank, int inviteCount) { +public record DayTally(LocalDate day, List voters, int rank, int inviteCount, boolean leading) { public int voteCount() { return voters.size(); } public boolean isLeading() { - return rank == 1 && !voters.isEmpty(); + return leading; } /** The bar's share of its track: votes out of everybody invited, never out of the leader. */ diff --git a/src/main/java/io/binarycodes/whichday/poll/domain/Poll.java b/src/main/java/io/binarycodes/whichday/poll/domain/Poll.java index c433dce..ea9c5b1 100644 --- a/src/main/java/io/binarycodes/whichday/poll/domain/Poll.java +++ b/src/main/java/io/binarycodes/whichday/poll/domain/Poll.java @@ -77,11 +77,28 @@ public List declined() { return ballots.stream().filter(Ballot::isDeclined).toList(); } - /** The day in front, once anybody has voted for one at all. */ + /** + * The day in front, once anybody has voted for one at all — and only when it is + * alone up there. A shared top is not a result, so this is empty and + * {@link #tiedAtTheTop()} is what has something to say. + */ public Optional leader() { return tallies.stream().filter(DayTally::isLeading).findFirst(); } + /** + * The days sharing the highest count, when more than one does. Empty when a single + * day leads and empty when nobody has voted, so a caller that finds something here + * has found a decision the group did not make and somebody has to. + */ + public List tiedAtTheTop() { + if (leader().isPresent() || isUnanswered()) { + return List.of(); + } + var top = tallies.stream().mapToInt(DayTally::voteCount).max().orElse(0); + return top == 0 ? List.of() : tallies.stream().filter(tally -> tally.voteCount() == top).toList(); + } + /** * Who still owes an answer apart from the person asking. The organizer is invited * like everybody else, so without this the results screen offers them a nudge to diff --git a/src/main/java/io/binarycodes/whichday/poll/service/PollService.java b/src/main/java/io/binarycodes/whichday/poll/service/PollService.java index 02d5da1..e38fe91 100644 --- a/src/main/java/io/binarycodes/whichday/poll/service/PollService.java +++ b/src/main/java/io/binarycodes/whichday/poll/service/PollService.java @@ -1,7 +1,9 @@ package io.binarycodes.whichday.poll.service; +import java.security.SecureRandom; import java.time.Clock; import java.time.LocalDate; +import java.util.ArrayList; import java.util.Collection; import java.util.Comparator; import java.util.LinkedHashSet; @@ -11,16 +13,17 @@ import java.util.Optional; import java.util.Set; import java.util.UUID; -import java.util.stream.IntStream; import java.util.stream.Stream; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import io.binarycodes.whichday.base.config.AccessMode; import io.binarycodes.whichday.people.domain.EmailAddress; import io.binarycodes.whichday.people.domain.Person; import io.binarycodes.whichday.people.service.PersonLookup; import io.binarycodes.whichday.poll.domain.Ballot; +import io.binarycodes.whichday.poll.domain.Caller; import io.binarycodes.whichday.poll.domain.DayTally; import io.binarycodes.whichday.poll.domain.Poll; import io.binarycodes.whichday.poll.domain.PollState; @@ -43,6 +46,13 @@ *

Who may do what is decided here rather than by which button a screen draws: * reading needs an invitation, answering needs an invitation, and editing, settling or * discarding the poll needs to be the person who called it. + * + *

That is login mode. Anonymous mode has no invitations to check, so the link is + * what stands in for one: anybody holding a poll's id may read it and answer it, a + * voter joins the invitee list as they answer so that every count and stack downstream + * keeps working, and changing the poll needs either the organizer's own session or the + * six digits {@link Caller} carries. Three branches, all of them here, all of them + * marked — nothing else in the application knows there are two modes of access. */ @Service @Transactional(readOnly = true) @@ -51,14 +61,25 @@ public class PollService { /** Whole days only, so a closing date rather than a closing moment. */ private static final int MINIMUM_VOTING_DAYS = 1; + /** + * Six digits, leading zeros kept. Short enough to read down a phone line, which is + * the point of it — and no shorter than that, because it is the only thing between + * a stranger with the link and the poll's days. + */ + private static final int ADMIN_CODE_BOUND = 1_000_000; + private static final String ADMIN_CODE_SHAPE = "%06d"; + private final Clock clock; private final PollRepository polls; private final PersonLookup people; + private final AccessMode access; + private final SecureRandom codes = new SecureRandom(); - public PollService(Clock clock, PollRepository polls, PersonLookup people) { + public PollService(Clock clock, PollRepository polls, PersonLookup people, AccessMode access) { this.clock = clock; this.polls = polls; this.people = people; + this.access = access; } /** @@ -70,19 +91,31 @@ public PollService(Clock clock, PollRepository polls, PersonLookup people) { public UUID create(String title, Person organizer, List invited) { var id = UUID.randomUUID(); var invitees = invited.stream().map(PollService::addressOf).distinct().toList(); - polls.save(new StoredPoll(id, title, addressOf(organizer), invitees, clock.instant())); + var stored = new StoredPoll(id, title, addressOf(organizer), invitees, clock.instant()); + if (access.isAnonymous()) { + stored.useAdminCode(ADMIN_CODE_SHAPE.formatted(codes.nextInt(ADMIN_CODE_BOUND))); + } + polls.save(stored); return id; } + /** + * The code to keep, for the one screen that shows it. Empty in login mode, where no + * poll has one. + */ + public Optional adminCodeOf(UUID id) { + return Optional.ofNullable(require(id).adminCode()); + } + @Transactional - public void replaceCandidateDays(UUID id, Person organizer, Collection days) { + public void replaceCandidateDays(UUID id, Caller organizer, Collection days) { requireEditable(requireOrganizer(id, organizer)) .replaceCandidateDays(days.stream().sorted().toList()); } /** Sending the poll out is what opens it; until then it has no closing date. */ @Transactional - public void send(UUID id, Person organizer) { + public void send(UUID id, Caller organizer) { var stored = requireEditable(requireOrganizer(id, organizer)); if (stored.closesOn() == null) { stored.closeOn(defaultClosingDayFor(stored), clock.instant()); @@ -95,7 +128,7 @@ public void send(UUID id, Person organizer) { * never past the last day on the table. */ @Transactional - public void closeOn(UUID id, Person organizer, LocalDate day) { + public void closeOn(UUID id, Caller organizer, LocalDate day) { var stored = requireEditable(requireOrganizer(id, organizer)); stored.closeOn(clampClosingDay(stored, day), clock.instant()); } @@ -151,7 +184,7 @@ public void decline(UUID id, Person voter, List proposedDays, String } @Transactional - public void acceptProposal(UUID id, Person organizer, LocalDate day) { + public void acceptProposal(UUID id, Caller organizer, LocalDate day) { var stored = requireEditable(requireOrganizer(id, organizer)); var days = new LinkedHashSet<>(stored.candidateDays()); days.add(day); @@ -163,7 +196,7 @@ public void acceptProposal(UUID id, Person organizer, LocalDate day) { * that has passed closes the poll to this as much as to anything else. */ @Transactional - public void lock(UUID id, Person organizer, LocalDate day) { + public void lock(UUID id, Caller organizer, LocalDate day) { requireEditable(requireOrganizer(id, organizer)).lock(day); } @@ -175,10 +208,15 @@ public void lock(UUID id, Person organizer, LocalDate day) { *

A draft is the organizer's alone, the same rule {@link #draftPolls} applies. * The state is not a column, so this is the one predicate the query cannot carry: * {@code stateOf} decides it, here, rather than being restated in JPQL. + * + *

Anonymous mode has no invitee list to be on, so the id is the whole of the + * question — holding the link is what being invited means there. A draft stays the + * organizer's alone either way: it has been shown to nobody, so nobody has a link. */ public Optional poll(UUID id, Person viewer) { var email = addressOf(viewer); - return polls.findVisibleById(id, email) + var found = access.isAnonymous() ? polls.findById(id) : polls.findVisibleById(id, email); + return found .filter(stored -> stateOf(stored) != PollState.DRAFT || stored.organizerEmail().equals(email)) .map(this::snapshot); @@ -207,7 +245,7 @@ public List draftPolls(Person viewer) { * and people waiting on it, and discarding one is a decision this does not make. */ @Transactional - public void deleteDraft(UUID id, Person organizer) { + public void deleteDraft(UUID id, Caller organizer) { var stored = requireEditable(requireOrganizer(id, organizer)); if (stateOf(stored) != PollState.DRAFT) { throw new IllegalStateException("Poll " + id + " has been sent and cannot be discarded"); @@ -245,13 +283,13 @@ public int pollsSharedBy(Person viewer, Person other) { * needs either way — only the ask to add to the table. */ @Transactional - public void allowAlternatives(UUID id, Person organizer, boolean allowed) { + public void allowAlternatives(UUID id, Caller organizer, boolean allowed) { requireEditable(requireOrganizer(id, organizer)).allowAlternatives(allowed); } /** Somebody the organizer thought of after sending it out. */ @Transactional - public void addInvitee(UUID id, Person organizer, Person invitee) { + public void addInvitee(UUID id, Caller organizer, Person invitee) { requireEditable(requireOrganizer(id, organizer)).invite(addressOf(invitee)); } @@ -304,8 +342,31 @@ private List invited(StoredPoll stored, Map named) { } /** - * Ranked by vote count, and by date where two days tie — a stable order matters - * because the rank is what the bars are painted from. + * Ordered by vote count, and by date where two days tie — a stable order matters + * because the screens draw the list in it. + * + *

The rank is a competition rank, so days on the same count share one and their + * bars are painted alike. It used to be the position in the list, which gave the + * earlier of two tied days the leader's dark bar and the words that go with it. + * + *

And a day only leads when it is alone at the top. Three days on four + * votes each are three days nobody has chosen between; calling the earliest of them + * the most popular is the application inventing a result, and offering only that one + * to be locked left the other two unreachable. + */ + /** + * Ordered by vote count, and by date where two days tie — a stable order matters + * because the screens draw the list in it. + * + *

The rank is a competition rank (1, 1, 1, 4), so days on the same count share + * one and their bars are painted alike. It used to be the position in the list, + * which handed the earlier of two tied days the leader's dark bar and the words + * that go with it. + * + *

And a day only leads when it is alone at the top. Three days on four + * votes each are three days nobody has chosen between; naming the earliest of them + * the most popular is the application inventing a result, and offering only that + * one to be locked left the other two unreachable. */ private List tallies(StoredPoll stored, Map named) { record Counted(LocalDate day, List voters) { @@ -316,12 +377,26 @@ record Counted(LocalDate day, List voters) { .thenComparing(Counted::day)) .toList(); var inviteCount = stored.inviteeEmails().size(); - return IntStream.range(0, counted.size()) - .mapToObj(index -> new DayTally(counted.get(index).day(), - counted.get(index).voters(), - index + 1, - inviteCount)) - .toList(); + var topCount = counted.isEmpty() ? 0 : counted.getFirst().voters().size(); + var aloneAtTheTop = topCount > 0 + && counted.stream().filter(entry -> entry.voters().size() == topCount).count() == 1; + + var tallies = new ArrayList(counted.size()); + var rank = 0; + var previousCount = -1; + for (var index = 0; index < counted.size(); index++) { + var votes = counted.get(index).voters().size(); + if (votes != previousCount) { + rank = index + 1; + previousCount = votes; + } + tallies.add(new DayTally(counted.get(index).day(), + counted.get(index).voters(), + rank, + inviteCount, + aloneAtTheTop && votes == topCount)); + } + return List.copyOf(tallies); } private List votersFor(StoredPoll stored, Map named, LocalDate day) { @@ -456,13 +531,23 @@ private StoredPoll requireOpen(StoredPoll stored) { * cannot post one — and it throws the same {@code IllegalArgumentException} an * unknown id throws, because a person who was not invited should not be able to * learn from the difference that the poll is real. + * + *

Anonymous mode invites nobody, so answering is what puts a voter on the list + * rather than the other way round. Adding them is not a formality: the tallies, the + * avatar stacks and {@code Poll.awaiting} all read the invitee list, so a ballot + * from somebody who is not on it would be counted nowhere. */ private StoredPoll requireInvited(UUID id, Person voter) { var stored = requireForUpdate(id); - if (!stored.inviteeEmails().contains(addressOf(voter))) { - throw unknown(id); + var email = addressOf(voter); + if (stored.inviteeEmails().contains(email)) { + return stored; } - return stored; + if (access.isAnonymous()) { + stored.invite(email); + return stored; + } + throw unknown(id); } /** @@ -474,19 +559,33 @@ private StoredPoll requireInvited(UUID id, Person voter) { * refused by name, because they can already see it and there is nothing left to * withhold. Anybody else is told the poll does not exist — the refusal itself must * not be what reveals that it does. + * + *

The admin code is the fourth answer, and only anonymous mode has one. It is + * checked against this poll's own code rather than looked up, so knowing six digits + * is worth nothing without the link they go with. Login-mode polls have no code, and + * the null check is what stops an absent one from matching an absent one. + * + *

Anonymous mode has only two answers, because the reason for the third is gone: + * anybody who reached this call is holding the link, and the link already showed + * them the poll. Withholding its existence from somebody looking at it would only + * make the refusal read as a bug. */ - private StoredPoll requireOrganizer(UUID id, Person asking) { + private StoredPoll requireOrganizer(UUID id, Caller asking) { var stored = requireForUpdate(id); - var email = addressOf(asking); - if (stored.organizerEmail().equals(email)) { + var email = addressOf(asking.person()); + if (stored.organizerEmail().equals(email) || carriesAdminCode(stored, asking)) { return stored; } - if (!stored.inviteeEmails().contains(email)) { + if (!access.isAnonymous() && !stored.inviteeEmails().contains(email)) { throw unknown(id); } throw new NotTheOrganizerException(id, email); } + private static boolean carriesAdminCode(StoredPoll stored, Caller asking) { + return stored.adminCode() != null && asking.adminCode().filter(stored.adminCode()::equals).isPresent(); + } + private StoredPoll require(UUID id) { return polls.findById(id).orElseThrow(() -> unknown(id)); } diff --git a/src/main/java/io/binarycodes/whichday/poll/service/StoredPoll.java b/src/main/java/io/binarycodes/whichday/poll/service/StoredPoll.java index 401c8b7..94e2fbd 100644 --- a/src/main/java/io/binarycodes/whichday/poll/service/StoredPoll.java +++ b/src/main/java/io/binarycodes/whichday/poll/service/StoredPoll.java @@ -75,6 +75,14 @@ class StoredPoll implements Persistable { @Column(name = "alternatives_allowed", nullable = false) private boolean alternativesAllowed = true; + /** + * What lets somebody who is not the organizer change this poll anyway. Written + * only in anonymous mode, where a session that closed its tab has no other way + * back; null on every login-mode poll. + */ + @Column(name = "admin_code", length = 6) + private String adminCode; + /** * Sorted rather than insertion-ordered: every write already sorts, so this is the * order the days were in anyway, and it no longer depends on what order the rows @@ -145,6 +153,14 @@ String organizerEmail() { return organizerEmail; } + String adminCode() { + return adminCode; + } + + void useAdminCode(String code) { + this.adminCode = code; + } + List inviteeEmails() { return List.copyOf(inviteeEmails); } diff --git a/src/main/java/io/binarycodes/whichday/poll/ui/component/DayBallot.java b/src/main/java/io/binarycodes/whichday/poll/ui/component/DayBallot.java index 8d2ec63..718555a 100644 --- a/src/main/java/io/binarycodes/whichday/poll/ui/component/DayBallot.java +++ b/src/main/java/io/binarycodes/whichday/poll/ui/component/DayBallot.java @@ -16,7 +16,9 @@ import com.vaadin.flow.component.icon.VaadinIcon; import io.binarycodes.whichday.base.ui.DateText; +import io.binarycodes.whichday.people.domain.Person; import io.binarycodes.whichday.people.ui.AvatarStack; +import io.binarycodes.whichday.people.ui.NameChips; import io.binarycodes.whichday.poll.domain.DayTally; /** @@ -29,6 +31,12 @@ public class DayBallot extends CustomField> { /** Above this many voters the row shows a count instead of faces. */ private static final int FACE_LIMIT = 3; + /** + * Names are wider than faces but they wrap, so a row holds more of them before it + * has to give up and count. Past this the tail becomes "+37 more". + */ + private static final int NAME_LIMIT = 6; + private final Div rows = new Div(); private final Map rowsByDay = new HashMap<>(); private final Set selection = new LinkedHashSet<>(); @@ -36,6 +44,7 @@ public class DayBallot extends CustomField> { private List tallies = List.of(); private NoteText noteText = (tally, leading) -> ""; + private boolean namesRatherThanFaces; /** How a row describes the votes already on a day; the view owns the wording. */ public interface NoteText { @@ -53,6 +62,23 @@ public void setNoteText(NoteText noteText) { render(); } + /** + * Name the voters instead of showing their faces. + * + *

An avatar is initials, and initials only identify anybody when the names + * behind them were settled in advance. Where a voter types their own name minutes + * before answering, one letter is as likely to be a stranger's as a colleague's — + * so the name goes on the row in full. + * + *

An opt-in the caller makes rather than something read from the mode here: this + * component is told what to draw, and never learns why. + */ + public DayBallot withVoterNames() { + this.namesRatherThanFaces = true; + render(); + return this; + } + public void setTallies(List tallies) { this.tallies = List.copyOf(tallies); render(); @@ -118,17 +144,48 @@ private Div mainOf(DayTally tally) { * The day in front always gets words, because "most popular" is the point of it. */ private Component supportOf(DayTally tally) { + if (namesRatherThanFaces) { + return namedSupportOf(tally); + } var voters = tally.voters(); if (!voters.isEmpty() && !tally.isLeading() && voters.size() <= FACE_LIMIT) { - var stack = new AvatarStack(FACE_LIMIT); - stack.addClassName("push-s"); - return stack.show(voters); + return facesOf(voters); + } + return noteOf(tally); + } + + /** + * Names wrap, so a row can carry them whatever the count — there is no size at + * which it has to fall back to a number the way the faces do. The day in front + * keeps its words as well, because "most popular" is the reason to look at it and + * a row of names does not say that. + */ + private Component namedSupportOf(DayTally tally) { + var voters = tally.voters(); + if (voters.isEmpty()) { + return noteOf(tally); } + return tally.isLeading() ? new Div(noteOf(tally), namesOf(voters)) : namesOf(voters); + } + + private Component facesOf(List voters) { + var stack = new AvatarStack(FACE_LIMIT); + stack.addClassName("push-s"); + return stack.show(voters); + } + + private Div noteOf(DayTally tally) { var note = new Div(new Span(noteText.describe(tally, tally.isLeading()))); note.addClassName("day-row-note"); return note; } + private Div namesOf(List voters) { + var row = NameChips.of(this, voters, NAME_LIMIT); + row.addClassName("day-row-voters"); + return row; + } + private Span checkMark() { var check = new Span(new Icon(VaadinIcon.CHECK)); check.addClassName("day-row-check"); diff --git a/src/main/java/io/binarycodes/whichday/poll/ui/component/DayChoice.java b/src/main/java/io/binarycodes/whichday/poll/ui/component/DayChoice.java new file mode 100644 index 0000000..47ab0ef --- /dev/null +++ b/src/main/java/io/binarycodes/whichday/poll/ui/component/DayChoice.java @@ -0,0 +1,103 @@ +package io.binarycodes.whichday.poll.ui.component; + +import java.time.LocalDate; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import com.vaadin.flow.component.customfield.CustomField; +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.html.NativeButton; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.icon.Icon; +import com.vaadin.flow.component.icon.VaadinIcon; + +import io.binarycodes.whichday.base.ui.DateText; + +/** + * One day out of a few, as the rows the voting screen draws — but picking one clears + * the last, because this is a choice rather than an answer. + * + *

{@code CustomField} so the screen treats "which day" as one value with + * one listener, the way {@code DayBallot} and {@code MonthCalendar} do. + */ +public class DayChoice extends CustomField { + + private final Div rows = new Div(); + private final Map rowsByDay = new HashMap<>(); + + private final List days; + private LocalDate chosen; + + public DayChoice(List days) { + this.days = List.copyOf(days); + rows.addClassName("stack-s"); + add(rows); + render(); + } + + @Override + protected LocalDate generateModelValue() { + return chosen; + } + + @Override + protected void setPresentationValue(LocalDate day) { + chosen = day; + render(); + } + + private void render() { + rows.removeAll(); + rowsByDay.clear(); + days.forEach(day -> rows.add(rowFor(day))); + } + + private NativeButton rowFor(LocalDate day) { + var number = new Div(new Span(DateText.dayNumber(day))); + number.addClassName("day-row-number"); + var weekday = new Div(new Span(DateText.weekdayAbbreviation(this, day))); + weekday.addClassName("day-row-weekday"); + var numeral = new Div(number, weekday); + numeral.addClassName("day-row-numeral"); + + var date = new Div(new Span(DateText.full(this, day))); + date.addClassName("day-row-date"); + var main = new Div(date); + main.addClassName("day-row-main"); + + var check = new Span(new Icon(VaadinIcon.CHECK)); + check.addClassName("day-row-check"); + + var row = new NativeButton(); + row.addClassName("day-row"); + row.add(numeral, main, check); + row.setAriaLabel(DateText.full(this, day)); + row.getElement().setAttribute("aria-pressed", String.valueOf(day.equals(chosen))); + row.addClickListener(ignored -> choose(day)); + rowsByDay.put(day, row); + return row; + } + + /** + * Only the two rows that change are touched. Rebuilding the list would throw away + * the row that was just pressed, and the caret with it. + */ + private void choose(LocalDate day) { + var previous = chosen; + chosen = day; + markPressed(previous); + markPressed(day); + updateValue(); + } + + private void markPressed(LocalDate day) { + if (day == null) { + return; + } + var row = rowsByDay.get(day); + if (row != null) { + row.getElement().setAttribute("aria-pressed", String.valueOf(day.equals(chosen))); + } + } +} diff --git a/src/main/java/io/binarycodes/whichday/poll/ui/presenter/PollPresenter.java b/src/main/java/io/binarycodes/whichday/poll/ui/presenter/PollPresenter.java index 02c2a41..cb5c036 100644 --- a/src/main/java/io/binarycodes/whichday/poll/ui/presenter/PollPresenter.java +++ b/src/main/java/io/binarycodes/whichday/poll/ui/presenter/PollPresenter.java @@ -14,9 +14,11 @@ import com.vaadin.flow.spring.annotation.VaadinSessionScope; +import io.binarycodes.whichday.base.config.AccessMode; import io.binarycodes.whichday.people.domain.Person; import io.binarycodes.whichday.people.ui.presenter.ViewerSession; import io.binarycodes.whichday.poll.domain.Ballot; +import io.binarycodes.whichday.poll.domain.Caller; import io.binarycodes.whichday.poll.domain.Poll; import io.binarycodes.whichday.poll.domain.PollSummary; import io.binarycodes.whichday.poll.domain.AccountMatch; @@ -36,19 +38,39 @@ public class PollPresenter { private final InviteeSearch invitees; private final ViewerSession session; private final Clock clock; + private final AccessMode access; private final PollDraft draft = new PollDraft(); - public PollPresenter(PollService polls, InviteeSearch invitees, ViewerSession session, Clock clock) { + public PollPresenter(PollService polls, InviteeSearch invitees, ViewerSession session, Clock clock, + AccessMode access) { this.polls = polls; this.invitees = invitees; this.session = session; this.clock = clock; + this.access = access; } public Person viewer() { return session.viewer(); } + /** + * The viewer plus whatever they have to show for wanting to change a poll. Every + * organizer-gated call takes one, so the admin code reaches the service the same + * way the viewer does and no screen has to carry it. + */ + private Caller caller() { + return Caller.of(session.viewer(), session.adminCode()); + } + + /** + * Which way this deployment lets people in. The screens ask because two of them + * have nothing to show in anonymous mode and one shows something extra. + */ + public boolean anonymous() { + return access.isAnonymous(); + } + public void signOut() { session.signOut(); } @@ -75,7 +97,7 @@ public List draftPolls() { } public void deleteDraft(UUID id) { - polls.deleteDraft(id, viewer()); + polls.deleteDraft(id, caller()); } public List settledPolls() { @@ -123,8 +145,19 @@ public boolean hasAccount(String email) { * The organizer leads the invited list, and the rest keep the order they were * added in — not directory order, which an outsider has no place in. Anybody who * managed to add the organizer to their own draft is not counted twice. + * + *

An anonymous poll starts with nobody on it, the organizer included. There is + * no list of who was asked in that mode — anybody with the link may answer — so the + * only honest membership is having answered, and the service adds each voter as + * they do. Seeding the organizer would put one person in a "waiting on" list that + * cannot know who else is missing. */ public UUID createFromDraft() { + if (anonymous()) { + var id = polls.create(draft.title(), viewer(), List.of()); + draft.reset(); + return id; + } var everybody = new ArrayList(); everybody.add(viewer()); draft.invitees().stream() @@ -146,23 +179,23 @@ public Optional latestClosingDay(UUID id) { } public void closeOn(UUID id, LocalDate day) { - polls.closeOn(id, viewer(), day); + polls.closeOn(id, caller(), day); } public void allowAlternatives(UUID id, boolean allowed) { - polls.allowAlternatives(id, viewer(), allowed); + polls.allowAlternatives(id, caller(), allowed); } public void addInvitee(UUID id, Person person) { - polls.addInvitee(id, viewer(), person); + polls.addInvitee(id, caller(), person); } public void chooseDays(UUID id, Set days) { - polls.replaceCandidateDays(id, viewer(), days); + polls.replaceCandidateDays(id, caller(), days); } public void send(UUID id) { - polls.send(id, viewer()); + polls.send(id, caller()); } public void vote(UUID id, Set days) { @@ -174,18 +207,35 @@ public void declineAll(UUID id, List proposedDays, String note) { } public void acceptProposal(UUID id, LocalDate day) { - polls.acceptProposal(id, viewer(), day); + polls.acceptProposal(id, caller(), day); } public void lock(UUID id, LocalDate day) { - polls.lock(id, viewer(), day); + polls.lock(id, caller(), day); } public Optional ballotOf(UUID id) { return poll(id).flatMap(poll -> poll.ballotOf(viewer())); } + /** + * Whether this viewer may change the poll — which is being the person who called + * it, or in anonymous mode holding its six digits. It mirrors what + * {@code PollService.requireOrganizer} decides, and mirroring is all it does: the + * screens hide what is not yours as a courtesy, and the service is the check. + */ public boolean isOrganizer(Poll poll) { - return poll.organizer().equals(viewer()); + return poll.organizer().equals(viewer()) || holdsAdminCodeFor(poll); + } + + private boolean holdsAdminCodeFor(Poll poll) { + return session.adminCode() + .filter(code -> polls.adminCodeOf(poll.id()).filter(code::equals).isPresent()) + .isPresent(); + } + + /** The six digits that get somebody back to this poll, for the screen that shows them. */ + public Optional adminCode(UUID id) { + return polls.adminCodeOf(id); } } diff --git a/src/main/java/io/binarycodes/whichday/poll/ui/share/CalendarInvite.java b/src/main/java/io/binarycodes/whichday/poll/ui/share/CalendarInvite.java index bedba09..aeebb08 100644 --- a/src/main/java/io/binarycodes/whichday/poll/ui/share/CalendarInvite.java +++ b/src/main/java/io/binarycodes/whichday/poll/ui/share/CalendarInvite.java @@ -12,9 +12,13 @@ import io.binarycodes.whichday.poll.domain.Poll; /** - * The days as an iCalendar file. Whole days only, so every event is a DATE-valued + * The settled day as an iCalendar file. Whole days only, so the event is a DATE-valued * all-day entry — which is also why DTEND is the day after: iCalendar's end is * exclusive, and an inclusive one shows a one-day event as zero-length. + * + *

Only a decided day is offered. The share screen used to hand out the candidate + * days as TENTATIVE events, which put every maybe in the reader's calendar for them to + * delete once the poll settled on one of them. */ public final class CalendarInvite { @@ -24,14 +28,9 @@ public final class CalendarInvite { private CalendarInvite() { } - /** Every day still on the table, so a voter can see the options against their week. */ - public static DownloadHandler forCandidateDays(Poll poll) { - return download(poll.id() + "-options.ics", calendar(poll, poll.candidateDays(), true)); - } - /** The one day the team landed on. */ public static DownloadHandler forLockedDay(Poll poll) { - return download(poll.id() + ".ics", calendar(poll, List.of(poll.lockedDay()), false)); + return download(poll.id() + ".ics", calendar(poll, List.of(poll.lockedDay()))); } private static DownloadHandler download(String fileName, String content) { @@ -40,23 +39,23 @@ private static DownloadHandler download(String fileName, String content) { new ByteArrayInputStream(bytes), fileName, "text/calendar", bytes.length)); } - static String calendar(Poll poll, List days, boolean tentative) { + static String calendar(Poll poll, List days) { var body = new StringBuilder("BEGIN:VCALENDAR").append(LINE_END) .append("VERSION:2.0").append(LINE_END) .append("PRODID:-//whichday//EN").append(LINE_END); for (var index = 0; index < days.size(); index++) { - appendEvent(body, poll, days.get(index), index, tentative); + appendEvent(body, poll, days.get(index), index); } return body.append("END:VCALENDAR").append(LINE_END).toString(); } - private static void appendEvent(StringBuilder body, Poll poll, LocalDate day, int index, boolean tentative) { + private static void appendEvent(StringBuilder body, Poll poll, LocalDate day, int index) { body.append("BEGIN:VEVENT").append(LINE_END) .append("UID:").append(poll.id()).append('-').append(index).append("@whichday").append(LINE_END) .append("DTSTART;VALUE=DATE:").append(DATE.format(day)).append(LINE_END) .append("DTEND;VALUE=DATE:").append(DATE.format(day.plusDays(1))).append(LINE_END) .append("SUMMARY:").append(escape(poll.title())).append(LINE_END) - .append("STATUS:").append(tentative ? "TENTATIVE" : "CONFIRMED").append(LINE_END) + .append("STATUS:CONFIRMED").append(LINE_END) .append("END:VEVENT").append(LINE_END); } diff --git a/src/main/java/io/binarycodes/whichday/poll/ui/share/VotingLink.java b/src/main/java/io/binarycodes/whichday/poll/ui/share/VotingLink.java index 09ec59f..02cc32a 100644 --- a/src/main/java/io/binarycodes/whichday/poll/ui/share/VotingLink.java +++ b/src/main/java/io/binarycodes/whichday/poll/ui/share/VotingLink.java @@ -5,13 +5,13 @@ import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.clipboard.Clipboard; -import com.vaadin.flow.component.notification.Notification; import com.vaadin.flow.component.webshare.ShareContent; import com.vaadin.flow.component.webshare.WebShare; import com.vaadin.flow.component.webshare.WebShareSupport; import com.vaadin.flow.server.VaadinRequest; import com.vaadin.flow.server.VaadinServletRequest; +import io.binarycodes.whichday.base.ui.Toast; import io.binarycodes.whichday.poll.domain.Poll; /** @@ -75,8 +75,8 @@ public static void shareFrom(Button button, Poll poll) { return; } Clipboard.onClick(button).writeText(absolute(poll.id()), - copied -> Notification.show(button.getTranslation("share.copied")), - failure -> Notification.show(button.getTranslation("share.copyFailed"))); + copied -> Toast.show(button.getTranslation("share.copied")), + failure -> Toast.show(button.getTranslation("share.copyFailed"))); } private static String path(UUID id) { diff --git a/src/main/java/io/binarycodes/whichday/poll/ui/view/BallotView.java b/src/main/java/io/binarycodes/whichday/poll/ui/view/BallotView.java index 5cec152..78bdd33 100644 --- a/src/main/java/io/binarycodes/whichday/poll/ui/view/BallotView.java +++ b/src/main/java/io/binarycodes/whichday/poll/ui/view/BallotView.java @@ -8,12 +8,13 @@ import com.vaadin.flow.component.html.Div; import com.vaadin.flow.component.html.Span; -import com.vaadin.flow.component.notification.Notification; import com.vaadin.flow.router.BeforeEnterEvent; import com.vaadin.flow.router.Route; import io.binarycodes.whichday.base.ui.Actions; +import io.binarycodes.whichday.base.ui.Toast; import io.binarycodes.whichday.base.ui.Typography; +import io.binarycodes.whichday.people.ui.AccountLabels; import io.binarycodes.whichday.people.ui.AccountMenu; import io.binarycodes.whichday.poll.domain.DayTally; import io.binarycodes.whichday.poll.domain.Poll; @@ -62,6 +63,9 @@ protected void build(Poll poll) { body(headline, lede); var ballot = new DayBallot(presenter.today()); + if (presenter.anonymous()) { + ballot.withVoterNames(); + } ballot.addClassNames("ballot-field", "push-xl"); ballot.setNoteText(this::noteFor); ballot.setTallies(poll.tallies()); @@ -85,7 +89,9 @@ protected void build(Poll poll) { private Div invitation(Poll poll) { var text = Typography.meta(getTranslation("ballot.invitedBy", poll.organizer().firstName(), poll.title())); - var account = new AccountMenu(presenter.viewer(), presenter::signOut); + var account = new AccountMenu(presenter.viewer(), + AccountLabels.of(this, presenter.viewer(), presenter.anonymous()), + presenter::signOut); var row = new Div(homeButton(), text, account); row.addClassName("invitation"); return row; @@ -116,11 +122,11 @@ private void renderProgress(Poll poll) { private void submit(Poll poll) { if (chosen.isEmpty()) { - Notification.show(getTranslation("ballot.needOne")); + Toast.show(getTranslation("ballot.needOne")); return; } presenter.vote(id(), Set.copyOf(chosen)); - Notification.show(getTranslation("ballot.submitted")); + Toast.show(getTranslation("ballot.submitted")); // The organizer came from the standings and wants them back; everybody else // wants the receipt for the answer they just gave. goTo(presenter.isOrganizer(poll) ? ResultsView.class : ReceiptView.class); diff --git a/src/main/java/io/binarycodes/whichday/poll/ui/view/CandidateDaysView.java b/src/main/java/io/binarycodes/whichday/poll/ui/view/CandidateDaysView.java index 9b1b4e9..71b9d8e 100644 --- a/src/main/java/io/binarycodes/whichday/poll/ui/view/CandidateDaysView.java +++ b/src/main/java/io/binarycodes/whichday/poll/ui/view/CandidateDaysView.java @@ -9,7 +9,6 @@ import com.vaadin.flow.component.checkbox.Checkbox; import com.vaadin.flow.component.html.Div; import com.vaadin.flow.component.icon.VaadinIcon; -import com.vaadin.flow.component.notification.Notification; import com.vaadin.flow.router.BeforeEnterEvent; import com.vaadin.flow.router.Route; @@ -18,6 +17,7 @@ import io.binarycodes.whichday.base.ui.Counts; import io.binarycodes.whichday.base.ui.DateText; import io.binarycodes.whichday.base.ui.HintBar; +import io.binarycodes.whichday.base.ui.Toast; import io.binarycodes.whichday.base.ui.Typography; import io.binarycodes.whichday.base.ui.TopBar; import io.binarycodes.whichday.poll.domain.Poll; @@ -80,7 +80,7 @@ protected void build(Poll poll) { summary.addClassNames("row-between", "divider-top"); chips.addClassNames("chip-row", "push-m"); footer(summary, chips, alternativesToggle(poll), - Actions.commit(getTranslation("days.send"), ignored -> send())); + Actions.commit(getTranslation("days.next"), ignored -> goOn())); renderChosen(); } @@ -117,9 +117,13 @@ private void clear() { render(); } - private void send() { + /** + * Saves the days and opens the share screen, which is all it does — nothing is sent + * anywhere from here in either mode, and the label says so. + */ + private void goOn() { if (chosen.isEmpty()) { - Notification.show(getTranslation("days.needOne")); + Toast.show(getTranslation("days.needOne")); return; } presenter.chooseDays(id(), Set.copyOf(chosen)); diff --git a/src/main/java/io/binarycodes/whichday/poll/ui/view/InviteeSearchView.java b/src/main/java/io/binarycodes/whichday/poll/ui/view/InviteeSearchView.java index fbc19c8..6c04b9f 100644 --- a/src/main/java/io/binarycodes/whichday/poll/ui/view/InviteeSearchView.java +++ b/src/main/java/io/binarycodes/whichday/poll/ui/view/InviteeSearchView.java @@ -10,7 +10,6 @@ import com.vaadin.flow.component.html.Span; import com.vaadin.flow.component.icon.Icon; import com.vaadin.flow.component.icon.VaadinIcon; -import com.vaadin.flow.component.notification.Notification; import com.vaadin.flow.component.textfield.TextField; import com.vaadin.flow.data.value.ValueChangeMode; import com.vaadin.flow.router.BeforeEnterEvent; @@ -21,7 +20,9 @@ import io.binarycodes.whichday.base.ui.Actions; import io.binarycodes.whichday.base.ui.HintBar; +import io.binarycodes.whichday.base.ui.Home; import io.binarycodes.whichday.base.ui.Screen; +import io.binarycodes.whichday.base.ui.Toast; import io.binarycodes.whichday.base.ui.TopBar; import io.binarycodes.whichday.base.ui.Typography; import io.binarycodes.whichday.people.domain.EmailAddress; @@ -62,9 +63,18 @@ public InviteeSearchView(PollPresenter presenter) { * Built on navigation rather than in the constructor: Vaadin reuses a view instance * when the route it is asked for is the one already showing, so a constructor-only * build leaves whatever it drew the first time. + * + *

Anonymous mode has no directory to search and nobody to invite into one, so + * the screen is not part of that mode and the URL leads home instead. Nothing + * navigates here — the field that did is not drawn — and this is for whoever typed + * the path anyway. */ @Override public void beforeEnter(BeforeEnterEvent event) { + if (presenter.anonymous()) { + event.forwardTo(Home.viewFor(presenter)); + return; + } render(); } @@ -129,7 +139,7 @@ private void acceptPastedList(String pasted) { } query.setValue(leftovers.toString()); if (accepted > 0) { - Notification.show(accepted == 1 + Toast.show(accepted == 1 ? getTranslation("invitees.pasted.one") : getTranslation("invitees.pasted.many", accepted)); } diff --git a/src/main/java/io/binarycodes/whichday/poll/ui/view/LockedView.java b/src/main/java/io/binarycodes/whichday/poll/ui/view/LockedView.java index 331286d..baf9450 100644 --- a/src/main/java/io/binarycodes/whichday/poll/ui/view/LockedView.java +++ b/src/main/java/io/binarycodes/whichday/poll/ui/view/LockedView.java @@ -13,6 +13,7 @@ import io.binarycodes.whichday.base.ui.Counts; import io.binarycodes.whichday.base.ui.DateText; import io.binarycodes.whichday.people.ui.AvatarStack; +import io.binarycodes.whichday.people.ui.NameChips; import io.binarycodes.whichday.poll.domain.DayTally; import io.binarycodes.whichday.poll.domain.Poll; import io.binarycodes.whichday.poll.ui.presenter.PollPresenter; @@ -27,6 +28,13 @@ @Route("poll/:id/locked") public class LockedView extends PollScreen { + /** + * How many of the people coming this screen names before the rest become a count. + * The same six the ballot rows use — there is room here, and this is the one screen + * anybody keeps. + */ + private static final int NAMED_ANSWERS = 6; + public LockedView(PollPresenter presenter) { super(presenter); } @@ -48,22 +56,28 @@ protected void build(Poll poll) { number.addClassName("locked-number"); var month = new Div(new Span(DateText.monthFull(this, day) + " " + DateText.year(day))); month.addClassName("locked-month"); - var summary = new Div(new Span(getTranslation("locked.summary", - poll.title(), yesCount(poll), poll.inviteCount()))); + var summary = new Div(new Span(presenter.anonymous() + ? getTranslation("locked.summary.anonymous", poll.title(), yesCount(poll)) + : getTranslation("locked.summary", poll.title(), yesCount(poll), poll.inviteCount()))); summary.addClassName("locked-summary"); var date = new Div(weekday, number, month, summary); date.addClassName("locked-date"); body(date); - var stack = new AvatarStack().large().show(poll.answered()); - stack.addClassName("push-3xl"); - body(stack); + body(whoSaidYes(poll)); var addToCalendar = new Anchor(CalendarInvite.forLockedDay(poll), ""); addToCalendar.getElement().setAttribute("download", true); addToCalendar.add(new Icon(VaadinIcon.CALENDAR), new Span(getTranslation("locked.addToCalendar"))); addToCalendar.addClassNames("action", "action-primary", "action-anchor"); + // Telling the team is a mail to the addresses on the poll, and anonymous mode + // has none — the day itself is still the thing to take away, so the calendar + // file stays. + if (presenter.anonymous()) { + footer(addToCalendar); + return; + } var tell = new Anchor(MailLink.announcement(this, poll, DateText.full(this, day)), ""); tell.add(new Span(getTranslation("locked.tellTeam"))); tell.addClassNames("action", "action-outline", "action-anchor"); @@ -71,6 +85,22 @@ protected void build(Poll poll) { footer(addToCalendar, tell); } + /** + * Who is coming. Faces where an account's initials mean something, names where they + * do not — a visitor typed theirs minutes before answering, so "W" is as likely to + * be a stranger as the colleague you expected. + */ + private Div whoSaidYes(Poll poll) { + if (presenter.anonymous()) { + var names = NameChips.of(this, poll.answered(), NAMED_ANSWERS); + names.addClassNames("locked-names", "push-3xl"); + return names; + } + var stack = new AvatarStack().large().show(poll.answered()); + stack.addClassName("push-3xl"); + return stack; + } + /** How many said yes to the day that won, not how many answered at all. */ private int yesCount(Poll poll) { return poll.tallies().stream() diff --git a/src/main/java/io/binarycodes/whichday/poll/ui/view/NewPollView.java b/src/main/java/io/binarycodes/whichday/poll/ui/view/NewPollView.java index d07d948..fe35d8a 100644 --- a/src/main/java/io/binarycodes/whichday/poll/ui/view/NewPollView.java +++ b/src/main/java/io/binarycodes/whichday/poll/ui/view/NewPollView.java @@ -5,7 +5,6 @@ import com.vaadin.flow.component.html.Div; import com.vaadin.flow.component.icon.Icon; import com.vaadin.flow.component.icon.VaadinIcon; -import com.vaadin.flow.component.notification.Notification; import com.vaadin.flow.component.textfield.TextField; import com.vaadin.flow.data.value.ValueChangeMode; import com.vaadin.flow.router.BeforeEnterEvent; @@ -16,10 +15,13 @@ import io.binarycodes.whichday.base.ui.Actions; import io.binarycodes.whichday.base.ui.AppHeader; +import io.binarycodes.whichday.base.ui.Home; import io.binarycodes.whichday.base.ui.Screen; +import io.binarycodes.whichday.base.ui.Toast; import io.binarycodes.whichday.base.ui.Typography; import io.binarycodes.whichday.people.service.AccountDirectory; import io.binarycodes.whichday.people.domain.Person; +import io.binarycodes.whichday.people.ui.AccountLabels; import io.binarycodes.whichday.people.ui.InviteeChips; import io.binarycodes.whichday.poll.ui.presenter.PollPresenter; @@ -27,6 +29,10 @@ * Where a poll starts: a name, who decides it, and one button through to the * calendar. Both fields write to the session's draft, so stepping out to search for * somebody and coming back loses nothing. + * + *

In anonymous mode it is the name and the button. There is no directory to search + * and no address to invite anybody at — the link is the invitation there — so the + * field that collects people is not drawn and not required. */ @PermitAll @Route("new") @@ -53,6 +59,7 @@ private void render() { clearFooter(); body(new AppHeader(getTranslation("app.name"), presenter.viewer(), + AccountLabels.of(this, presenter.viewer(), presenter.anonymous()), presenter::signOut, this::goHome)); var headline = Typography.hero(getTranslation("create.headline")); @@ -61,14 +68,18 @@ private void render() { lede.addClassName("push-l"); body(headline, lede); - var fields = new Div(nameField(), inviteeField()); + var fields = presenter.anonymous() + ? new Div(nameField()) + : new Div(nameField(), inviteeField()); fields.addClassNames("field-column", "push-2xl"); body(fields); var next = Actions.primary(getTranslation("create.next"), ignored -> chooseDays()); next.setIcon(new Icon(VaadinIcon.ARROW_RIGHT)); next.setIconAfterText(true); - var footnote = Typography.meta(getTranslation("create.footnote")); + var footnote = Typography.meta(getTranslation(presenter.anonymous() + ? "create.footnote.anonymous" + : "create.footnote")); footnote.addClassNames("meta-faint", "meta-centred"); footer(next, footnote); } @@ -125,8 +136,8 @@ private void chooseDays() { if (titleIsMissing()) { return; } - if (presenter.draft().isEmpty()) { - Notification.show(getTranslation("invitees.needOne")); + if (!presenter.anonymous() && presenter.draft().isEmpty()) { + Toast.show(getTranslation("invitees.needOne")); return; } var id = presenter.createFromDraft(); @@ -137,12 +148,12 @@ private boolean titleIsMissing() { if (!presenter.draft().title().isBlank()) { return false; } - Notification.show(getTranslation("create.eventName.required")); + Toast.show(getTranslation("create.eventName.required")); return true; } private void goHome() { - getUI().ifPresent(ui -> ui.navigate(PollsView.class)); + getUI().ifPresent(ui -> ui.navigate(Home.viewFor(presenter))); } @Override diff --git a/src/main/java/io/binarycodes/whichday/poll/ui/view/NoDayWorksView.java b/src/main/java/io/binarycodes/whichday/poll/ui/view/NoDayWorksView.java index d746e3c..0deecbf 100644 --- a/src/main/java/io/binarycodes/whichday/poll/ui/view/NoDayWorksView.java +++ b/src/main/java/io/binarycodes/whichday/poll/ui/view/NoDayWorksView.java @@ -12,7 +12,6 @@ import com.vaadin.flow.component.html.Span; import com.vaadin.flow.component.icon.Icon; import com.vaadin.flow.component.icon.VaadinIcon; -import com.vaadin.flow.component.notification.Notification; import com.vaadin.flow.component.textfield.TextArea; import com.vaadin.flow.data.value.ValueChangeMode; import com.vaadin.flow.router.BeforeEnterEvent; @@ -21,6 +20,8 @@ import io.binarycodes.whichday.base.ui.Actions; import io.binarycodes.whichday.base.ui.Counts; import io.binarycodes.whichday.base.ui.HintBar; +import io.binarycodes.whichday.base.ui.Home; +import io.binarycodes.whichday.base.ui.Toast; import io.binarycodes.whichday.base.ui.TopBar; import io.binarycodes.whichday.base.ui.Typography; import io.binarycodes.whichday.poll.domain.Poll; @@ -69,7 +70,7 @@ protected void build(Poll poll) { body(new TopBar(poll.title()) .withBack(getTranslation("nav.back"), () -> goTo(BallotView.class)) - .withHome(getTranslation("nav.home"), this::goHome)); + .withHome(Home.labelFor(this, presenter), this::goHome)); var headline = Typography.displaySmall(getTranslation("none.headline", Counts.days(this, poll.candidateDays().size()))); @@ -197,7 +198,7 @@ private void send() { ? List.copyOf(proposed) : List.of(), note.getValue()); - Notification.show(getTranslation("none.sent")); + Toast.show(getTranslation("none.sent")); goTo(ReceiptView.class); } diff --git a/src/main/java/io/binarycodes/whichday/poll/ui/view/NotFoundView.java b/src/main/java/io/binarycodes/whichday/poll/ui/view/NotFoundView.java index c1d1fb1..d825ab3 100644 --- a/src/main/java/io/binarycodes/whichday/poll/ui/view/NotFoundView.java +++ b/src/main/java/io/binarycodes/whichday/poll/ui/view/NotFoundView.java @@ -6,20 +6,24 @@ import com.vaadin.flow.router.Route; import io.binarycodes.whichday.base.ui.Actions; +import io.binarycodes.whichday.base.ui.Home; import io.binarycodes.whichday.base.ui.Screen; import io.binarycodes.whichday.base.ui.Typography; +import io.binarycodes.whichday.poll.ui.presenter.PollPresenter; /** A link to a poll that is not here — expired, or never sent. */ @PermitAll @Route("gone") public class NotFoundView extends Screen implements HasDynamicTitle { - public NotFoundView() { + public NotFoundView(PollPresenter presenter) { body(Typography.displayMedium(getTranslation("notFound.headline")), Typography.lede(getTranslation("notFound.lede"))); - var home = Actions.primary(getTranslation("notFound.action"), - ignored -> getUI().ifPresent(ui -> ui.navigate(PollsView.class))); + var home = Actions.primary(getTranslation(presenter.anonymous() + ? "notFound.action.anonymous" + : "notFound.action"), + ignored -> getUI().ifPresent(ui -> ui.navigate(Home.viewFor(presenter)))); home.addClassName(Actions.HOME_CLASS); footer(home); } diff --git a/src/main/java/io/binarycodes/whichday/poll/ui/view/PollScreen.java b/src/main/java/io/binarycodes/whichday/poll/ui/view/PollScreen.java index f303c6b..51763b4 100644 --- a/src/main/java/io/binarycodes/whichday/poll/ui/view/PollScreen.java +++ b/src/main/java/io/binarycodes/whichday/poll/ui/view/PollScreen.java @@ -12,6 +12,7 @@ import com.vaadin.flow.router.RouteParameters; import io.binarycodes.whichday.base.ui.Actions; +import io.binarycodes.whichday.base.ui.Home; import io.binarycodes.whichday.base.ui.Screen; import io.binarycodes.whichday.poll.domain.Poll; import io.binarycodes.whichday.poll.ui.presenter.PollPresenter; @@ -87,7 +88,7 @@ protected void goTo(Class view) { } protected void goHome() { - getUI().ifPresent(ui -> ui.navigate(PollsView.class)); + getUI().ifPresent(ui -> ui.navigate(Home.viewFor(presenter))); } /** @@ -97,7 +98,7 @@ protected void goHome() { * on the not-found screen. */ protected Button homeButton() { - var home = Actions.icon(VaadinIcon.HOME, getTranslation("nav.home"), ignored -> goHome()); + var home = Actions.icon(VaadinIcon.HOME, Home.labelFor(this, presenter), ignored -> goHome()); home.addClassName(Actions.HOME_CLASS); return home; } diff --git a/src/main/java/io/binarycodes/whichday/poll/ui/view/PollsView.java b/src/main/java/io/binarycodes/whichday/poll/ui/view/PollsView.java index 4c75663..ab66c0c 100644 --- a/src/main/java/io/binarycodes/whichday/poll/ui/view/PollsView.java +++ b/src/main/java/io/binarycodes/whichday/poll/ui/view/PollsView.java @@ -9,7 +9,6 @@ import com.vaadin.flow.component.html.Span; import com.vaadin.flow.component.icon.Icon; import com.vaadin.flow.component.icon.VaadinIcon; -import com.vaadin.flow.component.notification.Notification; import com.vaadin.flow.router.BeforeEnterEvent; import com.vaadin.flow.router.BeforeEnterObserver; import com.vaadin.flow.router.HasDynamicTitle; @@ -18,11 +17,14 @@ import io.binarycodes.whichday.base.ui.Actions; import io.binarycodes.whichday.base.ui.AppHeader; +import io.binarycodes.whichday.base.ui.Home; import io.binarycodes.whichday.base.ui.Chip; import io.binarycodes.whichday.base.ui.Counts; import io.binarycodes.whichday.base.ui.DateText; import io.binarycodes.whichday.base.ui.Screen; +import io.binarycodes.whichday.base.ui.Toast; import io.binarycodes.whichday.base.ui.Typography; +import io.binarycodes.whichday.people.ui.AccountLabels; import io.binarycodes.whichday.poll.domain.PollSummary; import io.binarycodes.whichday.poll.ui.component.DraftRow; import io.binarycodes.whichday.poll.ui.component.PollRow; @@ -46,9 +48,19 @@ public PollsView(PollPresenter presenter) { * Built on navigation rather than in the constructor: Vaadin reuses a view instance * when the route it is asked for is the one already showing, so a constructor-only * build leaves whatever it drew the first time. + * + *

Anonymous mode has nothing to put here — no account, no invitations, nothing + * that outlives the session — so this route hands straight over to the one screen + * that is home there. It stays registered rather than being unregistered so that + * every existing way home keeps working, and so that "/" means something in both + * modes. */ @Override public void beforeEnter(BeforeEnterEvent event) { + if (presenter.anonymous()) { + event.forwardTo(Home.viewFor(presenter)); + return; + } render(); } @@ -57,6 +69,7 @@ private void render() { clearFooter(); body(new AppHeader(getTranslation("app.name"), presenter.viewer(), + AccountLabels.of(this, presenter.viewer(), presenter.anonymous()), presenter::signOut, this::render)); var headline = Typography.displayMedium(headlineText()); @@ -182,7 +195,7 @@ private String draftNoteFor(PollSummary draft) { private void deleteDraft(PollSummary draft) { presenter.deleteDraft(draft.id()); - Notification.show(getTranslation("polls.draft.deleted", draft.title())); + Toast.show(getTranslation("polls.draft.deleted", draft.title())); render(); } diff --git a/src/main/java/io/binarycodes/whichday/poll/ui/view/ReceiptView.java b/src/main/java/io/binarycodes/whichday/poll/ui/view/ReceiptView.java index 52dc58e..7b6b386 100644 --- a/src/main/java/io/binarycodes/whichday/poll/ui/view/ReceiptView.java +++ b/src/main/java/io/binarycodes/whichday/poll/ui/view/ReceiptView.java @@ -86,7 +86,7 @@ private Div proposals(Ballot ballot) { private Div standingsHeader(Poll poll) { var title = Typography.fieldLabel(getTranslation("receipt.standings")); - var progress = new LiveBadge(Counts.progress(this, poll.answerCount(), poll.inviteCount())); + var progress = new LiveBadge(Counts.progress(this, poll.answerCount(), poll.inviteCount(), presenter.anonymous())); var header = new Div(title, progress); header.addClassNames("row-between", "divider-bottom", "push-3xl"); return header; diff --git a/src/main/java/io/binarycodes/whichday/poll/ui/view/ResultsView.java b/src/main/java/io/binarycodes/whichday/poll/ui/view/ResultsView.java index b087f21..19138e7 100644 --- a/src/main/java/io/binarycodes/whichday/poll/ui/view/ResultsView.java +++ b/src/main/java/io/binarycodes/whichday/poll/ui/view/ResultsView.java @@ -6,11 +6,11 @@ import java.util.List; import java.util.Optional; +import com.vaadin.flow.component.Component; import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.html.Div; import com.vaadin.flow.component.html.Span; import com.vaadin.flow.component.icon.VaadinIcon; -import com.vaadin.flow.component.notification.Notification; import com.vaadin.flow.router.BeforeEnterEvent; import com.vaadin.flow.router.Route; @@ -19,10 +19,12 @@ import io.binarycodes.whichday.base.ui.DateText; import io.binarycodes.whichday.base.ui.HintBar; import io.binarycodes.whichday.base.ui.LiveBadge; +import io.binarycodes.whichday.base.ui.Toast; import io.binarycodes.whichday.base.ui.TopBar; import io.binarycodes.whichday.base.ui.Typography; import io.binarycodes.whichday.people.domain.Person; import io.binarycodes.whichday.people.ui.AvatarStack; +import io.binarycodes.whichday.people.ui.NameChips; import io.binarycodes.whichday.people.ui.WaitingChip; import io.binarycodes.whichday.poll.domain.Ballot; import io.binarycodes.whichday.poll.domain.DayTally; @@ -44,6 +46,12 @@ public class ResultsView extends PollScreen { private static final int VISIBLE_WAITING = 4; + /** + * How many answers the header names before the rest become a count. Fewer than the + * ballot's six: this row sits beside the headline figure and has less to give. + */ + private static final int ANSWERED_NAMES = 4; + public ResultsView(PollPresenter presenter) { super(presenter); } @@ -72,7 +80,7 @@ private void buildUnanswered(Poll poll) { .withLeading(homeButton()) .withTrailing(Typography.meta(getTranslation("results.sentAgo", sentAgo(poll))))); - var count = Typography.stat(Counts.progress(this, 0, poll.inviteCount())); + var count = Typography.stat(Counts.progress(this, 0, poll.inviteCount(), presenter.anonymous())); count.addClassName("stat-empty"); var caption = Typography.meta(getTranslation("results.haveVoted")); var block = new Div(count, new Div(caption)); @@ -83,13 +91,20 @@ private void buildUnanswered(Poll poll) { days.addClassName("push-xl"); body(days); - body(waitingSection(poll)); + if (!presenter.anonymous()) { + body(waitingSection(poll)); + } // A poll nobody has answered includes the organizer, and this is where they // most need the way in. body(ownAnswer(poll)); - var reminder = new HintBar(VaadinIcon.CLOCK, getTranslation("results.reminder")); - footer(reminder, shareLink(poll)); + // Both of those promise a message. Anonymous mode has nobody's address and no + // way to reach anybody, so the only thing it can offer is the link again. + if (presenter.anonymous()) { + footer(shareLink(poll)); + return; + } + footer(new HintBar(VaadinIcon.CLOCK, getTranslation("results.reminder")), shareLink(poll)); } private void buildStandings(Poll poll) { @@ -100,11 +115,15 @@ private void buildStandings(Poll poll) { DateText.closing(this, poll.closesOn()))) : new LiveBadge(getTranslation("results.live")))); - var count = Typography.stat(Counts.progress(this, poll.answerCount(), poll.inviteCount())); + var count = Typography.stat(Counts.progress(this, poll.answerCount(), poll.inviteCount(), presenter.anonymous())); var caption = Typography.meta(getTranslation("results.haveVoted")); var text = new Div(count, new Div(caption)); text.addClassName("stack-s"); - var faces = new AvatarStack().show(poll.answered(), poll.awaiting()); + // Names rather than faces where an initial identifies nobody, and there is no + // "awaiting" half to draw either: membership is having answered (REQUIREMENTS §1c). + var faces = presenter.anonymous() + ? NameChips.of(this, poll.answered(), ANSWERED_NAMES) + : new AvatarStack().show(poll.answered(), poll.awaiting()); var header = new Div(text, faces); header.addClassNames("row-between", "row-end", "push-2xl"); body(header); @@ -120,16 +139,14 @@ private void buildStandings(Poll poll) { if (poll.isOpen()) { body(ownAnswer(poll)); var others = poll.awaitingOthers(presenter.viewer()); - if (others.size() == 1) { + if (others.size() == 1 && !presenter.anonymous()) { body(nudge(others.getFirst())); } // Settling is the organizer's, so nobody else is shown the button. The // service refuses it either way; this is so an invitee is not offered a // decision that is not theirs. if (presenter.isOrganizer(poll)) { - poll.leader().ifPresent(leader -> - footer(Actions.commit(getTranslation("results.lock", DateText.compact(this, leader.day())), - ignored -> lock(leader)))); + settleSection(poll).ifPresent(this::footer); } } } @@ -137,8 +154,15 @@ private void buildStandings(Poll poll) { /** * The leading bar is the only one dark enough to carry text, so it says who is * in rather than repeating the number above it. + * + *

It says nothing in anonymous mode. "Everyone" and "everyone but Ada" are both + * claims about who was asked, and nobody was asked — the people on the poll are the + * people who answered it, so "everyone" would mean "everyone who already said yes". */ private Optional captionFor(Poll poll, DayTally tally) { + if (presenter.anonymous()) { + return Optional.empty(); + } if (tally.voteCount() == poll.inviteCount()) { return Optional.of(getTranslation("results.everyone")); } @@ -148,6 +172,10 @@ private Optional captionFor(Poll poll, DayTally tally) { : Optional.empty(); } + /** + * Only ever called in login mode, where the invitee list says who was asked. Nobody + * is waited on in anonymous mode because nobody could say who is missing. + */ private Div waitingSection(Poll poll) { var chips = new Div(); chips.addClassNames("chip-row", "push-m"); @@ -219,9 +247,13 @@ private String describeOwnAnswer(Ballot ballot) { : getTranslation("results.yourAnswer.many", ballot.chosenDays().size()); } + /** + * Login mode only, and its caller says so: a nudge is a message to an address, and + * anonymous mode knows nobody's. + */ private HintBar nudge(Person holdout) { var send = Actions.inline(getTranslation("results.nudge.action"), - ignored -> Notification.show(getTranslation("results.nudged", holdout.firstName()))); + ignored -> Toast.show(getTranslation("results.nudged", holdout.firstName()))); var bar = new HintBar(VaadinIcon.BELL, getTranslation("results.nudge", holdout.firstName())) .outlined().withAction(send); bar.addClassName("push-m"); @@ -264,9 +296,32 @@ private void accept(Ballot ballot) { render(); } - private void lock(DayTally leader) { - presenter.lock(id(), leader.day()); - goTo(LockedView.class); + /** + * The way to a decision, not the decision. Locking is final, so it happens on + * {@link SettleView} where the screen can say so and be cancelled — never under the + * organizer's thumb on a screen they came to read. + * + *

One day in front names it on the button. A shared top is a decision the group + * did not make, so this says so and hands the choice on rather than picking the + * earliest of the tied days and calling it the winner. + */ + private Optional settleSection(Poll poll) { + var single = poll.leader(); + if (single.isPresent()) { + return Optional.of(Actions.commit( + getTranslation("results.lock", DateText.compact(this, single.get().day())), + ignored -> goTo(SettleView.class))); + } + var tied = poll.tiedAtTheTop(); + if (tied.isEmpty()) { + return Optional.empty(); + } + var section = new Div( + new HintBar(VaadinIcon.SCALE, getTranslation("results.tied", tied.size())), + Actions.commit(getTranslation("results.tied.action"), + ignored -> goTo(SettleView.class))); + section.addClassName("stack-s"); + return Optional.of(section); } @Override diff --git a/src/main/java/io/binarycodes/whichday/poll/ui/view/SettleView.java b/src/main/java/io/binarycodes/whichday/poll/ui/view/SettleView.java new file mode 100644 index 0000000..9a28a59 --- /dev/null +++ b/src/main/java/io/binarycodes/whichday/poll/ui/view/SettleView.java @@ -0,0 +1,109 @@ +package io.binarycodes.whichday.poll.ui.view; + +import jakarta.annotation.security.PermitAll; + +import java.time.LocalDate; +import java.util.List; + +import com.vaadin.flow.component.icon.VaadinIcon; +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.router.BeforeEnterEvent; +import com.vaadin.flow.router.Route; + +import io.binarycodes.whichday.base.ui.Actions; +import io.binarycodes.whichday.base.ui.DateText; +import io.binarycodes.whichday.base.ui.HintBar; +import io.binarycodes.whichday.base.ui.Toast; +import io.binarycodes.whichday.base.ui.TopBar; +import io.binarycodes.whichday.base.ui.Typography; +import io.binarycodes.whichday.poll.domain.DayTally; +import io.binarycodes.whichday.poll.domain.Poll; +import io.binarycodes.whichday.poll.ui.component.DayChoice; +import io.binarycodes.whichday.poll.ui.presenter.PollPresenter; + +/** + * The last thing between a poll and a decision. Locking a day is final — no more + * answers, no different day, nothing to undo — so it gets a screen of its own to say + * so, rather than happening under the organizer's thumb on the standings. + * + *

It is also where a tie is settled. The standings can say three days are level; + * only somebody can say which one the team goes with, and this is where they say it. + */ +@PermitAll +@Route("poll/:id/settle") +public class SettleView extends PollScreen { + + private LocalDate settled; + + public SettleView(PollPresenter presenter) { + super(presenter); + } + + /** + * The organizer's, and only while the poll is still open — the same rule the + * standings apply to the button that leads here. Anybody else, and everybody once + * voting is over, is sent to the screen the poll actually has for them. + */ + @Override + protected boolean redirect(BeforeEnterEvent event, Poll poll) { + if (presenter.isOrganizer(poll) && poll.isOpen() && hasSomethingToSettle(poll)) { + return false; + } + forwardToPoll(event, ResultsView.class); + return true; + } + + /** Nobody has voted, so there is no day in front and nothing to choose between. */ + private boolean hasSomethingToSettle(Poll poll) { + return poll.leader().isPresent() || !poll.tiedAtTheTop().isEmpty(); + } + + @Override + protected void build(Poll poll) { + body(new TopBar(getTranslation("settle.title")) + .withBack(getTranslation("nav.back"), () -> goTo(ResultsView.class)) + .withTrailingSpace()); + + var tied = poll.tiedAtTheTop(); + settled = poll.leader().map(DayTally::day).orElse(null); + + var headline = Typography.displayMedium(settled == null + ? getTranslation("settle.headline.tied") + : getTranslation("settle.headline.one", DateText.full(this, settled))); + headline.addClassName("push-l"); + body(headline); + + if (settled == null) { + var lede = Typography.lede(getTranslation("settle.lede.tied", tied.size())); + lede.addClassName("push-xl"); + body(lede, dayChoice(tied)); + } + + footer(new HintBar(VaadinIcon.LOCK, getTranslation("settle.warning")).outlined(), + Actions.commit(getTranslation("settle.confirm"), ignored -> settle()), + Actions.outline(getTranslation("settle.cancel"), ignored -> goTo(ResultsView.class))); + } + + private Div dayChoice(List tied) { + var picker = new DayChoice(tied.stream().map(DayTally::day).toList()); + picker.addClassName("day-choice"); + picker.addValueChangeListener(event -> settled = event.getValue()); + var wrapper = new Div(picker); + wrapper.addClassName("push-xl"); + return wrapper; + } + + private void settle() { + if (settled == null) { + Toast.show(getTranslation("settle.needOne")); + return; + } + presenter.lock(id(), settled); + goTo(LockedView.class); + } + + @Override + public String getPageTitle() { + return getTranslation("settle.title"); + } +} diff --git a/src/main/java/io/binarycodes/whichday/poll/ui/view/ShareView.java b/src/main/java/io/binarycodes/whichday/poll/ui/view/ShareView.java index 82e91c9..c57e832 100644 --- a/src/main/java/io/binarycodes/whichday/poll/ui/view/ShareView.java +++ b/src/main/java/io/binarycodes/whichday/poll/ui/view/ShareView.java @@ -7,7 +7,6 @@ import com.vaadin.flow.component.html.Span; import com.vaadin.flow.component.icon.Icon; import com.vaadin.flow.component.icon.VaadinIcon; -import com.vaadin.flow.component.notification.Notification; import com.vaadin.flow.router.BeforeEnterEvent; import com.vaadin.flow.router.Route; @@ -17,6 +16,8 @@ import io.binarycodes.whichday.base.ui.Counts; import io.binarycodes.whichday.base.ui.DateText; import io.binarycodes.whichday.base.ui.HintBar; +import io.binarycodes.whichday.base.ui.Home; +import io.binarycodes.whichday.base.ui.Toast; import io.binarycodes.whichday.base.ui.TopBar; import io.binarycodes.whichday.base.ui.Typography; import io.binarycodes.whichday.people.ui.PersonRow; @@ -24,13 +25,17 @@ import io.binarycodes.whichday.poll.domain.PollState; import io.binarycodes.whichday.poll.ui.component.MonthCalendar; import io.binarycodes.whichday.poll.ui.presenter.PollPresenter; -import io.binarycodes.whichday.poll.ui.share.CalendarInvite; import io.binarycodes.whichday.poll.ui.share.MailLink; import io.binarycodes.whichday.poll.ui.share.VotingLink; /** * One link, and the list of people who are owed it. Sending the invites is what * opens the poll, so this is the last screen before the counts start moving. + * + *

Anonymous mode has no list — the link is the invitation, and who follows it is + * not known until they answer — so what stands in its place is the admin code. It is + * shown here and nowhere else, because this is the one moment the person who called + * the poll is certainly looking. */ @PermitAll @Route("poll/:id/share") @@ -62,18 +67,22 @@ protected boolean redirect(BeforeEnterEvent event, Poll poll) { protected void build(Poll poll) { body(new TopBar(getTranslation("share.title")) .withBack(getTranslation("nav.back"), () -> goTo(CandidateDaysView.class)) - .withHome(getTranslation("nav.home"), this::goHome)); + .withHome(Home.labelFor(this, presenter), this::goHome)); - var headline = Typography.displayMedium(getTranslation("share.headline", - Counts.days(this, poll.candidateDays().size()), poll.inviteCount())); + var headline = Typography.displayMedium(presenter.anonymous() + ? getTranslation("share.headline.anonymous", Counts.days(this, poll.candidateDays().size())) + : getTranslation("share.headline", + Counts.days(this, poll.candidateDays().size()), poll.inviteCount())); headline.addClassName("push-2xl"); body(headline); - body(linkCard(poll), shareActions(poll), inviteList(poll)); + body(linkCard(poll)); + if (!presenter.anonymous()) { + body(messageAction(poll)); + } + body(presenter.anonymous() ? adminCodeCard() : inviteList(poll)); - footer(closingSection(poll), Actions.primary(poll.inviteCount() == 1 - ? getTranslation("share.send.one") - : getTranslation("share.send.many", poll.inviteCount()), ignored -> send())); + footer(closingSection(poll), Actions.primary(sendLabel(poll), ignored -> send())); } /** @@ -118,6 +127,36 @@ private Div closingSection(Poll poll) { return section; } + private String sendLabel(Poll poll) { + if (presenter.anonymous()) { + return getTranslation("share.open"); + } + return poll.inviteCount() == 1 + ? getTranslation("share.send.one") + : getTranslation("share.send.many", poll.inviteCount()); + } + + /** + * The six digits, and the warning that goes with them. There is no second copy + * anywhere — no account to attach the poll to and no list to find it in — so a + * code nobody wrote down is a poll nobody can change again. + */ + private Div adminCodeCard() { + var label = Typography.meta(getTranslation("share.code")); + var digits = new Span(presenter.adminCode(id()).orElse("")); + digits.addClassName("admin-code"); + var text = new Div(label, digits); + text.addClassName("link-text"); + + var card = new Div(text); + card.addClassNames("link-card", "push-m"); + + var warning = new HintBar(VaadinIcon.KEY, getTranslation("share.code.keep")); + var section = new Div(card, warning); + section.addClassNames("stack-s", "push-2xl"); + return section; + } + private Div linkCard(Poll poll) { var label = Typography.meta(getTranslation("share.link")); var url = new Span(VotingLink.display(poll.id())); @@ -134,17 +173,20 @@ private Div linkCard(Poll poll) { return card; } - private Div shareActions(Poll poll) { + /** + * Login mode's alone: the copy tells the reader which address to sign in with, and + * anonymous mode has no address and no signing in. + * + *

There is no calendar file here. These are days on the table, not a date — an + * .ics of five maybes is five entries the reader has to go back and delete, and the + * settled day gets its own download on the locked screen where it means something. + */ + private Div messageAction(Poll poll) { var message = new Anchor(MailLink.invitation(this, poll), ""); message.add(new Icon(VaadinIcon.PAPERPLANE), new Span(getTranslation("share.message"))); message.addClassNames("action", "action-quiet", "action-anchor"); - var calendar = new Anchor(CalendarInvite.forCandidateDays(poll), ""); - calendar.getElement().setAttribute("download", true); - calendar.add(new Icon(VaadinIcon.CALENDAR), new Span(getTranslation("share.calendar"))); - calendar.addClassNames("action", "action-quiet", "action-anchor"); - - var row = new Div(message, calendar); + var row = new Div(message); row.addClassNames("action-row", "push-m"); return row; } @@ -176,7 +218,7 @@ private Div inviteList(Poll poll) { private void send() { presenter.send(id()); - Notification.show(getTranslation("share.sentAll")); + Toast.show(getTranslation(presenter.anonymous() ? "share.opened" : "share.sentAll")); goTo(ResultsView.class); } diff --git a/src/main/resources/META-INF/resources/styles/colors.css b/src/main/resources/META-INF/resources/styles/colors.css index a0b4a89..3facbf1 100644 --- a/src/main/resources/META-INF/resources/styles/colors.css +++ b/src/main/resources/META-INF/resources/styles/colors.css @@ -27,7 +27,7 @@ html { --color-accent-veil: color-mix(in oklab, var(--aura-accent-color) 10%, transparent); --color-accent-hairline: color-mix(in oklab, var(--aura-accent-color) 35%, transparent); - /* The button the design paints black — "Send to the team", "Lock in Friday 18". + /* The button the design paints black — "Send 3 invites", "Lock in Friday 18". Solid ink in light; dark mode has no darker paint, so it borrows the ink outline and reads as the inverse of the surface instead. */ --color-commit: light-dark(rgb(11, 11, 11), rgb(240, 241, 244)); diff --git a/src/main/resources/META-INF/resources/styles/day-ballot.css b/src/main/resources/META-INF/resources/styles/day-ballot.css index 29edad2..4f896db 100644 --- a/src/main/resources/META-INF/resources/styles/day-ballot.css +++ b/src/main/resources/META-INF/resources/styles/day-ballot.css @@ -76,6 +76,19 @@ color: var(--color-ink-tertiary); } +/* Names instead of faces on a day row; .name-chips in screen.css carries the layout. */ +.day-row-voters { + margin-top: var(--vaadin-gap-s); +} + +/* A chosen row is filled with the accent, so the chip borrows the row's own text + colour rather than the page's — which would leave it unreadable on the fill. */ +.day-row[aria-pressed="true"] .day-row-voters .chip { + border-color: currentcolor; + color: inherit; + opacity: 0.85; +} + /* The check is empty until the day is chosen, so it reads as a target rather than as a decoration. */ .day-row-check { diff --git a/src/main/resources/META-INF/resources/styles/poster.css b/src/main/resources/META-INF/resources/styles/poster.css index ec9bdcc..6956ca0 100644 --- a/src/main/resources/META-INF/resources/styles/poster.css +++ b/src/main/resources/META-INF/resources/styles/poster.css @@ -136,6 +136,15 @@ opacity: 0.8; } +/* The people coming, named rather than shown. The accent fills this whole screen, so + the chips borrow the badge's treatment — a wash of the page's own text colour — where + an outline meant for a pale background would all but disappear. */ +.locked-names .chip { + border-color: transparent; + background: color-mix(in oklab, var(--color-accent-contrast) 18%, transparent); + color: inherit; +} + .locked-screen .action-primary { --vaadin-button-background: var(--color-accent-contrast); --vaadin-button-text-color: var(--color-accent); diff --git a/src/main/resources/META-INF/resources/styles/screen.css b/src/main/resources/META-INF/resources/styles/screen.css index c283ab4..7cb4123 100644 --- a/src/main/resources/META-INF/resources/styles/screen.css +++ b/src/main/resources/META-INF/resources/styles/screen.css @@ -246,6 +246,23 @@ font-weight: var(--aura-font-weight-regular); } +/* People named rather than shown, where an initial identifies nobody. Wrapping rather + than truncating: the rows that carry these are narrow, and a second line costs less + than a name cut in half. */ +.name-chips { + display: flex; + flex-wrap: wrap; + gap: var(--vaadin-gap-xs); +} + +.name-chips .chip { + height: 1.375rem; + max-width: 100%; + overflow: hidden; + font-size: 0.6875rem; + text-overflow: ellipsis; +} + .chip-row { display: flex; flex-wrap: wrap; diff --git a/src/main/resources/META-INF/resources/styles/share.css b/src/main/resources/META-INF/resources/styles/share.css index 5c11cb7..bb45941 100644 --- a/src/main/resources/META-INF/resources/styles/share.css +++ b/src/main/resources/META-INF/resources/styles/share.css @@ -85,3 +85,17 @@ .team-picker .person-row { --vaadin-avatar-size: 2rem; } + +/* Anonymous mode's admin code, which sits where the voting link sits and is read + the same way — off a screen, out loud, into somebody's notes. Tabular figures and + the wide tracking are what make six digits legible at a glance; the monospace + stack is the fallback for a face without them. */ +.admin-code { + font-family: var(--vaadin-font-family-monospace, ui-monospace, monospace); + font-feature-settings: "tnum"; + font-size: 1.75rem; + font-weight: var(--aura-font-weight-medium); + line-height: 1; + letter-spacing: 0.18em; + color: var(--color-ink); +} diff --git a/src/main/resources/META-INF/resources/styles/shell.css b/src/main/resources/META-INF/resources/styles/shell.css index 64ea6ad..6ab07af 100644 --- a/src/main/resources/META-INF/resources/styles/shell.css +++ b/src/main/resources/META-INF/resources/styles/shell.css @@ -47,3 +47,27 @@ html { margin-top: auto; padding: var(--vaadin-padding-xl) 0; } + +/* What the application says back. Pinned to the top over the header band rather than + bottom-left, where Vaadin puts it and where every screen's primary action lives — a + message about a button should not be sitting on top of it. + + The card paints itself in its own shadow root, so the look belongs to ::part(overlay); + styling the host as well is what put a second card around the first. The host only + caps the width, so the toast lines up with the app column instead of floating across + a desktop. */ +vaadin-notification-card.toast { + width: calc(100vw - 2 * var(--screen-inset)); + max-width: calc(var(--screen-max-width) - var(--screen-inset)); + margin: 0; +} + +vaadin-notification-card.toast::part(overlay) { + padding: var(--vaadin-padding-m) var(--vaadin-padding-l); + border-radius: var(--vaadin-radius-l); + background: var(--color-surface-sunken); + box-shadow: var(--aura-shadow-s); + color: var(--color-ink); + font-size: 0.875rem; + line-height: 1.3; +} diff --git a/src/main/resources/META-INF/resources/styles/tally.css b/src/main/resources/META-INF/resources/styles/tally.css index fef6ab6..7f9e7d2 100644 --- a/src/main/resources/META-INF/resources/styles/tally.css +++ b/src/main/resources/META-INF/resources/styles/tally.css @@ -78,3 +78,4 @@ .tally-compact .tally-count { font-size: 0.875rem; } + diff --git a/src/main/resources/application-anonymous.properties b/src/main/resources/application-anonymous.properties new file mode 100644 index 0000000..ec13e34 --- /dev/null +++ b/src/main/resources/application-anonymous.properties @@ -0,0 +1,11 @@ +# Anonymous mode configures nothing. That is the point of it: no provider, no client, +# no secret — a session says who it is by typing a name, the link is what lets anybody +# see a poll, and a six-digit code is what lets anybody change one. +# +# The absence of a spring.security.oauth2.client.* key here is load-bearing, not an +# omission: see application.properties for why the issuer cannot merely be blanked. + +# What the application branches on. The profile decides which file is read; the +# property is what the code asks, so a test is free to compose @ActiveProfiles +# without @Profile on a bean deciding behind its back. +whichday.access.mode=anonymous diff --git a/src/main/resources/application-login.properties b/src/main/resources/application-login.properties new file mode 100644 index 0000000..7e4a1ca --- /dev/null +++ b/src/main/resources/application-login.properties @@ -0,0 +1,24 @@ +# What the login mode needs, and nothing else needs. +# +# Signing in is the only way in, and OIDC is the only way to sign in. The +# registration is called "oidc" rather than after the provider behind it: Spring +# builds /oauth2/authorization/oidc and the /login/oauth2/code/oidc callback from +# this id, so a vendor named here would end up in the application's own URLs and in +# every deployment's configuration (CODING_CONVENTIONS.md §10b). Which provider it +# is belongs to the issuer below and to nothing else. +# +# The three defaults are the development stack in environment/dev — the Keycloak +# `./run.sh env up` brings up, its realm, and a client secret that is in version +# control on purpose. They are a laptop's, and they are here so that a fresh checkout +# is `env up` and `run` with nothing to configure in between. A deployment sets all +# three; the README says so and neither sample leaves any of them out. +spring.security.oauth2.client.provider.oidc.issuer-uri=${WHICHDAY_OIDC_ISSUER_URI:http://localhost:8082/realms/whichday} +spring.security.oauth2.client.registration.oidc.provider=oidc +spring.security.oauth2.client.registration.oidc.client-id=${WHICHDAY_OIDC_CLIENT_ID:whichday} +spring.security.oauth2.client.registration.oidc.client-secret=${WHICHDAY_OIDC_CLIENT_SECRET:whichday-dev-secret} +spring.security.oauth2.client.registration.oidc.scope=openid,email,profile + +# What the application branches on. The profile decides which file is read; the +# property is what the code asks, so a test is free to compose @ActiveProfiles +# without @Profile on a bean deciding behind its back. +whichday.access.mode=login diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 6130fab..2f90544 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -12,28 +12,24 @@ server.port=${PORT:8080} # because those headers are client-supplied and spoofable with nothing in front. server.forward-headers-strategy=${FORWARD_HEADERS_STRATEGY:none} -# Signing in is the only way in, and OIDC is the only way to sign in. The -# registration is called "oidc" rather than after the provider behind it: Spring -# builds /oauth2/authorization/oidc and the /login/oauth2/code/oidc callback from -# this id, so a vendor named here would end up in the application's own URLs and in -# every deployment's configuration (CODING_CONVENTIONS.md §10b). Which provider it -# is belongs to the issuer below and to nothing else. +# Whichday runs one of two ways, chosen once at deploy time. The variable names the +# profile outright, so the file that carries a mode's configuration is +# application-.properties and there is nothing mapping one to the other. # -# The three defaults are the development stack in environment/dev — the Keycloak -# `./run.sh env up` brings up, its realm, and a client secret that is in version -# control on purpose. They are a laptop's, and they are here so that a fresh checkout -# is `env up` and `run` with nothing to configure in between. A deployment sets all -# three; the README says so and neither sample leaves any of them out. -spring.security.oauth2.client.provider.oidc.issuer-uri=${WHICHDAY_OIDC_ISSUER_URI:http://localhost:8082/realms/whichday} -spring.security.oauth2.client.registration.oidc.provider=oidc -spring.security.oauth2.client.registration.oidc.client-id=${WHICHDAY_OIDC_CLIENT_ID:whichday} -spring.security.oauth2.client.registration.oidc.client-secret=${WHICHDAY_OIDC_CLIENT_SECRET:whichday-dev-secret} -spring.security.oauth2.client.registration.oidc.scope=openid,email,profile +# The OIDC block lives in application-login.properties rather than here, and it has +# to genuinely live there: Boot resolves an issuer by fetching its discovery document +# at startup, so an anonymous deployment that inherited issuer-uri would hang on a +# provider it has no reason to reach. Blanking the key is not the same thing — that +# fails as "issuer cannot be empty" (CODING_CONVENTIONS.md §11). +# +# Anonymous is the default because it is the mode that needs nothing configured: the +# image runs, the link works, and a deployment that wants accounts opts into them. +spring.profiles.active=${WHICHDAY_ACCESS_MODE:anonymous} # The whole store is one H2 file the process opens — one container, and nothing else -# to bring up. MODE=PostgreSQL is what keeps the migrations written in the SQL a real -# PostgreSQL would run, which is the only part of CODING_CONVENTIONS.md §10 this -# deviates from: the engine, not the shape. +# to bring up (CODING_CONVENTIONS.md §10, docs/REQUIREMENTS.md §9). MODE=PostgreSQL is +# what keeps the migrations written in SQL a real PostgreSQL would run, so the engine +# is the only thing an instance that outgrows this has to change. # # WHICHDAY_DATA_DIR names the directory rather than the whole JDBC URL, so moving the # file somewhere else cannot drop MODE=PostgreSQL on the way past. The default is the diff --git a/src/main/resources/db/migration/V2__anonymous_admin_code.sql b/src/main/resources/db/migration/V2__anonymous_admin_code.sql new file mode 100644 index 0000000..f8d31c5 --- /dev/null +++ b/src/main/resources/db/migration/V2__anonymous_admin_code.sql @@ -0,0 +1,11 @@ +-- The six digits that let somebody change a poll they did not create. +-- +-- Only anonymous deployments write one (WHICHDAY_ACCESS_MODE=anonymous): there is no +-- provider there, so a session that closed its tab has no way back to being the +-- organizer, and this is it. Login-mode polls leave it null — the address on the poll +-- is the whole of the answer there. +-- +-- Nullable and not unique on purpose. The code is only ever compared with the poll +-- whose link the caller already holds, never looked up across the table, so two polls +-- sharing six digits means nothing. +alter table poll add column admin_code varchar(6); diff --git a/src/main/resources/vaadin-i18n/translations.properties b/src/main/resources/vaadin-i18n/translations.properties index 87f5ed9..8286853 100644 --- a/src/main/resources/vaadin-i18n/translations.properties +++ b/src/main/resources/vaadin-i18n/translations.properties @@ -5,14 +5,32 @@ app.name=Whichday count.days.one=One day count.days.many={0} days count.progress={0} of {1} +# Anonymous mode: anybody with the link may answer, so there is no denominator. +count.answered={0} count.overflow=+{0} count.more=+{0} more undecided=— nav.back=Back nav.home=Your polls +# Anonymous mode has no list to go back to, so home is where a poll starts. +nav.home.anonymous=New poll nav.menu=More +# Anonymous mode's front door. It asks for a name because a name is the whole of +# identity there; the address behind it is minted and never shown. +identity.title=Your name +identity.headline=What should we call you? +identity.lede=Your name is what everyone else on the poll sees. Nothing else is asked, and nothing is kept after you close the tab. +identity.name=Your name +identity.name.placeholder=Ada +identity.name.required=Type a name so the others know who answered. +identity.next=Continue +identity.code=Admin code +identity.code.placeholder=483920 +identity.code.hint=Only if you called a poll and want to change it. Leave it empty to answer one. +identity.footnote=No account, no email, no sign-up. + time.justNow=just now time.minutes.one=1 min ago time.minutes.many={0} min ago @@ -30,18 +48,22 @@ create.eventName.required=Give the poll a name so the team knows what they are a create.deciders=Who decides with you create.next=Choose the days create.footnote=Everyone signs in with the address you invite. +create.footnote.anonymous=Anyone with the link can answer. No sign-up. days.title=Choose the days days.clear=Clear days.onTheTable={0} on the table days.none=No days chosen yet -days.send=Send to the team -days.needOne=Pick at least one day before sending it out. +# Just the way on: this screen saves the days and opens the share screen. Nothing +# leaves the application until the button there. +days.next=Next +days.needOne=Pick at least one day before you go on. days.previousMonth=Previous month days.nextMonth=Next month share.title=Share share.headline={0} out, {1} to answer. +share.headline.anonymous={0} out. Send the link. share.link=Voting link share.share=Share link share.sheet.title=When can you do {0}? @@ -49,7 +71,6 @@ share.sheet.text=A few days are on the table for {0}. Sign in with this address share.copied=Voting link copied share.copyFailed=The link could not be copied share.message=Message -share.calendar=Calendar share.invited=Invited share.notSent=Not sent share.sent=Sent @@ -58,6 +79,14 @@ share.send.one=Send 1 invite share.send.many=Send {0} invites share.sentAll=Invites sent +# Anonymous mode. There is no invitee list to send anything to, so opening the poll +# for answers is the same act under a name that fits it, and the code is the only +# copy anybody will ever get. +share.open=Open for answers +share.opened=The poll is open +share.code=Admin code +share.code.keep=Write it down. It is the only way back in to change this poll. + ballot.title=Your answer ballot.invitedBy={0} invited you · {1} ballot.headline=Tap every day that works. @@ -103,6 +132,10 @@ results.nudge={0} hasn''t voted yet. Send a nudge? results.nudge.action=Nudge results.nudged=Nudged {0} results.lock=Lock in {0} +# A shared top is not a result. The organizer picks, rather than the application +# handing the earliest of the tied days the win. +results.tied={0} days are tied. Pick the one to lock in. +results.tied.action=Choose a day results.sentAgo=Sent {0} results.waitingOn=Waiting on results.reminder=A reminder goes out tomorrow morning. @@ -114,6 +147,7 @@ results.acceptProposal=Add it locked.title=Date locked locked.badge=Date locked locked.summary={0} · {1} of {2} said yes +locked.summary.anonymous={0} · {1} said yes locked.addToCalendar=Add to calendar locked.tellTeam=Tell the team @@ -132,6 +166,7 @@ notFound.title=No such poll notFound.headline=That poll isn't here. notFound.lede=The link may have expired, or the poll was never sent. notFound.action=Back to your polls +notFound.action.anonymous=Start a poll mail.invite.subject=When can you do {0}? mail.invite.body=A few days are on the table for {0}. Sign in with this address and tap every one that works: {1} @@ -182,6 +217,18 @@ polls.closed=Voting closed · {0} polls.closedChip=Closed receipt.closed=Voting closed {0} results.closed=Closed {0} + +# Locking is final — no more answers, no different day, nothing to undo — so it gets a +# screen of its own to say so and be cancelled from. It is also where a tie is settled: +# the standings can say three days are level, only a person can say which one wins. +settle.title=Lock the day +settle.headline.one=Lock in {0}? +settle.headline.tied=Which day? +settle.lede.tied={0} days are tied. Pick the one the team goes with. +settle.warning=This settles the poll. No more answers, and the day cannot be changed afterwards. +settle.confirm=Lock it in +settle.cancel=Cancel +settle.needOne=Pick a day to lock in. polls.drafts=Drafts polls.draft.days={0} on the table polls.draft.noDays=No days chosen yet @@ -192,3 +239,7 @@ polls.draft.confirm=Delete this draft? polls.draft.deleted=Deleted "{0}" nav.signedInAs=Signed in as {0} nav.signOut=Sign out +# The same two lines for a session that only ever typed a name. Signing out of a +# provider and dropping a name are not the same act and do not read the same. +nav.youAre=You are {0} +nav.startOver=Start over diff --git a/src/test/java/io/binarycodes/whichday/AnonymousWhichdayTest.java b/src/test/java/io/binarycodes/whichday/AnonymousWhichdayTest.java new file mode 100644 index 0000000..a7d5a29 --- /dev/null +++ b/src/test/java/io/binarycodes/whichday/AnonymousWhichdayTest.java @@ -0,0 +1,23 @@ +package io.binarycodes.whichday; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +/** + * What a Spring test of anonymous mode declares — {@link WhichdayTest} with the other + * mode's profile in front of it. + * + *

A second context, which is deliberate: the two modes differ in which beans exist + * at all, so there is nothing to switch at runtime. Both are cached for the run. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +@SpringBootTest(classes = {Application.class, TestSupportConfiguration.class}) +@ActiveProfiles({"anonymous", "test"}) +public @interface AnonymousWhichdayTest { +} diff --git a/src/test/java/io/binarycodes/whichday/Sample.java b/src/test/java/io/binarycodes/whichday/Sample.java index 35cedb4..bba8d93 100644 --- a/src/test/java/io/binarycodes/whichday/Sample.java +++ b/src/test/java/io/binarycodes/whichday/Sample.java @@ -10,6 +10,7 @@ import io.binarycodes.whichday.people.domain.Person; import io.binarycodes.whichday.people.service.AccountDirectory; +import io.binarycodes.whichday.poll.domain.Caller; import io.binarycodes.whichday.poll.service.PollService; /** @@ -59,8 +60,8 @@ public static UUID offsite(PollService service, Clock clock) { monday.plusDays(8), monday.plusDays(9)); var id = service.create("Q3 team offsite", ADA, TEAM); - service.replaceCandidateDays(id, ADA, days); - service.send(id, ADA); + service.replaceCandidateDays(id, Caller.of(ADA), days); + service.send(id, Caller.of(ADA)); service.castVote(id, ADA, Set.of(days.get(2), days.get(3))); service.castVote(id, MIRO, Set.of(days.get(0), days.get(2))); service.castVote(id, SARA, Set.of(days.get(0), days.get(2), days.get(3))); @@ -74,9 +75,9 @@ public static UUID offsite(PollService service, Clock clock) { public static UUID unanswered(PollService service, Clock clock) { var monday = mondayAfterNext(LocalDate.now(clock)).plusWeeks(3); var id = service.create("Design review week", MIRO, TEAM); - service.replaceCandidateDays(id, MIRO, + service.replaceCandidateDays(id, Caller.of(MIRO), List.of(monday, monday.plusDays(1), monday.plusDays(2), monday.plusDays(4))); - service.send(id, MIRO); + service.send(id, Caller.of(MIRO)); return id; } @@ -84,10 +85,10 @@ public static UUID unanswered(PollService service, Clock clock) { public static UUID settled(PollService service, Clock clock) { var day = mondayAfterNext(LocalDate.now(clock)).plusWeeks(1); var id = service.create("Sprint 14 retro", ADA, TEAM); - service.replaceCandidateDays(id, ADA, List.of(day)); - service.send(id, ADA); + service.replaceCandidateDays(id, Caller.of(ADA), List.of(day)); + service.send(id, Caller.of(ADA)); TEAM.forEach(person -> service.castVote(id, person, Set.of(day))); - service.lock(id, ADA, day); + service.lock(id, Caller.of(ADA), day); return id; } diff --git a/src/test/java/io/binarycodes/whichday/TestDatabase.java b/src/test/java/io/binarycodes/whichday/TestDatabase.java index 3b96869..0646a50 100644 --- a/src/test/java/io/binarycodes/whichday/TestDatabase.java +++ b/src/test/java/io/binarycodes/whichday/TestDatabase.java @@ -33,6 +33,15 @@ public void empty() { } } + /** + * How many rows a table holds. For the assertions that are about a table nothing + * above the service package can see — the {@code account} repository is + * package-private, and rightly so. + */ + public int rowsIn(String table) { + return jdbc.queryForObject("SELECT count(*) FROM " + table, Integer.class); + } + private List tableNames() { return jdbc.queryForList(""" SELECT table_name FROM information_schema.tables diff --git a/src/test/java/io/binarycodes/whichday/WhichdayTest.java b/src/test/java/io/binarycodes/whichday/WhichdayTest.java index 5a26bc3..2111388 100644 --- a/src/test/java/io/binarycodes/whichday/WhichdayTest.java +++ b/src/test/java/io/binarycodes/whichday/WhichdayTest.java @@ -9,13 +9,19 @@ import org.springframework.test.context.ActiveProfiles; /** - * What every Spring test here declares. One annotation rather than three on each + * What a Spring test of login mode declares. One annotation rather than three on each * class, because the context cache is keyed on the configuration: a class that drifts * by one annotation quietly bootstraps a second context and pays for it. + * + *

Two profiles, in this order. {@code login} brings the mode's own file — the OIDC + * registration among it — and {@code test} comes after so that its overrides win; the + * order is the whole reason the offline provider works. The anonymous counterpart is + * {@link AnonymousWhichdayTest}, and the two are separate contexts on purpose: they + * differ in which beans exist, which is not something a test can switch at runtime. */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) @SpringBootTest(classes = {Application.class, TestSupportConfiguration.class}) -@ActiveProfiles("test") +@ActiveProfiles({"login", "test"}) public @interface WhichdayTest { } diff --git a/src/test/java/io/binarycodes/whichday/base/security/AnonymousSecurityConfigTest.java b/src/test/java/io/binarycodes/whichday/base/security/AnonymousSecurityConfigTest.java new file mode 100644 index 0000000..6de7dd2 --- /dev/null +++ b/src/test/java/io/binarycodes/whichday/base/security/AnonymousSecurityConfigTest.java @@ -0,0 +1,96 @@ +package io.binarycodes.whichday.base.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.ActiveProfiles; + +import io.binarycodes.whichday.Application; + +/** + * The same questions {@link LoginSecurityConfigTest} asks, put to the chain that has no + * provider behind it. There is nowhere to send anybody, so nothing redirects. + * + *

The stylesheet cases look redundant next to the login ones and are not: anonymous + * mode permits everything through a different configurer, and a chain that served the + * routes but not the partials would render the application unstyled just as surely. + */ +@SpringBootTest(classes = Application.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles({"anonymous", "test"}) +@DisplayName("What the anonymous filter chain serves") +class AnonymousSecurityConfigTest { + + private static final HttpClient CLIENT = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NEVER) + .build(); + + @LocalServerPort + private int port; + + @Test + @DisplayName("lets a visitor with no session straight in") + void nobodyIsTurnedAway() { + assertThat(get("/").statusCode()).isEqualTo(200); + } + + /** + * The link a poll is shared by. It is the case worth stating on its own: a mode + * whose whole bargain is that a link works has to serve the link's own path to + * somebody who has never been here. + */ + @Test + @DisplayName("serves a voting link to somebody who has never been here") + void theSharedLinkOpens() { + assertThat(get("/vote/" + UUID.randomUUID()).statusCode()).isEqualTo(200); + } + + /** + * The entry point and every partial it imports. A redirect carries no content type, + * which a browser rejects as "not a supported stylesheet MIME type" — so a + * stylesheet behind authentication is an unstyled application, not a login prompt. + */ + @Test + @DisplayName("serves the stylesheet and every partial it imports, as CSS") + void stylesheetsAreServed() { + for (var path : new String[] {"/styles.css", "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/styles/colors.css", "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/styles/screen.css", + "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/styles/shell.css", "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/styles/calendar.css", "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/styles/day-ballot.css", + "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/styles/tally.css", "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/styles/poster.css", "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/styles/poll-list.css", + "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/styles/people.css", "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/styles/share.css", "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/styles/ballot.css", + "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/styles/invitees.css"}) { + var response = get(path); + + assertThat(response.statusCode()).as("%s status", path).isEqualTo(200); + assertThat(response.headers().firstValue("content-type")).as("%s content type", path) + .get().asString().startsWith("text/css"); + } + } + + @Test + @DisplayName("serves the theme and the font it asks for") + void themeIsServed() { + assertThat(get("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/aura/aura.css").statusCode()).isEqualTo(200); + assertThat(get("/aura/fonts/InstrumentSans/InstrumentSans.woff2").statusCode()).isEqualTo(200); + } + + private HttpResponse get(String path) { + var request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + path)).build(); + try { + return CLIENT.send(request, HttpResponse.BodyHandlers.discarding()); + } catch (IOException failure) { + throw new AssertionError("Could not GET " + path, failure); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new AssertionError("Interrupted while getting " + path, interrupted); + } + } +} diff --git a/src/test/java/io/binarycodes/whichday/base/security/SecurityConfigTest.java b/src/test/java/io/binarycodes/whichday/base/security/LoginSecurityConfigTest.java similarity index 91% rename from src/test/java/io/binarycodes/whichday/base/security/SecurityConfigTest.java rename to src/test/java/io/binarycodes/whichday/base/security/LoginSecurityConfigTest.java index 1d32afa..e208e50 100644 --- a/src/test/java/io/binarycodes/whichday/base/security/SecurityConfigTest.java +++ b/src/test/java/io/binarycodes/whichday/base/security/LoginSecurityConfigTest.java @@ -23,11 +23,15 @@ * entirely, so a route that redirects to the provider and a stylesheet that does the * same both look fine from there. The JDK's own client rather than a Spring one, * because it is here to follow no redirects and read two headers. + * + *

This is login mode. {@link AnonymousSecurityConfigTest} is the same questions put + * to the other chain, and the pair is what stops one mode's rule from silently becoming + * the other's. */ @SpringBootTest(classes = Application.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -@ActiveProfiles("test") -@DisplayName("What the filter chain serves") -class SecurityConfigTest { +@ActiveProfiles({"login", "test"}) +@DisplayName("What the login filter chain serves") +class LoginSecurityConfigTest { private static final HttpClient CLIENT = HttpClient.newBuilder() .followRedirects(HttpClient.Redirect.NEVER) diff --git a/src/test/java/io/binarycodes/whichday/people/ui/presenter/AnonymousViewerSessionTest.java b/src/test/java/io/binarycodes/whichday/people/ui/presenter/AnonymousViewerSessionTest.java new file mode 100644 index 0000000..371dd4a --- /dev/null +++ b/src/test/java/io/binarycodes/whichday/people/ui/presenter/AnonymousViewerSessionTest.java @@ -0,0 +1,126 @@ +package io.binarycodes.whichday.people.ui.presenter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.ZoneOffset; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import io.binarycodes.whichday.AnonymousWhichdayTest; +import io.binarycodes.whichday.TestClock; +import io.binarycodes.whichday.TestDatabase; +import io.binarycodes.whichday.people.service.AccountDirectory; + +/** + * Identity when there is no provider. + * + *

The session is built by hand rather than injected: it is session-scoped, so there + * is no Vaadin session to resolve one from here, and a test that wants two visitors + * wants two of them. Its own clock, too, so that moving time on cannot disturb the + * shared one. + */ +@AnonymousWhichdayTest +@DisplayName("A session that only ever typed a name") +class AnonymousViewerSessionTest { + + @Autowired + private AccountDirectory directory; + + @Autowired + private TestDatabase database; + + private TestClock clock; + private AnonymousViewerSession session; + + @BeforeEach + void setUp() { + database.empty(); + clock = new TestClock(TestClock.START, ZoneOffset.UTC); + session = new AnonymousViewerSession(clock, directory); + } + + @Test + @DisplayName("has nobody in it until somebody says who they are") + void nobodyYet() { + assertThat(session.isIdentified()).isFalse(); + assertThatThrownBy(session::viewer) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Nobody has said who they are"); + } + + @Test + @DisplayName("mints an address nobody could have typed, stamped with the moment") + void mintsAnAddress() { + session.identify("Ada", ""); + + assertThat(session.viewer().name()).isEqualTo("Ada"); + assertThat(session.viewer().displayName()).isEqualTo("Ada"); + assertThat(session.viewer().email()) + .endsWith("-20260820t090000@whichday.anonymous") + .matches("[0-9a-f-]{36}-[0-9t]+@whichday\\.anonymous"); + } + + /** + * The address is what every poll, ballot and invitee row this session writes will be + * keyed on. A second one would make the same person a stranger to their own answers, + * so correcting a name must not mint one — not even after the clock has moved. + */ + @Test + @DisplayName("keeps the address it minted when the name is corrected") + void keepsTheAddress() { + session.identify("Ada", ""); + var minted = session.viewer().email(); + + clock.advanceDays(1); + session.identify("Ada Lindqvist", ""); + + assertThat(session.viewer().email()).isEqualTo(minted); + assertThat(session.viewer().name()).isEqualTo("Ada Lindqvist"); + } + + /** + * Two people who typed the same name at the same moment are still two people. The + * timestamp is for reading, not for telling anybody apart — the UUID is. + */ + @Test + @DisplayName("gives two sessions two addresses, however close together they start") + void twoSessionsAreTwoPeople() { + session.identify("Ada", ""); + var other = new AnonymousViewerSession(clock, directory); + other.identify("Ada", ""); + + assertThat(other.viewer().email()).isNotEqualTo(session.viewer().email()); + } + + /** + * A poll stores addresses and reads names from the account table, so a name that + * was not written there is a name nobody else on the poll ever sees. + */ + @Test + @DisplayName("puts the name where every other screen reads names from") + void theNameIsReadableByEverybodyElse() { + session.identify("Ada", ""); + + assertThat(directory.forInvite(session.viewer().email()).name()).isEqualTo("Ada"); + assertThat(database.rowsIn("account")).isEqualTo(1); + + session.identify("Ada Lindqvist", ""); + + assertThat(directory.forInvite(session.viewer().email()).name()).isEqualTo("Ada Lindqvist"); + assertThat(database.rowsIn("account")).isEqualTo(1); + } + + @Test + @DisplayName("carries the admin code it was given, and nothing when it was given none") + void carriesTheAdminCode() { + session.identify("Ada", " "); + assertThat(session.adminCode()).isEmpty(); + + session.identify("Ada", " 483920 "); + assertThat(session.adminCode()).contains("483920"); + } +} diff --git a/src/test/java/io/binarycodes/whichday/poll/service/AnonymousPollServiceTest.java b/src/test/java/io/binarycodes/whichday/poll/service/AnonymousPollServiceTest.java new file mode 100644 index 0000000..bf6ff4a --- /dev/null +++ b/src/test/java/io/binarycodes/whichday/poll/service/AnonymousPollServiceTest.java @@ -0,0 +1,168 @@ +package io.binarycodes.whichday.poll.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import io.binarycodes.whichday.AnonymousWhichdayTest; +import io.binarycodes.whichday.Sample; +import io.binarycodes.whichday.TestClock; +import io.binarycodes.whichday.TestDatabase; +import io.binarycodes.whichday.people.domain.Person; +import io.binarycodes.whichday.poll.domain.Caller; +import io.binarycodes.whichday.poll.domain.PollState; + +/** + * The three places the service answers differently when there is no provider behind + * it: who may read a poll, who may answer one, and who may change one. + * + *

The people are {@link Sample}'s, addresses and all. What they are here is + * strangers to each other — no directory, no invitations — so the fixture is + * deliberately thin: a poll with an organizer and nobody else on it, which is what + * this mode actually produces. + */ +@AnonymousWhichdayTest +@DisplayName("Counting a poll nobody signed in for") +class AnonymousPollServiceTest { + + @Autowired + private TestClock clock; + + @Autowired + private TestDatabase database; + + @Autowired + private PollService service; + + private UUID poll; + private LocalDate day; + + @BeforeEach + void setUp() { + database.empty(); + clock.reset(); + day = Sample.mondayAfterNext(LocalDate.now(clock)); + poll = service.create("Q3 offsite", Sample.ADA, List.of(Sample.ADA)); + service.replaceCandidateDays(poll, Caller.of(Sample.ADA), List.of(day)); + service.send(poll, Caller.of(Sample.ADA)); + } + + @Test + @DisplayName("gives every new poll six digits, leading zeros and all") + void everyPollGetsACode() { + assertThat(service.adminCodeOf(poll)).get().asString().matches("\\d{6}"); + } + + /** + * The whole bargain of the mode. In login mode this same call answers empty, and + * {@code PollServiceTest.aStrangerSeesNothing} is the other half of the pair. + */ + @Test + @DisplayName("shows the poll to anybody holding its id") + void theLinkIsTheCredential() { + assertThat(service.poll(poll, Sample.TANVI)).isPresent(); + } + + /** A draft has been shown to nobody, so nobody has a link to it. */ + @Test + @DisplayName("keeps a draft to the person putting it together") + void aDraftIsStillPrivate() { + var draft = service.create("Not sent yet", Sample.ADA, List.of(Sample.ADA)); + + assertThat(service.poll(draft, Sample.ADA)).isPresent(); + assertThat(service.poll(draft, Sample.TANVI)).isEmpty(); + } + + /** + * Answering is what puts somebody on the poll, because nothing else could: the + * tallies, the avatar stacks and {@code Poll.awaiting} all read the invitee list, + * so a ballot from somebody off it would be counted nowhere. + */ + @Test + @DisplayName("puts a voter on the poll as they answer it") + void answeringJoinsThePoll() { + service.castVote(poll, Sample.TANVI, Set.of(day)); + + var answered = service.poll(poll, Sample.TANVI).orElseThrow(); + assertThat(answered.inviteCount()).isEqualTo(2); + assertThat(answered.answerCount()).isEqualTo(1); + assertThat(answered.tallies().getFirst().voteCount()).isEqualTo(1); + } + + @Test + @DisplayName("counts a voter once, however many times they change their answer") + void answeringTwiceIsStillOnePerson() { + service.castVote(poll, Sample.TANVI, Set.of(day)); + service.castVote(poll, Sample.TANVI, Set.of()); + + assertThat(service.poll(poll, Sample.TANVI).orElseThrow().inviteCount()).isEqualTo(2); + } + + @Test + @DisplayName("refuses to be changed by somebody with no code, and yields to the right one") + void theCodeIsWhatChangesIt() { + var code = service.adminCodeOf(poll).orElseThrow(); + + assertThatThrownBy(() -> service.lock(poll, Caller.of(Sample.TANVI), day)) + .isInstanceOf(NotTheOrganizerException.class); + assertThatThrownBy(() -> service.lock(poll, Caller.of(Sample.TANVI, Optional.of("000000")), day)) + .isInstanceOf(NotTheOrganizerException.class); + + service.lock(poll, Caller.of(Sample.TANVI, Optional.of(code)), day); + + assertThat(service.poll(poll, Sample.ADA).orElseThrow().state()).isEqualTo(PollState.LOCKED); + } + + /** + * Six digits are worth nothing without the link they go with, because the code is + * only ever compared with the poll being changed — never looked up across the table. + */ + @Test + @DisplayName("takes another poll's six digits for nothing") + void aCodeIsOnlyGoodForItsOwnPoll() { + var elsewhere = service.create("Design review", Sample.MIRO, List.of(Sample.MIRO)); + var code = service.adminCodeOf(elsewhere).orElseThrow(); + + assertThatThrownBy(() -> service.lock(poll, Caller.of(Sample.TANVI, Optional.of(code)), day)) + .isInstanceOf(NotTheOrganizerException.class); + } + + /** + * The organizer needs no code for their own poll: the address on it is theirs for + * as long as the session that made it lasts. + */ + @Test + @DisplayName("still lets the person who called the poll change it without one") + void theOrganizerNeedsNoCode() { + service.lock(poll, Caller.of(Sample.ADA), day); + + assertThat(service.poll(poll, Sample.ADA).orElseThrow().lockedDay()).isEqualTo(day); + } + + /** + * Login mode withholds a poll's existence from a stranger; here the stranger is + * looking at it, so a refusal that denied it existed would only read as a bug. + */ + @Test + @DisplayName("refuses a link-holder by name rather than pretending the poll is not there") + void theRefusalSaysWhatItMeans() { + assertThatThrownBy(() -> service.lock(poll, Caller.of(stranger()), day)) + .isInstanceOf(NotTheOrganizerException.class) + .hasMessageContaining(stranger().email()); + } + + private static Person stranger() { + return Person.signedIn("00000000-0000-0000-0000-000000000000-20260820t090000@whichday.anonymous", + "Passer-by"); + } +} diff --git a/src/test/java/io/binarycodes/whichday/poll/service/PollServiceTest.java b/src/test/java/io/binarycodes/whichday/poll/service/PollServiceTest.java index 89e31be..b256614 100644 --- a/src/test/java/io/binarycodes/whichday/poll/service/PollServiceTest.java +++ b/src/test/java/io/binarycodes/whichday/poll/service/PollServiceTest.java @@ -22,6 +22,7 @@ import io.binarycodes.whichday.WhichdayTest; import io.binarycodes.whichday.people.domain.Person; import io.binarycodes.whichday.people.service.AccountDirectory; +import io.binarycodes.whichday.poll.domain.Caller; import io.binarycodes.whichday.poll.domain.DayTally; import io.binarycodes.whichday.poll.domain.Poll; import io.binarycodes.whichday.poll.domain.PollState; @@ -91,20 +92,71 @@ void aHoldoutIsNeverYourself() { assertThat(poll.awaitingOthers(Sample.ADA)).isEmpty(); } + /** + * The order breaks by date so the list is stable. The rank does not: two days on + * the same count are the same rank, because their bars are the same length and + * painting one darker claims an order that is not there. + */ @Test - @DisplayName("ranks by count and breaks a tie by date") + @DisplayName("orders by count and breaks a tie by date, but ranks tied days alike") void ranking() { var monday = Sample.mondayAfterNext(LocalDate.now(clock)); var friday = monday.plusDays(4); var id = service.create("Tie break", Sample.ADA, Sample.TEAM); - service.replaceCandidateDays(id, Sample.ADA, List.of(friday, monday)); - service.send(id, Sample.ADA); + service.replaceCandidateDays(id, Caller.of(Sample.ADA), List.of(friday, monday)); + service.send(id, Caller.of(Sample.ADA)); service.castVote(id, Sample.ADA, Set.of(monday, friday)); var tallies = poll(id).orElseThrow().tallies(); assertThat(tallies).extracting(DayTally::day).containsExactly(monday, friday); - assertThat(tallies).extracting(DayTally::rank).containsExactly(1, 2); + assertThat(tallies).extracting(DayTally::rank).containsExactly(1, 1); + } + + /** + * A shared top is not a result. The application used to hand the earliest of the + * tied days rank 1, call it the most popular on every ballot, and offer it as the + * only day that could be locked — so the other tied days were unreachable. + */ + @Test + @DisplayName("names no leader when the highest count is shared") + void aTieHasNoLeader() { + var monday = Sample.mondayAfterNext(LocalDate.now(clock)); + var tuesday = monday.plusDays(1); + var friday = monday.plusDays(4); + var id = service.create("Tie break", Sample.ADA, Sample.TEAM); + service.replaceCandidateDays(id, Caller.of(Sample.ADA), List.of(monday, tuesday, friday)); + service.send(id, Caller.of(Sample.ADA)); + service.castVote(id, Sample.ADA, Set.of(monday, tuesday, friday)); + service.castVote(id, Sample.MIRO, Set.of(monday, tuesday, friday)); + + var poll = poll(id).orElseThrow(); + + assertThat(poll.leader()).isEmpty(); + assertThat(poll.tallies()).noneMatch(DayTally::isLeading); + assertThat(poll.tiedAtTheTop()).extracting(DayTally::day) + .containsExactly(monday, tuesday, friday); + + // One more vote settles it, and the leader is the day that actually won. + service.castVote(id, Sample.SARA, Set.of(tuesday)); + + var settled = poll(id).orElseThrow(); + assertThat(settled.leader()).get().extracting(DayTally::day).isEqualTo(tuesday); + assertThat(settled.tiedAtTheTop()).isEmpty(); + } + + @Test + @DisplayName("is not a tie when nobody has voted at all") + void nobodyVotingIsNotATie() { + var monday = Sample.mondayAfterNext(LocalDate.now(clock)); + var id = service.create("Nothing yet", Sample.ADA, Sample.TEAM); + service.replaceCandidateDays(id, Caller.of(Sample.ADA), List.of(monday, monday.plusDays(1))); + service.send(id, Caller.of(Sample.ADA)); + + var poll = poll(id).orElseThrow(); + + assertThat(poll.leader()).isEmpty(); + assertThat(poll.tiedAtTheTop()).isEmpty(); } @Test @@ -121,7 +173,7 @@ void shareIsOfTheInvited() { void withdrawingADayDropsItsVotes() { var remaining = poll(offsite).orElseThrow().candidateDays().stream().skip(1).toList(); - service.replaceCandidateDays(offsite, Sample.ADA, remaining); + service.replaceCandidateDays(offsite, Caller.of(Sample.ADA), remaining); var updated = poll(offsite).orElseThrow(); assertThat(updated.candidateDays()).isEqualTo(remaining); @@ -165,7 +217,7 @@ void acceptingAProposal() { assertThat(poll(offsite).orElseThrow().candidateDays()).doesNotContain(proposed); - service.acceptProposal(offsite, Sample.ADA, proposed); + service.acceptProposal(offsite, Caller.of(Sample.ADA), proposed); assertThat(poll(offsite).orElseThrow().candidateDays()).contains(proposed); } @@ -175,7 +227,7 @@ void acceptingAProposal() { void alternativesAreTheOrganizersChoice() { assertThat(poll(offsite).orElseThrow().alternativesAllowed()).isTrue(); - service.allowAlternatives(offsite, Sample.ADA, false); + service.allowAlternatives(offsite, Caller.of(Sample.ADA), false); assertThat(poll(offsite).orElseThrow().alternativesAllowed()).isFalse(); } @@ -183,7 +235,7 @@ void alternativesAreTheOrganizersChoice() { @Test @DisplayName("still lets somebody say none of the days work when alternatives are off") void decliningSurvivesAlternativesBeingOff() { - service.allowAlternatives(offsite, Sample.ADA, false); + service.allowAlternatives(offsite, Caller.of(Sample.ADA), false); service.decline(offsite, Sample.JONAS, List.of(), "Away that week"); var poll = poll(offsite).orElseThrow(); @@ -200,7 +252,7 @@ void decliningSurvivesAlternativesBeingOff() { void locking() { var leader = poll(offsite).orElseThrow().leader().orElseThrow().day(); - service.lock(offsite, Sample.ADA, leader); + service.lock(offsite, Caller.of(Sample.ADA), leader); assertThat(poll(offsite).orElseThrow().state()).isEqualTo(PollState.LOCKED); assertThat(service.openPolls(Sample.ADA)).extracting(PollSummary::id).doesNotContain(offsite); @@ -211,11 +263,11 @@ void locking() { @DisplayName("a new poll is a draft until it is sent, and then it is stamped") void sendingOpensThePoll() { var id = service.create("Roadmap workshop", Sample.ADA, Sample.TEAM); - service.replaceCandidateDays(id, Sample.ADA, List.of(LocalDate.of(2026, 9, 7))); + service.replaceCandidateDays(id, Caller.of(Sample.ADA), List.of(LocalDate.of(2026, 9, 7))); assertThat(poll(id).orElseThrow().state()).isEqualTo(PollState.DRAFT); - service.send(id, Sample.ADA); + service.send(id, Caller.of(Sample.ADA)); var sent = poll(id).orElseThrow(); assertThat(sent.state()).isEqualTo(PollState.OPEN); @@ -226,11 +278,11 @@ void sendingOpensThePoll() { @DisplayName("sending twice keeps the original closing date") void sendingIsIdempotent() { var id = service.create("Roadmap workshop", Sample.ADA, Sample.TEAM); - service.replaceCandidateDays(id, Sample.ADA, List.of(LocalDate.of(2026, 9, 7))); - service.send(id, Sample.ADA); + service.replaceCandidateDays(id, Caller.of(Sample.ADA), List.of(LocalDate.of(2026, 9, 7))); + service.send(id, Caller.of(Sample.ADA)); var first = poll(id).orElseThrow().closesOn(); - service.send(id, Sample.ADA); + service.send(id, Caller.of(Sample.ADA)); assertThat(poll(id).orElseThrow().closesOn()).isEqualTo(first); } @@ -287,15 +339,15 @@ void theOrganizerChoosesTheClosingDate() { var last = LocalDate.now(clock).plusWeeks(2); var id = openPoll(Sample.TEAM, LocalDate.now(clock).plusWeeks(1), last); - service.closeOn(id, Sample.ADA, last.minusDays(4)); + service.closeOn(id, Caller.of(Sample.ADA), last.minusDays(4)); assertThat(poll(id).orElseThrow().closesOn()).isEqualTo(last.minusDays(4)); // Never past the last day on the table. - service.closeOn(id, Sample.ADA, last.plusDays(3)); + service.closeOn(id, Caller.of(Sample.ADA), last.plusDays(3)); assertThat(poll(id).orElseThrow().closesOn()).isEqualTo(last); // Never in the past. - service.closeOn(id, Sample.ADA, LocalDate.now(clock).minusWeeks(1)); + service.closeOn(id, Caller.of(Sample.ADA), LocalDate.now(clock).minusWeeks(1)); assertThat(poll(id).orElseThrow().closesOn()).isEqualTo(LocalDate.now(clock).plusDays(1)); assertThat(service.latestClosingDay(id)).contains(last); @@ -308,11 +360,11 @@ void plannedClosingBeforeSending() { assertThat(service.plannedClosing(id)).isEmpty(); - service.replaceCandidateDays(id, Sample.ADA, List.of(LocalDate.of(2026, 9, 7), LocalDate.of(2026, 9, 9))); + service.replaceCandidateDays(id, Caller.of(Sample.ADA), List.of(LocalDate.of(2026, 9, 7), LocalDate.of(2026, 9, 9))); assertThat(service.plannedClosing(id)).contains(LocalDate.of(2026, 9, 9)); - service.send(id, Sample.ADA); + service.send(id, Caller.of(Sample.ADA)); assertThat(service.plannedClosing(id)).contains(poll(id).orElseThrow().closesOn()); } @@ -351,7 +403,7 @@ void refusesAnswersWhenClosed() { @DisplayName("refuses an answer to a poll that was never sent") void refusesAnswersBeforeSending() { var id = service.create("Roadmap workshop", Sample.ADA, Sample.TEAM); - service.replaceCandidateDays(id, Sample.ADA, List.of(LocalDate.of(2026, 9, 7))); + service.replaceCandidateDays(id, Caller.of(Sample.ADA), List.of(LocalDate.of(2026, 9, 7))); assertThatThrownBy(() -> service.castVote(id, Sample.JONAS, Set.of(LocalDate.of(2026, 9, 7)))) .isInstanceOf(PollClosedException.class); @@ -381,10 +433,10 @@ void draftsArePrivate() { void deletingDrafts() { var draft = service.create("Roadmap workshop", Sample.ADA, Sample.TEAM); - service.deleteDraft(draft, Sample.ADA); + service.deleteDraft(draft, Caller.of(Sample.ADA)); assertThat(poll(draft)).isEmpty(); - assertThatThrownBy(() -> service.deleteDraft(offsite, Sample.ADA)) + assertThatThrownBy(() -> service.deleteDraft(offsite, Caller.of(Sample.ADA))) .isInstanceOf(IllegalStateException.class) .hasMessageContaining("cannot be discarded"); assertThat(poll(offsite)).isPresent(); @@ -457,7 +509,7 @@ void unknownPoll() { var nobodys = UUID.randomUUID(); assertThat(poll(nobodys)).isEmpty(); - assertThatThrownBy(() -> service.lock(nobodys, Sample.ADA, LocalDate.of(2026, 9, 7))) + assertThatThrownBy(() -> service.lock(nobodys, Caller.of(Sample.ADA), LocalDate.of(2026, 9, 7))) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining(nobodys.toString()); } @@ -501,8 +553,8 @@ void listsOnlyYourOwnPolls() { Sample.settled(service, clock); // Organized by Miro, not by the openPoll helper, which would make Ada the organizer. var theirs = service.create("Theirs alone", Sample.MIRO, List.of(Sample.MIRO, Sample.JONAS)); - service.replaceCandidateDays(theirs, Sample.MIRO, List.of(LocalDate.now(clock).plusWeeks(2))); - service.send(theirs, Sample.MIRO); + service.replaceCandidateDays(theirs, Caller.of(Sample.MIRO), List.of(LocalDate.now(clock).plusWeeks(2))); + service.send(theirs, Caller.of(Sample.MIRO)); assertThat(service.openPolls(Sample.ADA)).extracting(PollSummary::id).doesNotContain(theirs); assertThat(service.openPolls(Sample.MIRO)).extracting(PollSummary::id).contains(theirs); @@ -533,8 +585,8 @@ void aDraftIsTheOrganizersAlone() { assertThat(service.draftPolls(Sample.MIRO)).isEmpty(); // Sending it is what makes it everybody's to see. - service.replaceCandidateDays(draft, Sample.ADA, List.of(Sample.mondayAfterNext(LocalDate.now(clock)))); - service.send(draft, Sample.ADA); + service.replaceCandidateDays(draft, Caller.of(Sample.ADA), List.of(Sample.mondayAfterNext(LocalDate.now(clock)))); + service.send(draft, Caller.of(Sample.ADA)); assertThat(service.poll(draft, Sample.MIRO)).isPresent(); } @@ -548,21 +600,21 @@ void nothingChangesOnceVotingIsOver() { clock.advanceDays(ChronoUnit.DAYS.between(LocalDate.now(clock), before.closesOn()) + 1); assertThat(poll(id).orElseThrow().state()).isEqualTo(PollState.CLOSED); - assertThatThrownBy(() -> service.replaceCandidateDays(id, Sample.ADA, List.of(day.plusWeeks(4)))) + assertThatThrownBy(() -> service.replaceCandidateDays(id, Caller.of(Sample.ADA), List.of(day.plusWeeks(4)))) .isInstanceOf(PollNotEditableException.class); - assertThatThrownBy(() -> service.closeOn(id, Sample.ADA, day.plusWeeks(4))) + assertThatThrownBy(() -> service.closeOn(id, Caller.of(Sample.ADA), day.plusWeeks(4))) .isInstanceOf(PollNotEditableException.class); - assertThatThrownBy(() -> service.acceptProposal(id, Sample.ADA, day.plusWeeks(4))) + assertThatThrownBy(() -> service.acceptProposal(id, Caller.of(Sample.ADA), day.plusWeeks(4))) .isInstanceOf(PollNotEditableException.class); - assertThatThrownBy(() -> service.allowAlternatives(id, Sample.ADA, false)) + assertThatThrownBy(() -> service.allowAlternatives(id, Caller.of(Sample.ADA), false)) .isInstanceOf(PollNotEditableException.class); - assertThatThrownBy(() -> service.addInvitee(id, Sample.ADA, Sample.TANVI)) + assertThatThrownBy(() -> service.addInvitee(id, Caller.of(Sample.ADA), Sample.TANVI)) .isInstanceOf(PollNotEditableException.class); - assertThatThrownBy(() -> service.lock(id, Sample.ADA, day)) + assertThatThrownBy(() -> service.lock(id, Caller.of(Sample.ADA), day)) .isInstanceOf(PollNotEditableException.class); - assertThatThrownBy(() -> service.send(id, Sample.ADA)) + assertThatThrownBy(() -> service.send(id, Caller.of(Sample.ADA))) .isInstanceOf(PollNotEditableException.class); - assertThatThrownBy(() -> service.deleteDraft(id, Sample.ADA)) + assertThatThrownBy(() -> service.deleteDraft(id, Caller.of(Sample.ADA))) .isInstanceOf(PollNotEditableException.class); // Answers were already refused, and say so in the voter's own terms. assertThatThrownBy(() -> service.castVote(id, Sample.SARA, Set.of(day))) @@ -586,13 +638,13 @@ void nothingChangesOnceADayIsLocked() { assertThat(settled.state()).isEqualTo(PollState.LOCKED); var other = settled.lockedDay().plusWeeks(3); - assertThatThrownBy(() -> service.replaceCandidateDays(id, Sample.ADA, List.of(other))) + assertThatThrownBy(() -> service.replaceCandidateDays(id, Caller.of(Sample.ADA), List.of(other))) .isInstanceOf(PollNotEditableException.class); - assertThatThrownBy(() -> service.lock(id, Sample.ADA, other)) + assertThatThrownBy(() -> service.lock(id, Caller.of(Sample.ADA), other)) .isInstanceOf(PollNotEditableException.class); - assertThatThrownBy(() -> service.addInvitee(id, Sample.ADA, Sample.TANVI)) + assertThatThrownBy(() -> service.addInvitee(id, Caller.of(Sample.ADA), Sample.TANVI)) .isInstanceOf(PollNotEditableException.class); - assertThatThrownBy(() -> service.closeOn(id, Sample.ADA, other)) + assertThatThrownBy(() -> service.closeOn(id, Caller.of(Sample.ADA), other)) .isInstanceOf(PollNotEditableException.class); var after = poll(id).orElseThrow(); @@ -627,21 +679,21 @@ void onlyTheOrganizerMayChangeThePoll() { var draft = service.create("Ada's draft", Sample.ADA, Sample.TEAM); // Miro is on this poll and can see all of it. None of it is his to change. - assertThatThrownBy(() -> service.lock(offsite, Sample.MIRO, day)) + assertThatThrownBy(() -> service.lock(offsite, Caller.of(Sample.MIRO), day)) .isInstanceOf(NotTheOrganizerException.class); - assertThatThrownBy(() -> service.acceptProposal(offsite, Sample.MIRO, day.plusDays(20))) + assertThatThrownBy(() -> service.acceptProposal(offsite, Caller.of(Sample.MIRO), day.plusDays(20))) .isInstanceOf(NotTheOrganizerException.class); - assertThatThrownBy(() -> service.replaceCandidateDays(offsite, Sample.MIRO, List.of(day))) + assertThatThrownBy(() -> service.replaceCandidateDays(offsite, Caller.of(Sample.MIRO), List.of(day))) .isInstanceOf(NotTheOrganizerException.class); - assertThatThrownBy(() -> service.closeOn(offsite, Sample.MIRO, day)) + assertThatThrownBy(() -> service.closeOn(offsite, Caller.of(Sample.MIRO), day)) .isInstanceOf(NotTheOrganizerException.class); - assertThatThrownBy(() -> service.allowAlternatives(offsite, Sample.MIRO, false)) + assertThatThrownBy(() -> service.allowAlternatives(offsite, Caller.of(Sample.MIRO), false)) .isInstanceOf(NotTheOrganizerException.class); - assertThatThrownBy(() -> service.addInvitee(offsite, Sample.MIRO, Sample.TANVI)) + assertThatThrownBy(() -> service.addInvitee(offsite, Caller.of(Sample.MIRO), Sample.TANVI)) .isInstanceOf(NotTheOrganizerException.class); - assertThatThrownBy(() -> service.send(draft, Sample.MIRO)) + assertThatThrownBy(() -> service.send(draft, Caller.of(Sample.MIRO))) .isInstanceOf(NotTheOrganizerException.class); - assertThatThrownBy(() -> service.deleteDraft(draft, Sample.MIRO)) + assertThatThrownBy(() -> service.deleteDraft(draft, Caller.of(Sample.MIRO))) .isInstanceOf(NotTheOrganizerException.class); // Nothing moved. @@ -659,8 +711,8 @@ void onlyTheOrganizerMayChangeThePoll() { void refusalsSayDifferentAmounts() { var day = poll(offsite).orElseThrow().candidateDays().getFirst(); - var invitee = catchThrowable(() -> service.lock(offsite, Sample.MIRO, day)); - var stranger = catchThrowable(() -> service.lock(offsite, Sample.TANVI, day)); + var invitee = catchThrowable(() -> service.lock(offsite, Caller.of(Sample.MIRO), day)); + var stranger = catchThrowable(() -> service.lock(offsite, Caller.of(Sample.TANVI), day)); // Miro can see the poll, so there is nothing left to withhold — only to refuse. assertThat(invitee).isInstanceOf(NotTheOrganizerException.class) @@ -677,11 +729,11 @@ void theOrganizerMayChangeThePoll() { var draft = service.create("Ada's own", Sample.ADA, Sample.TEAM); var day = Sample.mondayAfterNext(LocalDate.now(clock)); - service.replaceCandidateDays(draft, Sample.ADA, List.of(day)); - service.allowAlternatives(draft, Sample.ADA, false); - service.addInvitee(draft, Sample.ADA, Sample.TANVI); - service.send(draft, Sample.ADA); - service.lock(draft, Sample.ADA, day); + service.replaceCandidateDays(draft, Caller.of(Sample.ADA), List.of(day)); + service.allowAlternatives(draft, Caller.of(Sample.ADA), false); + service.addInvitee(draft, Caller.of(Sample.ADA), Sample.TANVI); + service.send(draft, Caller.of(Sample.ADA)); + service.lock(draft, Caller.of(Sample.ADA), day); var settled = poll(draft).orElseThrow(); assertThat(settled.state()).isEqualTo(PollState.LOCKED); @@ -700,8 +752,8 @@ private Optional poll(UUID id) { private UUID openPoll(List invited, LocalDate... days) { var id = service.create("Poll " + days[0], Sample.ADA, invited); - service.replaceCandidateDays(id, Sample.ADA, List.of(days)); - service.send(id, Sample.ADA); + service.replaceCandidateDays(id, Caller.of(Sample.ADA), List.of(days)); + service.send(id, Caller.of(Sample.ADA)); return id; } } diff --git a/src/test/java/io/binarycodes/whichday/poll/ui/presenter/PollPresenterTest.java b/src/test/java/io/binarycodes/whichday/poll/ui/presenter/PollPresenterTest.java index b3ecea7..7ca97ae 100644 --- a/src/test/java/io/binarycodes/whichday/poll/ui/presenter/PollPresenterTest.java +++ b/src/test/java/io/binarycodes/whichday/poll/ui/presenter/PollPresenterTest.java @@ -4,6 +4,7 @@ import java.time.LocalDate; import java.util.List; +import java.util.Optional; import java.util.Set; import java.util.UUID; @@ -16,6 +17,7 @@ import io.binarycodes.whichday.TestClock; import io.binarycodes.whichday.TestDatabase; import io.binarycodes.whichday.WhichdayTest; +import io.binarycodes.whichday.base.config.AccessMode; import io.binarycodes.whichday.people.domain.Person; import io.binarycodes.whichday.people.service.AccountDirectory; import io.binarycodes.whichday.people.ui.presenter.ViewerSession; @@ -60,7 +62,7 @@ void setUp() { Sample.signedInBefore(directory); offsite = Sample.offsite(polls, clock); signedIn = Sample.ADA; - presenter = new PollPresenter(polls, invitees, viewerSession(), clock); + presenter = new PollPresenter(polls, invitees, viewerSession(), clock, AccessMode.LOGIN); } /** Stands in for the signed-in user, which the application reads from the provider. */ @@ -75,6 +77,21 @@ public Person viewer() { public void signOut() { signedIn = null; } + + @Override + public boolean isIdentified() { + return signedIn != null; + } + + @Override + public Optional adminCode() { + return Optional.empty(); + } + + @Override + public void identify(String name, String adminCode) { + throw new UnsupportedOperationException(); + } }; } diff --git a/src/test/java/io/binarycodes/whichday/poll/ui/share/CalendarInviteTest.java b/src/test/java/io/binarycodes/whichday/poll/ui/share/CalendarInviteTest.java index 7f7b7ec..2ebb1b8 100644 --- a/src/test/java/io/binarycodes/whichday/poll/ui/share/CalendarInviteTest.java +++ b/src/test/java/io/binarycodes/whichday/poll/ui/share/CalendarInviteTest.java @@ -24,19 +24,23 @@ class CalendarInviteTest { @Test @DisplayName("ends an all-day event on the following date, because iCalendar's end is exclusive") void allDayEventsAreExclusiveAtTheEnd() { - var ics = CalendarInvite.calendar(poll("Q3 team offsite"), List.of(MONDAY), false); + var ics = CalendarInvite.calendar(poll("Q3 team offsite"), List.of(MONDAY)); assertThat(ics).contains("DTSTART;VALUE=DATE:20260907") .contains("DTEND;VALUE=DATE:20260908") .contains("STATUS:CONFIRMED"); } + /** + * Only a settled day is ever offered, so there is no TENTATIVE case left to cover. + * The multi-day shape stays exercised because the writer still loops. + */ @Test - @DisplayName("marks days that are only on the table as tentative, one event each") - void candidateDaysAreTentative() { - var ics = CalendarInvite.calendar(poll("Q3 team offsite"), List.of(MONDAY, FRIDAY), true); + @DisplayName("wraps one event per day in a single calendar") + void eachDayIsItsOwnEvent() { + var ics = CalendarInvite.calendar(poll("Q3 team offsite"), List.of(MONDAY, FRIDAY)); - assertThat(ics).contains("STATUS:TENTATIVE"); + assertThat(ics).doesNotContain("STATUS:TENTATIVE"); assertThat(ics.split("BEGIN:VEVENT", -1)).hasSize(3); assertThat(ics).startsWith("BEGIN:VCALENDAR").endsWith("END:VCALENDAR\r\n"); } @@ -44,7 +48,7 @@ void candidateDaysAreTentative() { @Test @DisplayName("gives every event its own identifier, so two do not collapse into one") void eventsAreDistinct() { - var ics = CalendarInvite.calendar(poll("Q3 team offsite"), List.of(MONDAY, FRIDAY), true); + var ics = CalendarInvite.calendar(poll("Q3 team offsite"), List.of(MONDAY, FRIDAY)); assertThat(ics).contains("UID:" + OFFSITE + "-0@whichday") .contains("UID:" + OFFSITE + "-1@whichday"); @@ -53,7 +57,7 @@ void eventsAreDistinct() { @Test @DisplayName("escapes the characters iCalendar reads as structure") void escapesTheTitle() { - var ics = CalendarInvite.calendar(poll("Lunch, drinks; maybe \\ both"), List.of(MONDAY), false); + var ics = CalendarInvite.calendar(poll("Lunch, drinks; maybe \\ both"), List.of(MONDAY)); assertThat(ics).contains("SUMMARY:Lunch\\, drinks\\; maybe \\\\ both"); } @@ -61,7 +65,7 @@ void escapesTheTitle() { @Test @DisplayName("folds every line the way the format requires") void usesCarriageReturns() { - var ics = CalendarInvite.calendar(poll("Q3 team offsite"), List.of(MONDAY), false); + var ics = CalendarInvite.calendar(poll("Q3 team offsite"), List.of(MONDAY)); assertThat(ics.lines()).allSatisfy(line -> assertThat(line).doesNotContain("\r")); assertThat(ics).doesNotContain("\n\n"); diff --git a/src/test/java/io/binarycodes/whichday/poll/ui/view/AnonymousPollJourneyTest.java b/src/test/java/io/binarycodes/whichday/poll/ui/view/AnonymousPollJourneyTest.java new file mode 100644 index 0000000..2790f7a --- /dev/null +++ b/src/test/java/io/binarycodes/whichday/poll/ui/view/AnonymousPollJourneyTest.java @@ -0,0 +1,434 @@ +package io.binarycodes.whichday.poll.ui.view; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.LocalDate; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; + +import com.vaadin.browserless.SpringBrowserlessTest; +import com.vaadin.flow.component.ClickEvent; +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.ComponentUtil; +import com.vaadin.flow.component.HasText; +import com.vaadin.flow.component.UI; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.router.RouteParameters; + +import io.binarycodes.whichday.AnonymousWhichdayTest; +import io.binarycodes.whichday.Sample; +import io.binarycodes.whichday.TestClock; +import io.binarycodes.whichday.TestDatabase; +import io.binarycodes.whichday.base.config.AccessMode; +import io.binarycodes.whichday.people.service.AccountDirectory; +import io.binarycodes.whichday.people.ui.PersonAvatar; +import io.binarycodes.whichday.people.ui.presenter.AnonymousViewerSession; +import io.binarycodes.whichday.people.ui.presenter.ViewerSession; +import io.binarycodes.whichday.people.ui.view.IdentityView; +import io.binarycodes.whichday.poll.domain.Poll; +import io.binarycodes.whichday.poll.service.InviteeSearch; +import io.binarycodes.whichday.poll.service.NotTheOrganizerException; +import io.binarycodes.whichday.poll.service.PollService; +import io.binarycodes.whichday.poll.ui.component.DayBallot; +import io.binarycodes.whichday.poll.ui.presenter.PollPresenter; + +/** + * The same journey with no provider behind it: a name typed on the way in, a link that + * works for somebody who has never been here, and six digits standing where the + * organizer's account would have stood. + * + *

Nothing signs anybody in — that is the point — so the who-are-you screen is the + * fixture. The browserless base class gives one {@code VaadinSession} per method, and + * an identity belongs to a session: naming the same one twice corrects a name rather + * than becoming somebody else. So a second visitor is a second session, built by hand + * with a presenter of its own — which is what {@link #visitor} is. + */ +@AnonymousWhichdayTest +@DisplayName("Walking the poll with nobody signed in") +class AnonymousPollJourneyTest extends SpringBrowserlessTest { + + @Autowired + private ApplicationContext context; + + @Autowired + private TestClock clock; + + @Autowired + private TestDatabase database; + + @BeforeEach + void empty() { + database.empty(); + clock.reset(); + } + + @Test + @DisplayName("asks who is asking before it shows anything at all") + void theFrontDoor() { + UI.getCurrent().navigate(PollsView.class); + + assertThat(currentView()).isInstanceOf(IdentityView.class); + assertThat(textOf(currentView())).contains(translation("identity.headline"), + translation("identity.name"), translation("identity.code")); + } + + /** + * The link is the whole bargain of this mode, so the detour through the front door + * has to give it back. A guard that dropped the destination would turn every shared + * link into a trip to the create screen. + */ + @Test + @DisplayName("keeps the shared link across the detour and lands on the ballot") + void theSharedLinkSurvivesTheDetour() { + var poll = pollCalledBy(visitor("Ada", ""), "Q3 offsite"); + + UI.getCurrent().navigate(BallotView.class, new RouteParameters("id", poll.toString())); + assertThat(currentView()).isInstanceOf(IdentityView.class); + + type(0, "Miro"); + click("Continue"); + + assertThat(currentView()).isInstanceOf(BallotView.class); + assertThat(textOf(currentView())).contains("Q3 offsite"); + } + + @Test + @DisplayName("has no polls list: home is where a poll starts") + void thereIsNoList() { + identifyAs("Ada", ""); + + UI.getCurrent().navigate(PollsView.class); + + assertThat(currentView()).isInstanceOf(NewPollView.class); + } + + @Test + @DisplayName("asks for a name and days, and nobody to invite") + void creatingAsksForNoInvitees() { + identifyAs("Ada", ""); + UI.getCurrent().navigate(NewPollView.class); + + var screen = textOf(currentView()); + + assertThat(screen).contains("Event name", "Anyone with the link can answer"); + assertThat(screen).doesNotContain("Who decides with you"); + } + + @Test + @DisplayName("shows the admin code on the share screen, and nowhere else") + void theShareScreenHandsOverTheCode() { + var poll = pollCalledBy("Ada", "Q3 offsite"); + + navigateTo(ShareView.class, poll); + var screen = textOf(currentView()); + + assertThat(screen).contains("Admin code", "Write it down"); + assertThat(screen).doesNotContain("Invited", "Message"); + assertThat(presenter().adminCode(poll)).get().asString().matches("\\d{6}"); + + navigateTo(ResultsView.class, poll); + assertThat(textOf(currentView())).doesNotContain("Admin code"); + } + + /** + * An anonymous poll starts empty — the organizer included. Membership is having + * answered, because nothing else could say who is on it, and the service adds each + * voter as they answer so that every count, stack and tally downstream reads a + * ballot that would otherwise belong to nobody. + */ + @Test + @DisplayName("starts with nobody on it and adds each voter as they answer") + void answeringJoinsThePoll() { + var poll = pollCalledBy("Ada", "Q3 offsite"); + var day = onlyDayOf(poll); + assertThat(pollOf(poll).inviteCount()).isZero(); + + visitor("Miro", "").vote(poll, Set.of(day)); + + var answered = pollOf(poll); + assertThat(answered.inviteCount()).isEqualTo(1); + assertThat(answered.answerCount()).isEqualTo(1); + assertThat(answered.awaiting()).isEmpty(); + assertThat(answered.ballots()).singleElement() + .satisfies(ballot -> assertThat(ballot.voter().displayName()).isEqualTo("Miro")); + } + + /** + * Nobody can say who has not voted when nobody was asked, so the screen does not + * guess. "Everyone but Ada" is a claim about an invitee list that does not exist. + */ + @Test + @DisplayName("never says who is missing, because nobody knows who was asked") + void nobodyIsWaitedOn() { + var poll = pollCalledBy("Ada", "Q3 offsite"); + visitor("Miro", "").vote(poll, Set.of(onlyDayOf(poll))); + + navigateTo(ResultsView.class, poll); + var screen = textOf(currentView()); + + assertThat(screen).contains("Q3 offsite"); + assertThat(screen).doesNotContain("Waiting on", "WAITING ON", "Everyone"); + } + + @Test + @DisplayName("refuses to settle the poll for somebody without the code, and allows it with") + void theCodeIsWhatSettlesIt() { + var poll = pollCalledBy("Ada", "Q3 offsite"); + var code = presenter().adminCode(poll).orElseThrow(); + var day = onlyDayOf(poll); + + var withoutTheCode = visitor("Miro", ""); + withoutTheCode.vote(poll, Set.of(day)); + assertThatThrownBy(() -> withoutTheCode.lock(poll, day)) + .isInstanceOf(NotTheOrganizerException.class); + + var withTheCode = visitor("Miro", code); + + withTheCode.lock(poll, day); + assertThat(pollOf(poll).lockedDay()).isEqualTo(day); + } + + /** + * Six digits are worth nothing on their own. Checking the code against the poll the + * caller already holds the link to is what keeps them worth nothing. + */ + @Test + @DisplayName("takes six digits that belong to another poll for nothing") + void aCodeIsOnlyGoodForItsOwnPoll() { + var mine = pollCalledBy("Ada", "Q3 offsite"); + var code = presenter().adminCode(mine).orElseThrow(); + + var miro = visitor("Miro", ""); + var theirs = pollCalledBy(miro, "Design review"); + + var tanvi = visitor("Tanvi", code); + var day = onlyDayOf(theirs); + assertThatThrownBy(() -> tanvi.lock(theirs, day)) + .isInstanceOf(NotTheOrganizerException.class); + } + + /** + * The standings header stacked avatars, and an initial identifies nobody here for + * the same reason it identifies nobody on the ballot. + */ + @Test + @DisplayName("names the people who answered on the standings, rather than stacking initials") + void theStandingsNameWhoAnswered() { + var poll = pollCalledBy("Ada", "Q3 offsite"); + visitor("Rob Nieminen", "").vote(poll, Set.of(onlyDayOf(poll))); + + navigateTo(ResultsView.class, poll); + + assertThat(textOf(currentView())).contains("Rob"); + assertThat(componentsOf(currentView()).filter(PersonAvatar.class::isInstance)).isEmpty(); + } + + /** + * The last screen, and the one anybody keeps — so it is the worst place of the three + * to show a letter that identifies nobody. + */ + @Test + @DisplayName("names who is coming on the locked screen, rather than stacking initials") + void theLockedScreenNamesWhoIsComing() { + var poll = pollCalledBy("Ada", "Q3 offsite"); + var day = onlyDayOf(poll); + visitor("Rob Nieminen", "").vote(poll, Set.of(day)); + presenter().lock(poll, day); + + navigateTo(LockedView.class, poll); + + assertThat(textOf(currentView())).contains("Rob"); + assertThat(componentsOf(currentView()).filter(PersonAvatar.class::isInstance)).isEmpty(); + } + + /** + * Every one of these promises a message, and there is nobody to send one to: no + * addresses, no invitee list, no account. Offering them would be the screen writing + * a cheque the deployment cannot cash. + */ + @Test + @DisplayName("offers no reminder, no nudge and nobody to tell") + void nothingPromisesAMessage() { + var poll = pollCalledBy("Ada", "Q3 offsite"); + var day = onlyDayOf(poll); + + navigateTo(ResultsView.class, poll); + assertThat(textOf(currentView())).doesNotContain("reminder", "nudge", "Nudge"); + + visitor("Miro", "").vote(poll, Set.of(day)); + navigateTo(ResultsView.class, poll); + assertThat(textOf(currentView())).doesNotContain("reminder", "nudge", "Nudge"); + + presenter().lock(poll, day); + navigateTo(LockedView.class, poll); + var locked = textOf(currentView()); + assertThat(locked).contains("Add to calendar"); + assertThat(locked).doesNotContain("Tell the team"); + } + + /** + * An avatar is initials, and a name typed minutes ago makes initials meaningless — + * two people who both called themselves something with an R are the same letter. + * So the ballot names its voters here and shows faces in login mode, which + * {@code PollJourneyTest.theBallotShowsFaces} is the other half of. + */ + @Test + @DisplayName("names the people who voted for a day rather than showing initials") + void theBallotNamesItsVoters() { + var poll = pollCalledBy("Ada", "Q3 offsite"); + var day = onlyDayOf(poll); + visitor("Rob Nieminen", "").vote(poll, Set.of(day)); + + navigateTo(BallotView.class, poll); + + // The field, not the screen: the header carries the viewer's own avatar either way. + assertThat(textOf(ballotField())).contains("Rob"); + assertThat(componentsOf(ballotField()).filter(PersonAvatar.class::isInstance)).isEmpty(); + } + + /** Past six the tail is a count, so a popular day cannot push the row off the screen. */ + @Test + @DisplayName("names six people and counts the rest") + void theBallotCountsTheTail() { + var poll = pollCalledBy("Ada", "Q3 offsite"); + var day = onlyDayOf(poll); + List.of("Rob", "Ab", "Ajaj", "Akekek", "Djdjd", "Ekeke", "Djj", "Nils") + .forEach(name -> visitor(name, "").vote(poll, Set.of(day))); + + navigateTo(BallotView.class, poll); + var field = textOf(ballotField()); + + assertThat(field).contains("Rob", "Ab", "Ajaj", "Akekek", "Djdjd", "Ekeke"); + assertThat(field).doesNotContain("Djj", "Nils"); + assertThat(field).contains("+2 more"); + } + + /** + * The link is the credential, so a visitor who was never invited still reads the + * poll. In login mode this same call answers empty, and that difference is the + * whole of what anonymous mode trades away. + */ + @Test + @DisplayName("shows the poll to somebody who was never put on it") + void theLinkIsTheCredential() { + var poll = pollCalledBy("Ada", "Q3 offsite"); + + assertThat(visitor("Tanvi", "").poll(poll)).isPresent(); + } + + // ---- Building what a test needs ---- + + /** Names this browser's session, the way the who-are-you screen does. */ + private void identifyAs(String name, String adminCode) { + context.getBean(ViewerSession.class).identify(name, adminCode); + } + + /** + * Somebody else entirely: their own session, their own minted address, their own + * presenter. Hand-built because an identity belongs to a Vaadin session and the + * test only has one of those. + */ + private PollPresenter visitor(String name, String adminCode) { + var session = new AnonymousViewerSession(clock, context.getBean(AccountDirectory.class)); + session.identify(name, adminCode); + return new PollPresenter(context.getBean(PollService.class), + context.getBean(InviteeSearch.class), session, clock, AccessMode.ANONYMOUS); + } + + /** A sent poll with one day on the table, called by somebody with that name. */ + private UUID pollCalledBy(String organizer, String title) { + identifyAs(organizer, ""); + return pollCalledBy(presenter(), title); + } + + private UUID pollCalledBy(PollPresenter organizer, String title) { + organizer.draft().reset(); + organizer.draft().rename(title); + var id = organizer.createFromDraft(); + organizer.chooseDays(id, Set.of(Sample.mondayAfterNext(organizer.today()))); + organizer.send(id); + return id; + } + + private LocalDate onlyDayOf(UUID id) { + return pollOf(id).candidateDays().getFirst(); + } + + private Poll pollOf(UUID id) { + return presenter().poll(id).orElseThrow(); + } + + // ---- Reaching into the screen ---- + + /** The who-are-you screen's two fields, in the order it draws them: name, then code. */ + private void type(int index, String value) { + componentsOf(currentView()) + .filter(TextField.class::isInstance) + .map(TextField.class::cast) + .toList() + .get(index) + .setValue(value); + } + + private void click(String label) { + var button = componentsOf(currentView()) + .filter(Button.class::isInstance) + .map(Button.class::cast) + .filter(candidate -> label.equals(candidate.getText())) + .findFirst() + .orElseThrow(() -> new AssertionError("No button labelled " + label + " on " + + currentView().getClass().getSimpleName())); + ComponentUtil.fireEvent(button, new ClickEvent<>(button)); + } + + private void navigateTo(Class view, UUID id) { + UI.getCurrent().navigate(view, new RouteParameters("id", id.toString())); + } + + private PollPresenter presenter() { + return context.getBean(PollPresenter.class); + } + + private String translation(String key, Object... arguments) { + return UI.getCurrent().getTranslation(key, arguments); + } + + private DayBallot ballotField() { + return componentsOf(currentView()) + .filter(DayBallot.class::isInstance) + .map(DayBallot.class::cast) + .findFirst() + .orElseThrow(() -> new AssertionError("No ballot field on " + + currentView().getClass().getSimpleName())); + } + + private Component currentView() { + return (Component) UI.getCurrent().getInternals().getActiveRouterTargetsChain().getFirst(); + } + + private String textOf(Component root) { + return componentsOf(root) + .map(this::ownText) + .filter(text -> !text.isBlank()) + .reduce("", (all, text) -> all + text + "\n"); + } + + private String ownText(Component component) { + var text = component instanceof HasText hasText ? hasText.getText() : ""; + return text == null ? "" : text; + } + + private Stream componentsOf(Component root) { + return Stream.concat(Stream.of(root), root.getChildren().flatMap(this::componentsOf)); + } +} diff --git a/src/test/java/io/binarycodes/whichday/poll/ui/view/PollJourneyTest.java b/src/test/java/io/binarycodes/whichday/poll/ui/view/PollJourneyTest.java index 7c08450..a1c411a 100644 --- a/src/test/java/io/binarycodes/whichday/poll/ui/view/PollJourneyTest.java +++ b/src/test/java/io/binarycodes/whichday/poll/ui/view/PollJourneyTest.java @@ -45,10 +45,12 @@ import io.binarycodes.whichday.people.domain.Person; import io.binarycodes.whichday.people.service.AccountDirectory; import io.binarycodes.whichday.people.ui.AccountMenu; +import io.binarycodes.whichday.people.ui.PersonAvatar; import io.binarycodes.whichday.poll.domain.PollState; import io.binarycodes.whichday.poll.domain.PollSummary; import io.binarycodes.whichday.poll.service.PollService; import io.binarycodes.whichday.poll.ui.component.DayBallot; +import io.binarycodes.whichday.poll.ui.component.DayChoice; import io.binarycodes.whichday.poll.ui.component.DayPoster; import io.binarycodes.whichday.poll.ui.component.MonthCalendar; import io.binarycodes.whichday.poll.ui.presenter.PollPresenter; @@ -398,7 +400,7 @@ void theCalendarOffersWeekends() { assertThat(offered).noneMatch(day -> day.isBefore(presenter().today())); calendarField().setValue(Set.of(saturday)); - click(translation("days.send")); + click(translation("days.next")); assertThat(presenter().poll(id).orElseThrow().candidateDays()).containsExactly(saturday); } @@ -421,6 +423,90 @@ private int weekdayIn(YearMonth month) { return month.atDay(1).getDayOfWeek().getValue() <= 5 ? 1 : 3; } + /** + * The organizer has to be able to settle a tie, and to settle it on any of the tied + * days. The screen used to offer one button for whichever tied day happened to be + * earliest, which made the others unreachable and called that one the most popular. + */ + @Test + @DisplayName("offers every tied day to lock, rather than picking the earliest") + void aTieIsTheOrganizersToBreak() { + var id = draftPoll("Roadmap workshop"); + var monday = Sample.mondayAfterNext(presenter().today()); + var tuesday = monday.plusDays(1); + presenter().chooseDays(id, Set.of(monday, tuesday)); + presenter().send(id); + StubIdentity.signIn(Sample.MIRO); + presenter().vote(id, Set.of(monday, tuesday)); + StubIdentity.signIn(Sample.ADA); + + navigateToPoll(ResultsView.class, id); + var screen = textOf(currentView()); + + assertThat(screen).contains(translation("results.tied", 2)); + assertThat(screen).doesNotContain(translation("results.lock", DateText.compact(currentView(), monday))); + + click(translation("results.tied.action")); + assertThat(currentView()).isInstanceOf(SettleView.class); + + // Nothing is chosen for the organizer, so confirming without picking asks again. + click(translation("settle.confirm")); + assertThat(presenter().poll(id).orElseThrow().lockedDay()).isNull(); + + dayChoice().setValue(tuesday); + click(translation("settle.confirm")); + + assertThat(presenter().poll(id).orElseThrow().lockedDay()).isEqualTo(tuesday); + assertThat(currentView()).isInstanceOf(LockedView.class); + } + + /** + * Faces, not names. An account's initials are stable and were settled before the + * poll existed, so they identify somebody here — which is what anonymous mode + * cannot say, and why {@code AnonymousPollJourneyTest.theBallotNamesItsVoters} + * expects the opposite. + */ + @Test + @DisplayName("shows a face for whoever voted for a day, not their name") + void theBallotShowsFaces() { + var id = draftPoll("Roadmap workshop"); + var monday = Sample.mondayAfterNext(presenter().today()); + var tuesday = monday.plusDays(1); + presenter().chooseDays(id, Set.of(monday, tuesday)); + presenter().send(id); + + // Faces are for a day that is not in front — the leader gets words instead — so + // Tuesday needs a voter and fewer of them than Monday. + StubIdentity.signIn(Sample.MIRO); + presenter().vote(id, Set.of(monday, tuesday)); + StubIdentity.signIn(Sample.ADA); + presenter().vote(id, Set.of(monday)); + + navigateToPoll(BallotView.class, id); + + // The field, not the screen: the header carries the viewer's own avatar either way. + assertThat(componentsOf(ballotField()).filter(PersonAvatar.class::isInstance)).isNotEmpty(); + assertThat(textOf(ballotField())).doesNotContain(Sample.MIRO.firstName()); + } + + /** + * The share screen hands out a link and a way to mail it, and nothing else. It used + * to offer the candidate days as a calendar file too — a TENTATIVE entry per maybe, + * downloaded before anybody had answered — and that is what this pins as gone. The + * settled day still gets one, on the locked screen. + */ + @Test + @DisplayName("offers the link and a message, and no calendar file for days nobody has picked") + void theShareScreenOffersNoCalendarFile() { + var id = openPoll("Roadmap workshop"); + navigateToPoll(ShareView.class, id); + + var screen = textOf(currentView()); + + assertThat(screen).contains(translation("share.message")); + assertThat(screen).doesNotContain("Calendar"); + } + @Test @DisplayName("choosing days on the calendar and sending opens the poll") void choosingDaysAndSending() { @@ -429,7 +515,7 @@ void choosingDaysAndSending() { var monday = Sample.mondayAfterNext(presenter().today()); calendarField().setValue(Set.of(monday)); - click("Send to the team"); + click(translation("days.next")); assertThat(currentView()).isInstanceOf(ShareView.class); assertThat(presenter().poll(id).orElseThrow().candidateDays()).containsExactly(monday); @@ -446,7 +532,7 @@ void sendingAnEmptyPoll() { var id = draftPoll("Roadmap workshop"); navigateToPoll(CandidateDaysView.class, id); - click("Send to the team"); + click(translation("days.next")); assertThat(currentView()).isInstanceOf(CandidateDaysView.class); assertThat(presenter().poll(id).orElseThrow().candidateDays()).isEmpty(); @@ -580,18 +666,52 @@ void decliningReachesTheOrganizer() { assertThat(presenter().poll(offsite).orElseThrow().candidateDays()).contains(proposed); } + /** + * Two taps, on purpose. Locking is final, so the standings only lead to the screen + * that says so — nothing is settled by the button the organizer came to read past. + */ @Test - @DisplayName("locking from the results screen hands over to the locked date") + @DisplayName("locking goes through the settle screen and hands over to the locked date") void lockingFromTheResults() { navigateToPoll(ResultsView.class, offsite); var leader = presenter().poll(offsite).orElseThrow().leader().orElseThrow().day(); clickStartingWith("Lock in"); + assertThat(currentView()).isInstanceOf(SettleView.class); + assertThat(presenter().poll(offsite).orElseThrow().lockedDay()).isNull(); + assertThat(textOf(currentView())).contains(translation("settle.warning")); + + click(translation("settle.confirm")); + assertThat(presenter().poll(offsite).orElseThrow().lockedDay()).isEqualTo(leader); assertThat(currentView()).isInstanceOf(LockedView.class); } + @Test + @DisplayName("cancelling the settle screen leaves the poll open") + void cancellingLeavesThePollOpen() { + navigateToPoll(ResultsView.class, offsite); + clickStartingWith("Lock in"); + + click(translation("settle.cancel")); + + assertThat(currentView()).isInstanceOf(ResultsView.class); + assertThat(presenter().poll(offsite).orElseThrow().lockedDay()).isNull(); + assertThat(presenter().poll(offsite).orElseThrow().isOpen()).isTrue(); + } + + /** The screen settles polls; a link to it for somebody else's is not a way in. */ + @Test + @DisplayName("turns an invitee away from the settle screen") + void onlyTheOrganizerMaySettle() { + StubIdentity.signIn(Sample.MIRO); + + navigateToPoll(SettleView.class, offsite); + + assertThat(currentView()).isInstanceOf(ResultsView.class); + } + @Test @DisplayName("sends a settled poll's results screen straight to the locked date") void aSettledPollHasNoStandings() { @@ -875,6 +995,15 @@ private MonthCalendar calendarField() { return proposalCalendar(); } + private DayChoice dayChoice() { + return componentsOf(currentView()) + .filter(DayChoice.class::isInstance) + .map(DayChoice.class::cast) + .findFirst() + .orElseThrow(() -> new AssertionError("No day choice on " + + currentView().getClass().getSimpleName())); + } + private DayBallot ballotField() { return componentsOf(currentView()) .filter(DayBallot.class::isInstance) @@ -927,8 +1056,8 @@ private void clickHome() { ComponentUtil.fireEvent(home, new ClickEvent<>(home)); } - private String translation(String key) { - return UI.getCurrent().getTranslation(key); + private String translation(String key, Object... arguments) { + return UI.getCurrent().getTranslation(key, arguments); } private Button buttonLabelled(String label) { diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties index 85a58a2..55035a0 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -1,4 +1,9 @@ -# The profile every Spring test names. +# The profile every Spring test names, after the access mode's own. +# +# The OIDC keys below are login mode's, and an anonymous-profile test inherits them: +# it builds a registration nothing in that mode injects. Harmless, and cheaper than a +# third profile — the alternative would be a src/test/resources/application-login.properties, +# which shares a name with the main one and would shadow it wholesale. # # A profile can override a key but never remove one, and Boot resolves an issuer by # fetching its discovery document at startup — so a test that inherited From afe321e067888f2b391722866d1faf3466ed4998 Mon Sep 17 00:00:00 2001 From: Sujoy Das Date: Wed, 26 Aug 2026 11:59:05 +0300 Subject: [PATCH 3/3] docs: rewrite the conventions around Whichday instead of the project they were copied from --- CLAUDE.md | 32 +++++++---- CODING_CONVENTIONS.md | 129 +++++++++++++++++++++++------------------- 2 files changed, 90 insertions(+), 71 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3bc532d..1402a10 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,7 @@ **Before writing or editing any code in this repository, read [`CODING_CONVENTIONS.md`](./CODING_CONVENTIONS.md) and follow every rule in it.** The conventions cover naming, comments, code structure, Java idioms, -Vaadin form bindings, CSS organisation, persistence patterns, and CI. +Vaadin, CSS organisation, i18n, persistence, access control and CI. In particular, recurring expectations: @@ -13,23 +13,31 @@ In particular, recurring expectations: names anywhere (variables, parameters, lambda captures, loop indices). - Add a comment **only** when the code is not self-explanatory; explain *why*, never *what*. No section-divider comments. -- Keep view / orchestrator classes thin. Extract forms, charts, grids, and - other distinct concerns into their own component classes with a focused - public API. +- Keep view classes thin. Anything with a shape of its own — a calendar, a + ballot, a row — is its own component class with a focused public API. A + view builds in `beforeEnter`, never in its constructor. +- No user-facing string literals in code: every one is a key in + `vaadin-i18n/translations.properties`, resolved through `getTranslation`. + A label has to be true — a button that navigates does not say "send". - Colors live in `colors.css` as semantic tokens. Component CSS files reference them through `var(--color-…)`; never inline raw hex / rgb / named colors. -- Form bindings go through `Binder` with `bind(getter, setter)` and - appropriate validators. Use `ValueChangeMode.LAZY` for live recalculation. -- Mutable beans use Lombok (`@Getter @Setter @NoArgsConstructor - @AllArgsConstructor`). Spring Boot 4 / Jackson 3 imports come from - `tools.jackson.*`. +- Immutable records are the default. JPA entities take **no** Lombok — see + §4 for why, and for the one exception. +- **Two access modes.** `WHICHDAY_ACCESS_MODE` picks `login` or `anonymous` + at deploy time, and a change to one screen usually has to answer for + both. Who may do what is decided in `PollService`, never by which button + a screen draws. +- Build and test through `./run.sh `. Never invoke `mvn` directly and + never set `JAVA_HOME` by hand. If a request would violate a convention, push back and propose the convention-compliant alternative before proceeding. ## When in doubt -Re-read `CODING_CONVENTIONS.md` first. If a situation isn't covered, match -the style of the surrounding code and propose adding the new rule to the -conventions file. +Re-read `CODING_CONVENTIONS.md` first. `docs/REQUIREMENTS.md` records what +the application has to do and why each decision was taken; `docs/issues/` +records what is still wrong. If a situation isn't covered, match the style +of the surrounding code and propose adding the new rule to the conventions +file. diff --git a/CODING_CONVENTIONS.md b/CODING_CONVENTIONS.md index 4330131..083383b 100644 --- a/CODING_CONVENTIONS.md +++ b/CODING_CONVENTIONS.md @@ -2,9 +2,13 @@ Project-wide rules. Once a pattern is established here, follow it without prompting. +Everything here describes Whichday as it is. A rule that names a class, a file or a +property is one you can go and check, and a rule that stops being true is a bug in this +file. + ## 1. Naming -- Full, meaningful identifiers everywhere (variables, params, lambda captures, locals). No abbreviations beyond well-known ones (`irr` ok, `infl` not). +- Full, meaningful identifiers everywhere (variables, params, lambda captures, locals). No abbreviations beyond well-known ones — `id` and `uuid` are fine, `org` for organizer is not. - Methods named for what they do, not how. - Lambda parameters get real names; a genuinely unused one is `ignored` / `unused`. - Constants replace magic numbers. @@ -18,111 +22,117 @@ Project-wide rules. Once a pattern is established here, follow it without prompt ## 3. Code structure -- One concern per class. Extract the form, each chart (its own class), and the grid; the view stays thin — composition, state transitions, and the one method that ties them together. -- Components extend the closest Vaadin primitive (`Card`, `Grid`, `Chart`, `VerticalLayout`) and expose a focused API (`setX` / `update` / `getInputs` / `addXChangeListener`). Stop extending it when its shadow layout fights the design: `Card` positions its own title/suffix slots, so a row that needs a trailing element pinned while the title truncates is a plain flex layout instead. -- `Card` implements neither `HasStyle` nor `HasTooltip`. Reach for `getElement().getClassList()`, and don't expect `addClassName` / `getStyle` on it. Same for `Markdown`. -- Vaadin `Button` takes an icon and a label and nothing else — `add()` is private. A control that needs three pieces of content (a color dot, a name, a count) is a `NativeButton` with `aria-pressed`. +- One concern per class. A view composes named components and holds the one method that ties them together; anything with a shape of its own — a calendar, a ballot, a tally list, a row — is its own class under the feature's `ui/component/` or `ui/` package with a focused API. +- Screens extend `Screen`, which owns the column and the pinned footer, and add content through `body(...)` / `footer(...)`. A screen about one poll extends `PollScreen`, which parses the route id, turns unknown ids away, and gives `render()` for rebuilding after a change. +- **A view builds in `beforeEnter`, not in its constructor.** Vaadin reuses a view instance when the route it is asked for is the one already showing, so a constructor-only build leaves whatever it drew the first time. +- **Turning a navigation away happens in `PollScreen.redirect`**, which forwards before anything is built, so the browser sees one navigation and the abandoned screen is never rendered. Navigating from inside the event instead leaves a half-built screen behind it. +- Components extend the closest Vaadin primitive and expose a focused API (`setValue` / `show` / `withAction` / `addValueChangeListener`). Stop extending it when its shadow layout fights the design. +- Vaadin `Button` takes an icon and a label and nothing else — `add()` is private. A control that needs three pieces of content (a date numeral, a weekday, a vote count) is a `NativeButton`, with `aria-pressed` when it is a toggle. +- A menu on a bare avatar is a `ContextMenu`, not a `MenuBar`: a menu bar brings a button and an overflow arrow the design does not draw, and suppressing both means styling into its shadow root. +- **An irreversible action gets a screen, not a dialog.** There is no dialog anywhere in this application, and locking a day — final: no more answers, no different day, nothing to undo — is confirmed on `SettleView` instead. A screen can say what is about to happen, carry the choice a tie still needs, and be cancelled with the back chevron every other screen has. A one-tap commit on a screen somebody came to *read* is the shape to avoid. - Helpers live with the thing they help. -- Data grids extend `Grid` directly. Introduce a column-chooser wrapper only when a projection grid actually needs one. - Prefer a named private method over a long inline block. ## 4. Java -- Records for immutable value types; mutable Lombok beans where a framework demands one — `Binder` inputs and JPA entities — with null-defaulted fields. -- Lombok `@Getter @Setter @NoArgsConstructor @AllArgsConstructor`, declared `true`. -- Jackson 3 — import from `tools.jackson.*`; build mappers via `JsonMapper.builder().build()`. -- Don't fabricate fallback defaults in form-read paths; surface empties through binder validation. -- `BigDecimal` for money — `MathContext.DECIMAL64` for arithmetic, `RoundingMode.HALF_UP` for display. +- Records for immutable value types. Every type that crosses out of a service is one — `Poll`, `Ballot`, `PollSummary`, `Person`, `Caller` — built on the spot, so a screen holds a snapshot rather than a view into the store. +- **JPA entities take no Lombok.** Hibernate wants a non-final class with a no-arg constructor and reads fields directly; it does not want accessors. Generated ones would put a `setOpenedAt` next to `StoredPoll.closeOn`, which stamps once, and a `setCandidateDays` next to `replaceCandidateDays`, which prunes the ballots as it goes. Those are rules, not boilerplate. `StoredBallotKey` is the one exception — an `@IdClass` is genuinely nothing but fields — and it carries `@Getter @Setter @NoArgsConstructor @AllArgsConstructor`. Lombok is declared `true`. - `var` when the right-hand side makes the type obvious. - Multi-line strings use text blocks with `.formatted(...)`, never `+`. +- **A startup failure says what to set.** The house idiom is a text-block constant and an `IllegalStateException` naming the environment variable — `LoginSecurityConfig.MISCONFIGURED`, `AccessMode.named`. An unresolved `${...}` placeholder binds as the literal string rather than failing, so `value.contains("${")` is how "an environment variable nobody set" is spelled. -## 5. Vaadin / form binding +## 5. Vaadin -- Bind every field through `Binder` with explicit `bind(getter, setter)`. -- Validators where they belong: `asRequired`, the range validators, `withValidator` for cross-field rules (re-trigger via the dependent field's value-change listener). -- `ValueChangeMode.LAZY` on number/text inputs. `CustomField` wrappers propagate inner-field changes with `updateValue()`. -- `binder.writeBeanAsDraft(target)` for possibly-invalid reads; pair with `isValid()` / `validate()`. -- Use semantic components: `RadioButtonGroup`, `Badge`, `Card` with the `status` attribute. -- Composite widgets extend `CustomField` (single value) or a `Card` subclass. +- A composite input extends `CustomField` and propagates its inner changes with `updateValue()`. `MonthCalendar` and `DayBallot` are both `CustomField>`, which is what lets a screen treat "the days chosen" as one value with one listener. +- `ValueChangeMode.LAZY` on text inputs that drive live work: a search that fires per keystroke, a draft field that writes as you type. +- Presenters are `@VaadinSessionScope`, and they are the only session-scoped beans. They pair the shared store with the one thing that is per-session — who is looking — so no view passes a viewer into a service call. +- **Every message goes through `Toast`, never `Notification.show`.** Vaadin's default puts a notification bottom-left for five seconds, which on a phone is exactly where the primary action is — so a message about a button sat on top of it. `Toast` moves it to the top and keeps the five seconds. +- **A Vaadin component that paints itself in its own shadow root is styled through `::part(...)`, not the host.** The notification card is the example: styling the host as well drew a second card around the first. +- Screens read every user-visible string through `getTranslation` (§7) and every colour through a token (§6). ## 6. CSS -- One file per concern; the entry stylesheet holds only `@import` lines. +- One file per concern; the entry stylesheet (`styles.css`) holds only `@import` lines, so touching it is what busts the browser cache for a changed partial — which is what `./run.sh styles` does. - Colors are semantic tokens in `colors.css`, referenced via `var(--color-…)`. No raw hex / rgb / `color-mix` / named colors elsewhere. - Light and dark are Aura's `color-scheme`, not a class on `html`. Own tokens adapt with the CSS `light-dark()` function; the scheme is set from Java with `Page::setColorScheme`. There is no `html.dark` selector. -- Prefer Vaadin / theme variables over hard-coded values: `--vaadin-*` base properties (`--vaadin-text-color`, `--vaadin-border-color`, `--vaadin-radius-m`, `--vaadin-gap-s`) and `--aura-*` for what only Aura defines (`--aura-accent-color`, `--aura-surface-color`, `--aura-font-size-s`, `--aura-shadow-xs`). +- Prefer Vaadin / theme variables over hard-coded values: `--vaadin-*` base properties (`--vaadin-text-color`, `--vaadin-border-color`, `--vaadin-radius-m`, `--vaadin-gap-s`) and `--aura-*` for what only Aura defines (`--aura-accent-color`, `--aura-font-weight-medium`, `--aura-shadow-xs`). - Style Vaadin components through their own custom properties (`--vaadin-button-background`) rather than `background` on the host, which the component's base styles win against. - `box-sizing: border-box` is set globally in `shell.css`. Padded full-width containers otherwise overflow their parent at phone widths. +- **The stylesheet's partials need their own `permitAll` in login mode.** Vaadin permits the resources it knows about, which includes the `@StyleSheet` entry point but not the files that entry point `@import`s. Left authenticated they redirect to the provider, and a 302 carries no content type, so the browser refuses every one of them as "not a supported stylesheet MIME type" and the application renders unstyled. - One-paragraph header comment per file; no section dividers. ## 7. Internationalization -- No user-facing string literals in code. Every label, title, hint, placeholder, tooltip, validation message, grid header, chart title/axis/series, notification, and aria-label is a key in `src/main/resources/vaadin-i18n/translations.properties`. -- Resolve via `getTranslation(key, args…)` on a `Component`. A non-component helper takes the calling `Component` and resolves through it rather than reaching for a static lookup. -- Reuse and parameterise shared keys; namespace them by feature (`library.*`, `reader.*`, `save.*`, `highlights.*`, `bookmark.*`). -- Counts that can be one carry both forms (`library.count.one` / `library.count.many`) and the code picks on the number — `MessageFormat` alone cannot. -- Enums own their key (`SortMode::translationKey`) so nothing switches on a label. Page titles come from `HasDynamicTitle`. -- Never `switch` on a translated string; switch on an enum or index. +- No user-facing string literals in code. Every label, title, hint, placeholder, tooltip, validation message, notification and aria-label is a key in `src/main/resources/vaadin-i18n/translations.properties`. +- Resolve via `getTranslation(key, args…)` on a `Component`. A non-component helper takes the calling `Component` and resolves through it rather than reaching for a static lookup — `Counts`, `DateText`, `AccountLabels`, `MailLink`. +- Reuse and parameterise shared keys; namespace them by screen or concern (`create.*`, `days.*`, `share.*`, `ballot.*`, `results.*`, `polls.*`, `identity.*`, `nav.*`, `count.*`). +- Counts that can be one carry both forms (`count.days.one` / `count.days.many`) and the code picks on the number — `MessageFormat` alone cannot. The choice is made once in a helper, not at every call site. +- **Where a mode changes the wording, the branch lives in one helper, not in the screens.** `Counts.progress(…, anonymous)` and `AccountLabels.of(…, anonymous)` are the shape: the component is handed what it should say and does not learn which mode it is in. +- Page titles come from `HasDynamicTitle`. Never `switch` on a translated string; switch on an enum. +- **A value that takes arguments doubles its apostrophes; one that takes none leaves them single.** Vaadin runs a value through `MessageFormat` only when the call passes arguments, and MessageFormat treats `'` as a quoting character — so `results.nudge` needs `hasn''t` and `identity.title` must not. Neither mistake fails a build, so `TranslationsTest` guards both. ## 8. Responsive layout -- Every form and result is usable from ~375px to wide desktop with no horizontal overflow. -- Field grids use `FormLayout` with responsive steps, collapsing to one column. -- Repeating rows add the `form-row` class; summary-card rows add `summary-row`. +- **Mobile first, and the design is drawn as 390×844 phone frames.** `.app-shell` centres one column capped at `--screen-max-width` against Aura's page background rather than letting a phone screen stretch across a desktop. A screen fills the viewport so that a footer pinned with `margin-top: auto` sits at the bottom of the phone rather than under the last paragraph. - Containers fill width (`setWidthFull()`); no fixed pixel widths. +- No horizontal overflow at any width from ~375px up. - Verify at mobile and desktop widths before declaring done. ## 9. Project layout -- Feature-based packaging: `base/` for shared infrastructure, feature packages with their own `domain/` / `service/` / `ui/`, plus `ui/presenter/` for the session-scoped classes the screens talk to. +- Feature-based packaging: `base/` for shared infrastructure, feature packages (`people/`, `poll/`) with their own `domain/` / `service/` / `ui/`, plus `ui/presenter/` for the session-scoped classes the screens talk to. - Dependencies run one way: `ui` → `ui/presenter` → `service` → storage, never backwards. A service imports no Vaadin: a change listener is how a component learns to redraw itself, so listeners and the orchestration around them belong to the presenter, which is also the only layer that needs to be session-scoped. - One public class per file; helpers stay private static unless reused. ## 10. Persistence -- PostgreSQL through Spring Data JPA. Flyway owns the schema (`db/migration/V*.sql`) and `spring.jpa.hibernate.ddl-auto=validate` fails startup on drift rather than rewriting it. -- **Entities, projections and repositories are package-private and never leave the service package.** The service translates them to records before anything above sees them — which keeps the domain immutable, and keeps every entity inside the transaction that loaded it, so a stateful UI framework never meets a detached one. A view that tries to import an entity does not compile; that is the point. -- Mutable JPA entities take the same Lombok treatment as `Binder` beans (see §4). -- Every table carries `owner_id` and every query is scoped by it. Accounts arrived exactly that way — `LibraryOwner.current()` returns the authenticated OIDC subject and no migration or query changed. It refuses rather than defaulting when nobody is signed in: a fallback owner would put an unauthenticated path back to writing into one shared library. -- `byte[]`, never `@Lob`: on PostgreSQL, Hibernate maps `@Lob byte[]` to an `oid` large object rather than `bytea`. -- Browser storage (`BrowserStorage`) is only for what describes this browser rather than the library — the light/dark choice, and reading what an older version left behind. +- **Embedded H2, one file, `MODE=PostgreSQL`.** This is the one place the project departs from a server database, and it is a decision rather than an omission: the point is one container with nothing else to bring up. `docs/REQUIREMENTS.md` §9 is the record. `MODE=PostgreSQL` and portable migrations keep it from being a one-way door — `V1` is SQL a real PostgreSQL runs unchanged, so moving is a URL and a dependency rather than a rewrite. +- Flyway owns the schema (`db/migration/V*.sql`) and `spring.jpa.hibernate.ddl-auto=validate` fails startup on drift rather than rewriting it. **A migration is immutable once written** — editing even a comment changes its checksum — so `V1`'s comments point at files that no longer exist and stay wrong on purpose. +- **Entities and repositories are package-private and never leave the service package.** The service translates them to records before anything above sees them, which keeps the domain immutable and keeps every entity inside the transaction that loaded it, so a stateful UI framework never meets a detached one. A view that tries to import an entity does not compile; that is the point. +- **A person is an email address.** `poll.organizer_email`, `poll_invitee.email`, `ballot.voter_email` — there is no user foreign key anywhere. The one place a name lives is the `account` table, so an invitee who has never signed in needs no row and one who signs in later is named everywhere at once. `PollService.addressOf` normalises on the way in, because an address written in mixed case is a row nothing can find again. +- **Readers go through `require`; writers go through `requireForUpdate`**, which takes a row lock held until the transaction commits. That pairing replaced a `synchronized` on every method: a monitor inside a transactional proxy is released before the commit, so it reads like a guarantee and is not one. +- `spring.jpa.open-in-view=false`. A screen holds a record and never an entity, so a lazy read outside a transaction is a bug, and this is what makes it fail loudly instead of hiding until the next thing moves. +- A path-like setting names the directory, not the whole URL: `WHICHDAY_DATA_DIR`, so moving the database file cannot drop `MODE=PostgreSQL` on the way past. ## 10a. Security headers -- Spring Security writes its headers as the response commits, and the response Vaadin renders a page into never commits that way — so the application's own routes come out with no headers at all while static resources get the full set. `SecurityConfig` fixes this with an `ObjectPostProcessor` calling `setShouldWriteHeadersEagerly(true)`. Verify a header change by curling an application route (`/`, `/read/…`), never only a static file. -- Headers belong in `SecurityConfig`, not a parallel servlet filter — one source of truth, and Spring Security keeps the conditional ones (HSTS only over HTTPS) conditional. +- Spring Security writes its headers as the response commits, and the response Vaadin renders a page into never commits that way — so the application's own routes come out with no headers at all while static resources get the full set. `SecurityHeadersConfig` fixes this with an `ObjectPostProcessor` calling `setShouldWriteHeadersEagerly(true)`. Verify a header change by curling an application route (`/`, `/poll/…`), never only a static file. +- Headers are configured once, in `SecurityHeadersConfig`, and not in either mode's chain — they are the same whichever way a deployment lets people in — and not in a parallel servlet filter either, so Spring Security keeps the conditional ones (HSTS only over HTTPS) conditional. - HSTS only goes out when the app believes the request was secure, and behind a TLS-terminating proxy it only ever sees plain HTTP. `FORWARD_HEADERS_STRATEGY=native` opts into trusting the proxy's `X-Forwarded-*` headers; it stays unset by default because those headers are client-supplied and spoofable with nothing in front. ## 10b. Access control -- Login is mandatory. `SecurityConfig` leaves `VaadinSecurityConfigurer` on its defaults and points `oauth2LoginPage` at the OIDC registration, so an unauthenticated request redirects to the identity provider rather than to a Harbor login view — Harbor collects no credentials of its own. -- **No product name appears in `src/main`.** The registration is called `oidc`, not after whatever the development stack happens to run, because Spring builds `/oauth2/authorization/oidc` and the `/login/oauth2/code/oidc` callback from that id — so a vendor named there would end up in Harbor's URLs and in every deployment's provider configuration. Harbor needs discovery, the authorization-code flow, an id token and RP-initiated logout, and nothing beyond them. Keycloak belongs to `environment/dev/` and to the test fixtures only. -- **A route with no access annotation is denied, and so is its parent layout's.** Navigation access control is on, so every `@Route` carries `@PermitAll` — and so does `MainLayout`. `AnnotatedViewAccessChecker` checks each parent layout a route names before it checks the route, and an unannotated layout denies everything behind it: the symptom is a `RouteNotFoundError` and a log line about the view allowing "broader access than the layout". This applies to a layout referenced as `layout = MainLayout.class`, not only to one annotated `@Layout`. "Authenticated" is the whole authorization model — there are no roles. -- A logout that only drops Harbor's session leaves the provider's intact and the next visit signs straight back in. `OidcClientInitiatedLogoutSuccessHandler` is what makes the button mean what it says. -- `AuthenticationContext` is the presenter-shaped API for who is signed in and for logging out, so a component may take it directly rather than through a Harbor presenter (`AccountFooter`) — the one sanctioned exception to §9, because a wrapper would hold no logic. -- **The issuer the app validates against must be the issuer the browser was sent to.** They agree by accident in development, where both are `localhost:8081`, and disagree in a container deployment, where the browser sees a public URL and the app sees a service name. The symptom is a redirect loop that names nothing; the fix is `KC_HOSTNAME` and `HARBOR_OIDC_ISSUER_URI` set to the same public URL. +- **There are two ways in and a deployment picks one**, with `WHICHDAY_ACCESS_MODE`, defaulting to `anonymous`. The variable names a Spring profile outright (`application-login.properties`, `application-anonymous.properties`), and each of those files sets `whichday.access.mode` — which is what the code keys `@ConditionalOnProperty` on, never `@Profile`, so a test stays free to compose `@ActiveProfiles`. One `SecurityFilterChain` per mode, one `ViewerSession` per mode, and `AccessMode` as a bean for the handful of places that genuinely branch. `docs/REQUIREMENTS.md` §1 is the record. +- **The OIDC block lives in the login profile's file, and that is load-bearing.** Boot resolves an issuer by fetching its discovery document at startup, so an anonymous deployment that inherited `issuer-uri` from `application.properties` would hang on a provider it has no reason to reach. Blanking it is not the same thing — that fails as "issuer cannot be empty". The key has to be genuinely absent. +- **No product name appears in `src/main`.** The registration is called `oidc`, not after whatever the development stack happens to run, because Spring builds `/oauth2/authorization/oidc` and the `/login/oauth2/code/oidc` callback from that id — so a vendor named there would end up in the application's own URLs and in every deployment's configuration. Whichday needs discovery, the authorization-code flow, an id token and RP-initiated logout, and nothing beyond them. Keycloak belongs to `environment/dev/` and to the test fixtures only. +- In `login` mode, `LoginSecurityConfig` leaves `VaadinSecurityConfigurer` on its defaults and points `oauth2LoginPage` at the registration, so an unauthenticated request redirects to the provider rather than to a login view of ours — the application collects no credentials and never sees one. It refuses to start without a client id and secret. +- **A route with no access annotation is denied, and so is its parent layout's.** In `login` mode navigation access control is on, so every `@Route` carries `@PermitAll` — and so does `MainLayout`. The checker looks at each parent layout a route names before it looks at the route, and an unannotated layout denies everything behind it: the symptom is a `RouteNotFoundError` and a log line about the view allowing "broader access than the layout". "Authenticated" is the whole authorization model — there are no roles. +- In `anonymous` mode, `AnonymousSecurityConfig` turns navigation access control **off** rather than changing every route to `@AnonymousAllowed`: Vaadin reads `@PermitAll` as *authenticated*, and nobody is, so leaving the checker on would refuse every screen. With it off, Vaadin's request rules can no longer classify a URL by which view it reaches, so the `permitAll` goes in an explicit `authorizeHttpRequests` and `enableAuthorizedRequestsConfiguration(false)` stops the configurer adding a second — otherwise every navigation logs that it could not tell whether the URL was public. +- **Who may do what is decided in `PollService`, never by which button a screen draws.** The screens do hide what is not yours, and that is a courtesy; a hidden button is not a check. Anonymous mode branches in exactly three places there — the link stands in for an invitation on read, a voter joins the invitee list as they answer, and a six-digit admin code is a second way to be the organizer. The code travels with the call as `Caller`, so it is never read from the session inside the service. +- **A refusal says as much as the caller already knows and no more.** In login mode a stranger gets the same `IllegalArgumentException` an id nobody issued gets, because "you are not on the list" confirms there is a list, which confirms the poll; somebody already on the poll is refused by name, since they can see it and there is nothing left to withhold. Anonymous mode has two answers rather than three — anybody who reached the call is holding the link, and the link already showed them the poll. +- A logout that only drops our own session leaves the provider's intact and the next visit signs straight back in. `OidcClientInitiatedLogoutSuccessHandler` is what makes the button mean what it says. Anonymous mode has no provider to log out of, so it closes the Vaadin session instead. +- **`AuthenticationContext` stays behind `ViewerSession`.** It is Vaadin's presenter-shaped API for who is signed in, but it only answers login mode's version of the question — so the interface is the seam, and no component takes it directly. ## 11. Build & CI -- `./run.sh test` and `verify` export `DOCKER_HOST` from the active docker context when it is not already set. The CLI reads contexts and Testcontainers does not, so on Colima the CLI works while the tests report no Docker environment. -- `./run.sh env` brings up the whole development stack in one task and names no service: which containers Harbor needs is `environment/dev/compose.yaml`'s decision, so adding one there needs no change to `run.sh`. -- Run build / test / frontend tasks through `./run.sh ` (`deps`, `compile`, `bundle`, `styles`, `test`, `verify`, `run`, `package`, `clean`, `resetdb`), which pins JDK 21. Never invoke `mvn` directly. `./run.sh deps` warms the local repository ahead of a build; every other task resolves what it needs as it goes. After a `@CssImport(themeFor=…)` / `@JsModule` change run `./run.sh bundle`; after editing an `@import`-ed CSS partial run `./run.sh styles`. -- **`run.sh` is shared between projects as it stands and names none of them.** Whichday's answers live in `run.conf` — the project name, the JDK, `CONTAINER_REQUIRED="false"` because the polls live in an embedded H2 file and no test brings a container, the `it` profile and the `build.commit` property. Anything left out falls back to the default Vaadin layout, so the file names only what is a decision. A task the runner does not have goes in `run.tasks.sh` as a `task_` function, dispatched by name and listed under `help` through `project_usage`; `resetdb` is the only one. Keeping both out of `run.sh` is what lets this project take a newer runner without a merge — so a runner improvement is made upstream and copied, never patched here. -- `./run.sh verify` clears the cached bundles before building. A `dev.bundle` left by `./run.sh run` makes the frontend build report "a production mode bundle build is not needed", and the integration tests then open a page whose client bundle fails to boot. -- CI runs `mvn verify` on push (Temurin JDK 21). +- Run build / test / frontend tasks through `./run.sh ` (`deps`, `compile`, `bundle`, `styles`, `test`, `verify`, `run`, `preview`, `package`, `clean`, `resetdb`, `env`), which pins JDK 21 and resolves Maven from SDKMAN. **Never invoke `mvn` directly and never set `JAVA_HOME` by hand** — a bare `mvn` may pick JDK 25, under which Lombok silently generates no getters and the build dies with bogus "cannot find symbol" errors. `./run.sh deps` warms the local repository ahead of a build; every other task resolves what it needs as it goes. After a `@CssImport(themeFor=…)` / `@JsModule` change run `./run.sh bundle`; after editing an `@import`-ed CSS partial run `./run.sh styles`. +- `./run.sh env` brings up the development Keycloak, which **only login mode needs** — anonymous mode is the default and has no provider at all. Which containers the stack has is `environment/dev/`'s decision, so adding one there needs no change to `run.sh`. The stack is defined twice, `compose.yaml` under docker and `quadlet/` under podman, and keeping the two in step is manual. +- **`run.sh` is shared between projects as it stands and names none of them.** Whichday's answers live in `run.conf` — the project name, the JDK, `CONTAINER_REQUIRED="true"` because `env` brings up a container, the `it` profile and the `build.commit` property. Anything left out falls back to the default Vaadin layout, so the file names only what is a decision. A task the runner does not have goes in `run.tasks.sh` as a `task_` function, dispatched by name and listed under `help` through `project_usage`; `resetdb` is the only one. Keeping both out of `run.sh` is what lets this project take a newer runner without a merge — so a runner improvement is made upstream and copied, never patched here. +- `./run.sh verify` clears the cached bundles before building. A `dev.bundle` left by `./run.sh run` makes the frontend build report "a production mode bundle build is not needed", and a test then opens a page whose client bundle fails to boot. +- **Every build carries the commit SHA.** `maven-enforcer-plugin` rejects a build without `-Dbuild.commit=<7-40 hex>`, and `run.sh` resolves it from the working tree, so an unidentifiable artefact cannot be produced. CI passes `${{ github.sha }}` and runs `mvn verify` on push (Temurin JDK 21); on `main` it then builds, attests and signs the image. - **A `FROM` names its registry**: `docker.io/library/maven:…`, never a bare `maven:…`. Docker silently assumes Docker Hub; Podman refuses to guess, so a bare name fails the build on the first `FROM` with "short-name resolution enforced but cannot prompt without a TTY" wherever there is no terminal to answer — CI, a hook, an agent. - **A directory the container writes to is a declared `VOLUME`**, created and `chown`ed to the runtime user *before* the `VOLUME` line, since a build step writing to a declared volume's path is discarded. That ordering is what lets a fresh named volume come out owned by the right uid with nothing for the operator to fix, and the declaration is what stops the data landing in the image layer when nobody mounts anything. -- Tests live alongside the package they cover. Unit tests for the service layer, browserless tests for views, `*IT` Playwright tests for whole journeys. Both of the latter stub `MetadataResolver` so nothing reaches the network. Anything touching the library needs a real PostgreSQL — import `HarborDatabase` and empty the table in `@BeforeEach`, since one database now serves the whole suite. -- **Every Spring test names a profile.** `@ActiveProfiles("test")` for everything that must not reach a network, `@ActiveProfiles("journey")` for `HarborJourneyIT`, which authenticates for real. Both files carry what the context will not start without: the archiving browser (the application refuses to start with none configured) and, for the `test` profile, an OAuth2 client — without a `ClientRegistrationRepository`, `oauth2LoginPage(...)` fails while the filter chain is being built. +- **An environment variable a deployment must set appears in four places**: the property file that reads it, both README deployment samples — the compose block and the Quadlet block, which are maintained as a pair — and `CONTRIBUTING.md` when a local run needs it. +- Tests live alongside the package they cover: unit tests for the service layer, browserless tests for the views. `TestDatabase` empties every table in `@BeforeEach`, since one in-memory database serves the whole suite, and it reads the table list from `information_schema` so a new migration needs no change there. +- **Every Spring test names its access mode and then `test`, in that order.** `@WhichdayTest` is `{"login", "test"}` and `@AnonymousWhichdayTest` is `{"anonymous", "test"}`; the order is what lets `test` override what the mode's file brought. Two cached contexts, deliberately — the modes differ in which beans exist, so there is nothing to switch at runtime. Use the composed annotation rather than repeating its parts: the context cache is keyed on the configuration, so a class that drifts by one annotation quietly bootstraps a third context and pays for it. - *Profile* files deliberately, not `application.properties` under `src/test/resources`: that name shadows the main file wholesale and takes the datasource and Flyway settings with it. -- **A profile can override a key but never remove one, and an issuer beats explicit endpoints.** `application.properties` defaults `provider.keycloak.issuer-uri` to the development realm, and Boot resolves an issuer by fetching its discovery document at startup — so a test that inherits it hangs the whole context on `localhost:8081`. Blanking the value does not help either; an empty issuer fails as "issuer cannot be empty". The `test` profile therefore points the registration at a second provider (`offline`) whose endpoints cannot resolve, leaving the inherited issuer unreferenced. The registration id stays `keycloak`, because that is what builds the callback path and what `SecurityConfig` names. -- The tier that wants a real identity provider does not share that profile. `HarborJourneyIT` uses `journey`, which leaves the main OAuth2 config alone, and supplies the issuer and client through `@DynamicPropertySource` from the Keycloak it starts. Undoing another profile's settings is the shape to avoid. +- **A profile can override a key but never remove one, and an issuer beats explicit endpoints.** Boot resolves an issuer by fetching its discovery document at startup, so a test that inherited `provider.oidc.issuer-uri` would hang the whole context, and blanking it fails as "issuer cannot be empty". The `test` profile therefore points the registration at a second provider (`offline`) whose endpoints cannot resolve, leaving the inherited issuer unreferenced. The registration id stays `oidc`, because that is what builds the callback path. - **Verify a configuration change against the real property files.** An `ApplicationContextRunner` with no `@ActiveProfiles` and no `ConfigDataApplicationContextInitializer` loads none of them, so it will happily confirm a registration that the actual suite cannot build. Both of the OIDC startup failures in this repository's history got past exactly that kind of check. -- Identity is stubbed in every tier but one. Import `StubIdentityConfiguration` for a `@Primary LibraryOwner` whose reader is switchable — which is what makes owner isolation testable at all — and call its static `authenticate(…)` where the code under test goes through navigation access control: a browserless test bypasses the servlet filter chain but not `SpringNavigationAccessControl`. -- Two tests reach a real container of their own rather than stubbing. `BrowserPageArchiverTest` starts Chromium through `ArchivingBrowser`, and `-Dharbor.archive.browser-url=…` points it at your own browser instead where containers cannot run. `HarborJourneyIT` starts Keycloak through `HarborIdentity`, which creates its own realm over the admin REST API and hands Spring its own client id and secret, and drives the real login form. A test tier owns the data it needs and shares nothing with `environment/dev/` — the development realm is free to differ. That is the only place the OAuth2 redirect, the token exchange, the subject and the logout are exercised for real, and it makes the test a four-container one and the most fragile in the suite. -- JaCoCo `` for a `PACKAGE` rule take dot notation (`io.binarycodes.harbor.*.service`). Slash notation matches nothing and the gate silently passes. -- **Surefire and failsafe pass `@{argLine}`, and `argLine` has an empty default in ``.** JaCoCo's prepare-agent overwrites that property with the coverage agent, so anything the plugins add to `argLine` — the Mockito agent, currently — has to be appended late or it replaces the coverage agent and the gate then measures nothing. The empty default is what keeps `-Djacoco.skip=true` working: with no property to resolve, `@{argLine}` reaches the forked JVM literally and it refuses to start. After touching either argLine, check that `target/jacoco.exec` is still written and that the gate still *fails* for an under-covered run — a detached agent looks exactly like a passing build. +- **Identity is stubbed, never authenticated.** `StubIdentity` puts a real `OAuth2AuthenticationToken` into the security context rather than replacing `ViewerSession`, because a browserless test bypasses the servlet filter chain but not `SpringNavigationAccessControl`, and because going through the token means the application's own `AuthenticatedViewerSession` is what the tests exercise, claims and all. Anonymous tests need none of it: a second visitor there is a second `AnonymousViewerSession` with a presenter of its own, since an identity belongs to a Vaadin session and a test method has one. No tier starts a provider — `docs/issues/0006` records that the real token exchange is untested. - Session-scoped beans cannot be `@Autowired` into a browserless test's fields — the Vaadin session does not exist that early. Take the `ApplicationContext` and resolve them in `@BeforeEach`. +- JaCoCo holds `io.binarycodes.whichday.*.service` and `io.binarycodes.whichday.*.ui.presenter` at 80% instruction coverage, so new code there needs tests. `` for a `PACKAGE` rule take dot notation; slash notation matches nothing and the gate silently passes. +- **Surefire and failsafe pass `@{argLine}`, and `argLine` has an empty default in ``.** JaCoCo's prepare-agent overwrites that property with the coverage agent, so anything the plugins add to `argLine` — the Mockito agent, currently — has to be appended late or it replaces the coverage agent and the gate then measures nothing. The empty default is what keeps `-Djacoco.skip=true` working: with no property to resolve, `@{argLine}` reaches the forked JVM literally and it refuses to start. After touching either argLine, check that `target/jacoco.exec` is still written and that the gate still *fails* for an under-covered run — a detached agent looks exactly like a passing build. +- The compiler runs `-Xlint:all` with `-Werror`. A warning is a build failure, not a note. - Conventional Commits: `[(scope)][!]: `, type one of `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`. Subject ≤100 chars, single line, no body, no `Co-Authored-By`. A `commit-msg` hook in `.githooks/` enforces this; enable per clone with `git config core.hooksPath .githooks`. ## 12. Working principles @@ -130,4 +140,5 @@ Project-wide rules. Once a pattern is established here, follow it without prompt - Verify each change visually or by test before declaring done. - Preserve look-and-feel during refactors; a pixel change is a separate task. - Refactor in small focused steps — extract, rename, run tests, then move on. -- **The README describes the application as it is now**, not as it was or as it might become. No migration notes, no historical asides, no upgrade paths for a scenario an earlier version left behind. Behaviour the code still performs is current state and belongs there — the import of a library from browser storage, for instance, because `LegacyLibraryImport` still runs it on first open. Advice for data nobody has is not. When a feature changes or a concern is dropped, the README text that existed only to serve it goes in the same change. +- **The README describes the application as it is now**, not as it was or as it might become. No migration notes, no historical asides, no upgrade paths for a scenario an earlier version left behind. Behaviour the code still performs is current state and belongs there; advice for data nobody has is not. When a feature changes or a concern is dropped, the README text that existed only to serve it goes in the same change. +- **`docs/REQUIREMENTS.md` records what the application has to do and every decision taken getting there, with the reason.** `docs/issues/` is its companion: one file per known defect or gap, saying what is wrong, what it costs and what fixing it would take, and it goes when the fix lands. A consequence accepted on purpose belongs in the requirements; one nobody wants belongs in an issue.