mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-14 17:54:42 +00:00
Compare commits
24 Commits
180ed41e68
...
feat/auth-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5028a43f4b | ||
|
|
fffbe68dc2 | ||
|
|
e6240bc889 | ||
|
|
4a5abd4639 | ||
|
|
d02f31b55c | ||
|
|
e961ce4042 | ||
|
|
dfb67bcdfa | ||
|
|
0ecc9a830e | ||
|
|
5ac55b8be0 | ||
|
|
19791df6a5 | ||
|
|
0780349a79 | ||
|
|
c0cb013f54 | ||
|
|
dfe248ea5f | ||
|
|
ff775e3b8a | ||
|
|
0e51630439 | ||
|
|
bd0e9e4a20 | ||
|
|
c5972b9f04 | ||
|
|
e1b6cba1d4 | ||
|
|
0ff42c3c70 | ||
|
|
a6a9046457 | ||
|
|
40ae75808c | ||
|
|
fbede956e9 | ||
|
|
3793fca65e | ||
|
|
78d0eaa739 |
101
.github/pull_request_template.md
vendored
Normal file
101
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,101 @@
|
||||
## Summary
|
||||
|
||||
<!--
|
||||
State WHAT is changing and WHY, in two to five sentences.
|
||||
A reader should understand the change without opening the diff.
|
||||
Describe the problem first, then the outcome.
|
||||
-->
|
||||
|
||||
## Type of change
|
||||
|
||||
<!-- Tick the one option that best describes this PR. -->
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
- [ ] Refactor (no change in behaviour)
|
||||
- [ ] Performance improvement
|
||||
- [ ] Documentation
|
||||
- [ ] Tests only
|
||||
- [ ] Build, CI or dependency update
|
||||
- [ ] Other (please specify):
|
||||
|
||||
## Related issues
|
||||
|
||||
<!--
|
||||
Link the issue(s) this PR relates to. Use one keyword per issue so that each is closed on merge:
|
||||
|
||||
Fixes #123
|
||||
Fixes #124, fixes #125
|
||||
Fixes owner/other-repo#456
|
||||
|
||||
Closing keywords only take effect when the PR targets the repository's default branch.
|
||||
Use "Related to #123" if the PR should not close the issue.
|
||||
If there is no issue, write "N/A" and explain the motivation in the Summary.
|
||||
-->
|
||||
|
||||
## Approach and context
|
||||
|
||||
<!--
|
||||
Explain how the change works and why this approach was chosen.
|
||||
Include, where relevant:
|
||||
- The decisions you made that are not obvious from the code.
|
||||
- Alternatives you considered and why you rejected them.
|
||||
- Known limitations or shortcomings, and any planned follow-up work.
|
||||
- Background such as benchmark results or design documents.
|
||||
|
||||
Summarise the key points of any external link. Links may be inaccessible to future readers.
|
||||
-->
|
||||
|
||||
## How this was tested
|
||||
|
||||
<!--
|
||||
Describe what you ran and what you observed. Be specific, and state only what you actually did.
|
||||
For example: the automated tests added or changed, manual steps and their outcome,
|
||||
environments or platforms covered, and anything you were unable to test.
|
||||
For a bug fix, note the test that fails without the fix.
|
||||
-->
|
||||
|
||||
## Compatibility and risk
|
||||
|
||||
- [ ] This change is backwards compatible.
|
||||
- [ ] This change is **breaking** (complete the details below).
|
||||
- [ ] This change deprecates existing behaviour (complete the details below).
|
||||
|
||||
<!--
|
||||
If breaking or deprecating, describe: what changes, who is affected,
|
||||
and the migration path or recommended alternative.
|
||||
Also note any deployment, configuration, data migration or rollback considerations.
|
||||
-->
|
||||
|
||||
## User-facing change
|
||||
|
||||
<!--
|
||||
Does this PR change behaviour that users, operators or API consumers will notice?
|
||||
If not, write "None".
|
||||
If so, write one or two sentences suitable for release notes, in plain language and from the
|
||||
user's point of view. If users must take action when upgrading, state clearly that action is required.
|
||||
-->
|
||||
|
||||
```release-note
|
||||
|
||||
```
|
||||
|
||||
## Notes for reviewers
|
||||
|
||||
<!--
|
||||
Help reviewers focus. For example:
|
||||
- Where to start, or a suggested order in which to read the files.
|
||||
- Areas where you would particularly value scrutiny, or where you are uncertain.
|
||||
- How any mechanical, generated or bulk changes were produced (for example a search-and-replace
|
||||
or a code generator), so that reviewers can reproduce the steps.
|
||||
- Screenshots or recordings for visual changes (include before and after where useful).
|
||||
-->
|
||||
|
||||
## Checklist
|
||||
- [ ] The PR has a single, focused purpose and contains no unrelated changes.
|
||||
- [ ] The title is a clear, imperative summary of the change (for example, "Add retry to payment client").
|
||||
- [ ] I have added or updated tests that cover this change.
|
||||
- [ ] The build succeeds and the tests pass locally.
|
||||
- [ ] The code follows the existing patterns and style of this repository.
|
||||
- [ ] I have updated documentation, changelogs or API references where required.
|
||||
- [ ] I have reviewed my own diff, and this description reflects the final state of the change.
|
||||
2
.github/workflows/tests.yml
vendored
2
.github/workflows/tests.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
if: always()
|
||||
run: |
|
||||
status=0
|
||||
for name in test-env-api-specs test-env-unit-tests test-env-frontend-tests; do
|
||||
for name in test-env-database-migrations test-env-database-seed test-env-api-specs test-env-unit-tests test-env-frontend-tests; do
|
||||
code="$(docker inspect -f '{{.State.ExitCode}}' "$name" 2>/dev/null || echo 1)"
|
||||
echo "$name exited with $code"
|
||||
if [ "$code" != "0" ]; then
|
||||
|
||||
@@ -176,7 +176,9 @@ brewery photo upload command) rather than bound to an HTTP route directly.
|
||||
|
||||
- All business logic for that feature lives in its command/query handlers
|
||||
- No direct controller-to-repository calls; everything flows through MediatR
|
||||
- Read endpoints return a dedicated `Dto`, never the domain entity directly
|
||||
- Read endpoints return a dedicated `Dto` rather than the domain entity
|
||||
directly. The one exception is `UserController.GetAuthenticated`, which
|
||||
returns the caller's own `UserAccount` entity
|
||||
|
||||
#### Shared projects (`Shared.Contracts`, `Shared.Application`)
|
||||
|
||||
@@ -187,7 +189,7 @@ needs it, or because duplicating it four times would be worse than sharing it
|
||||
|
||||
| Project | Purpose |
|
||||
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `Shared.Contracts` | `ResponseBody<T>`/`ResponseBody`, the API response envelope every controller returns |
|
||||
| `Shared.Contracts` | `ResponseBody<T>`/`ResponseBody`, the API response envelope `AuthController`, `BreweryController`, and `CityController` return; `UserController`'s read endpoints return their DTOs unwrapped |
|
||||
| `Shared.Application` | `ValidationBehavior<TRequest,TResponse>`, the MediatR pipeline behavior that runs FluentValidation before a handler executes |
|
||||
|
||||
**Rules**:
|
||||
@@ -264,7 +266,8 @@ everything needed to understand or change one capability lives in one project
|
||||
- Controllers generally bind directly to the Command/Query as the request body;
|
||||
there is no separate request DTO + mapping step for writes
|
||||
- The exception to this is for authenticated routes, when the current user id
|
||||
needs to be extracted from the authentication token to be used in a command.
|
||||
needs to be extracted from the authentication token to be used in a command
|
||||
or query.
|
||||
- A single shared `ValidationBehavior<TRequest,TResponse>`
|
||||
(`Shared.Application/Behaviors/`) runs FluentValidation validators in the
|
||||
MediatR pipeline before any handler executes
|
||||
@@ -379,6 +382,7 @@ web/frontend/
|
||||
│ │ - catalog beers, beer styles
|
||||
│ │ - home landing page
|
||||
│ │ - theme theme switcher/guide
|
||||
│ │ - users public user profile page (/users/:id)
|
||||
│ ├── hooks/ Shared hooks not tied to one feature, for example useMediaQuery
|
||||
│ ├── routes.ts Route table (React Router 8 config-based routing)
|
||||
│ ├── root.tsx App shell and global providers
|
||||
@@ -395,6 +399,8 @@ web/frontend/
|
||||
`/breweries/:id`) with Leaflet-powered maps and geolocation-based "nearby"
|
||||
search
|
||||
- Render the auth demo, account dashboard, and theme guide routes
|
||||
- Render the public user profile route (`/users/:id`), including a user's
|
||||
following list and (via brewery comments) links back to commenter profiles
|
||||
- Manage cookie-backed website session state
|
||||
- Call the .NET API for login, registration, token refresh, confirmation, and
|
||||
brewery/location data
|
||||
@@ -559,11 +565,12 @@ are not seeded either.
|
||||
|
||||
**Environments**:
|
||||
|
||||
| Environment | Compose file |
|
||||
| ----------- | -------------------------- |
|
||||
| Development | `docker-compose.dev.yaml` |
|
||||
| Testing | `docker-compose.test.yaml` |
|
||||
| Production | `docker-compose.prod.yaml` |
|
||||
| Environment | Compose file |
|
||||
| --------------- | ------------------------------- |
|
||||
| Development | `docker-compose.dev.yaml` |
|
||||
| Testing | `docker-compose.test.yaml` |
|
||||
| Production | `docker-compose.prod.yaml` |
|
||||
| Production seed | `docker-compose.prod.seed.yaml` |
|
||||
|
||||
For details, see [Docker Guide](docker.md).
|
||||
|
||||
|
||||
@@ -76,14 +76,14 @@ updates (`UPDATE ... WHERE ... AND RowVersion = @RowVersion`).
|
||||
| `UserAccount` | `Domain.Entities.UserAccount`, `Features.Users` | `Username` and `Email` are unique |
|
||||
| `UserCredential` | `Domain.Entities.UserCredential`, `Features.Users` | Argon2id password hash; `IsRevoked`/`RevokedAt` track credential rotation, not JWTs |
|
||||
| `UserVerification` | `Domain.Entities.UserVerification`, `Features.Users` | Written when a user confirms their email |
|
||||
| `UserProfile` | `Domain.Entities.UserProfile`, `Features.Users` | 1:1 with `UserAccount`; holds the user's biography; written via `IUserProfileRepository` |
|
||||
| `UserAvatar` | `Domain.Entities.UserAvatar`, `Features.Users` | Links a `UserProfile` to a `Photo` as its avatar; written via `IUserProfileRepository.SaveAvatarAsync` |
|
||||
| `UserProfile` | `Domain.Entities.UserProfile`, `Features.Users` | 1:1 with `UserAccount`; holds the user's biography; written via `IUserProfileRepository`, read via `IUserListRepository`'s left joins in both `GetByIdAsync` and `GetAllAsync` |
|
||||
| `UserAvatar` | `Domain.Entities.UserAvatar`, `Features.Users` | Links a `UserProfile` to a `Photo` as its avatar; written via `IUserProfileRepository.SaveAvatarAsync`, read via `IUserListRepository`'s `ValidTo IS NULL` join for the active avatar |
|
||||
| `BreweryPost` | `Domain.Entities.BreweryPost`, `Features.Breweries` | A user-submitted brewery listing |
|
||||
| `BreweryPostLocation` | `Domain.Entities.BreweryPostLocation`, `Features.Breweries` | 1:1 with `BreweryPost`; `Coordinates` is a `GEOGRAPHY` column, read via a plain Dapper query that selects `CONVERT(varbinary(max), Coordinates)`, since Dapper can't deserialize SQL Server UDTs directly |
|
||||
| `City` | `Domain.Entities.City`, `Features.Locations` | Referenced by `BreweryPostLocation.CityID`; `CityName` and `StateProvinceID` together are unique; written via `ILocationRepository`, read via `ILocationRepository` and `IBreweryRepository`'s location joins, currently written only by `Database.Seed` |
|
||||
| `StateProvince` | `Domain.Entities.StateProvince`, `Features.Locations` | Referenced by `City.StateProvinceID`; created on demand by `ILocationRepository.GetOrCreateCityIdAsync`, read via `IBreweryRepository`'s location joins |
|
||||
| `Country` | `Domain.Entities.Country`, `Features.Locations` | Referenced by `StateProvince.CountryID`; created on demand by `ILocationRepository.GetOrCreateCityIdAsync`, read via `IBreweryRepository`'s location joins |
|
||||
| `Photo` | `Domain.Entities.Photo`, `Features.PhotoUpload` | Written via `IPhotoUploadRepository.CreateAsync`, sent as an `UploadPhotoCommand` from other features' handlers; not yet seeded by `Database.Seed` |
|
||||
| `Photo` | `Domain.Entities.Photo`, `Features.PhotoUpload` | Written via `IPhotoUploadRepository.CreateAsync`, sent as an `UploadPhotoCommand` from other features' handlers; `Hyperlink` holds the object key within the bucket, which readers sign into a time-limited URL via `IFileStorageProvider.GetPresignedUrl`; the active avatar's row is also read via `IUserListRepository`'s `Media.Photo` join; not yet seeded by `Database.Seed` |
|
||||
|
||||
### Tables defined in the schema but not yet wired to a feature slice
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ no data flowing over them.
|
||||
| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Dev network** (`devnet`, bridge) | Internal DNS resolves service names such as `sqlserver` and `api.core`. Volumes: `sqlserverdata-dev`, `nuget-cache-dev`, `seaweedfsdata-dev`. Startup order: SQL Server (health check) → migrations (run-once) → seed (run-once, depends on `seaweedfs`) → API (long-running, depends on seed). |
|
||||
| **Test network** (`testnet`, bridge) | All test components are isolated; fresh DB instance each run (`CLEAR_DATABASE=true`, ephemeral `sqlserverdata-test` / `seaweedfsdata-test` volumes). `api.specs`, `unit.tests`, and `frontend.tests` all run as separate containers, in parallel once seeding completes; results are aggregated. |
|
||||
| **Prod network** (`prodnet`, bridge) | Isolated from dev/test networks. Volumes: `sqlserverdata-prod`, `nuget-cache-prod`. No seed step in production. |
|
||||
| **Prod network** (`prodnet`, bridge, named `biergarten-prodnet`) | Isolated from dev/test networks. Volumes: `biergartendata-prod` and `seaweedfsdata-prod`, both external, so `down -v` keeps them. Startup order: SeaweedFS (health check) → API (health check) → frontend, with `frontend` the only service publishing a host port, bound to `127.0.0.1`. Migrating and seeding run on demand as their own run-once jobs joining `biergarten-prodnet`, so an `up` or a reboot replays neither. |
|
||||
| **Database-only environment** | Same devnet volumes/images as `docker-compose.dev.yaml`, minus `api.core`, `frontend`, and `mailpit` — for working against the database (and object storage, since `seaweedfs` is still included) without running the API or website. |
|
||||
| **Minimal environment** | Database + migrations + seed + `seaweedfs` + mailpit, no API or frontend container — for frontend development against an `API.Core` process run outside Docker. |
|
||||
| `unit.tests` | Builds `Dockerfile.tests`, which restores/builds against `Biergarten.API.slnx` and then runs `dotnet test` for every `Features/*.Tests` project in turn (`Features.Users.Tests`, `Features.Breweries.Tests`, `Features.Emails.Tests`, `Features.PhotoUpload.Tests`), mocking each handler's repository/service dependencies with Moq — no real database needed. |
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
sequenceDiagram
|
||||
box Production Environment (docker-compose.prod.yaml)
|
||||
participant ProdDB as SQL Server 2022<br/>Biergarten Database<br/>no host port published
|
||||
participant ProdMig as Migrations (run-once)
|
||||
participant ProdS3 as SeaweedFS<br/>S3 gateway :8333<br/>no host port published
|
||||
participant ProdAPI as API Container<br/>ASP.NET Core 10<br/>:8080 HTTP / :8081 HTTPS
|
||||
participant ProdWeb as Frontend Container<br/>Express + React Router<br/>127.0.0.1:5656
|
||||
end
|
||||
|
||||
ProdMig-->>ProdDB: depends_on
|
||||
ProdMig->>ProdDB: Run migrations
|
||||
ProdAPI-->>ProdDB: depends_on: sql-server healthy (not migrations/seed)
|
||||
ProdAPI-->>ProdS3: depends_on: seaweedfs healthy
|
||||
ProdWeb-->>ProdAPI: depends_on: api.core healthy
|
||||
ProdAPI->>ProdDB: Connect and serve
|
||||
ProdAPI->>ProdS3: Store and read photos
|
||||
Note over ProdDB: Migrating and seeding are separate run-once jobs,<br/>run at deploy time rather than by the stack
|
||||
|
||||
@@ -98,7 +98,7 @@ see the notes for what else each of those foundation projects pulls in.
|
||||
- **Omitted from the diagram**:
|
||||
- `Domain.Exceptions` (referenced by nearly every project directly, including
|
||||
all of `Shared.Contracts` and each feature slice)
|
||||
- `Infrastructure.Configuration` (the shared options-binding project
|
||||
- `Configuration` (the shared options-binding project
|
||||
referenced by `Database.Connection`, `Features.PhotoUpload`,
|
||||
and `Features.Emails` directly)
|
||||
- both are plumbing-level dependencies of nearly everything above, and drawing
|
||||
|
||||
@@ -98,7 +98,7 @@ sqlserver # Test database
|
||||
database.migrations # Fresh schema
|
||||
database.seed # Test data
|
||||
api.specs # Reqnroll BDD tests
|
||||
unit.tests # All Features.*.Tests unit test projects
|
||||
unit.tests # All Features.*.Tests and Shared.*.Tests unit test projects
|
||||
frontend.tests # Storybook Vitest + Playwright suites (own Playwright image)
|
||||
```
|
||||
|
||||
@@ -134,27 +134,136 @@ containers, which would end the run prematurely. See
|
||||
- Production logging levels
|
||||
- No database clearing
|
||||
- Optimized build configurations
|
||||
- Health checks enabled
|
||||
- Restart policies (unless-stopped)
|
||||
- Health checks on every long-running service
|
||||
- Restart policies that survive a reboot (see
|
||||
[Restart tolerance](#restart-tolerance))
|
||||
- Security hardening
|
||||
- Only `frontend` publishes a port to the host; `sqlserver` and `api.core` are
|
||||
reachable only from other containers on `prodnet`
|
||||
- Only `frontend` publishes a port to the host, bound to `127.0.0.1` so a reverse
|
||||
proxy can sit in front of it; `sqlserver`, `api.core` and `seaweedfs` are
|
||||
reachable only from other containers on `prodnet` (named `biergarten-prodnet`
|
||||
so the seed job can join it from its own Compose project)
|
||||
- Seeding is a separate, explicitly run compose file
|
||||
(`docker-compose.prod.seed.yaml`), so an `up` or a restart leaves the existing
|
||||
catalogue as it is. See [Production data](#production-data) below.
|
||||
|
||||
**Services**:
|
||||
|
||||
```yaml
|
||||
sqlserver # Production SQL Server (internal only)
|
||||
database.migrations # Schema updates only
|
||||
sqlserver # Production SQL Server (internal only)
|
||||
api.core # Production API (internal only, no published ports)
|
||||
frontend # Production website (port 3000, the only public binding)
|
||||
seaweedfs # S3-compatible object storage (internal only)
|
||||
frontend # Production website (127.0.0.1:5656, the only host binding)
|
||||
```
|
||||
|
||||
Migrating and seeding are run-once jobs kept outside this file, so an `up` or a
|
||||
restart touches neither the schema nor the catalogue. Run them explicitly at
|
||||
deploy time: see [Migrating production](#migrating-production) and
|
||||
[Seeding production](#seeding-production-docker-composeprodseedyaml).
|
||||
|
||||
**Deploy Production**:
|
||||
|
||||
```bash
|
||||
# One time, before the first `up`: the data volumes are external, so Compose
|
||||
# expects them to exist already.
|
||||
docker volume create biergartendata-prod
|
||||
docker volume create seaweedfsdata-prod
|
||||
|
||||
docker compose --env-file web/.env.prod -f web/docker-compose.prod.yaml up -d
|
||||
```
|
||||
|
||||
Docker seeds each volume with the ownership the image expects and labels it for
|
||||
SELinux, so the host needs no `chown` and no `:z`/`:Z` mount options on
|
||||
enforcing distributions such as RHEL, AlmaLinux and Fedora.
|
||||
|
||||
#### Migrating production
|
||||
|
||||
`database.migrations` is a run-once job rather than a service in the prod stack,
|
||||
so the schema is migrated when an operator asks for it instead of on every boot.
|
||||
Build the image and run it against the network the prod stack creates:
|
||||
|
||||
```bash
|
||||
docker build -t database.migrations \
|
||||
-f web/backend/Database/Database.Migrations/Dockerfile \
|
||||
--build-arg BUILD_CONFIGURATION=Release --build-arg APP_UID=1000 \
|
||||
web/backend
|
||||
|
||||
docker run --rm --env-file web/.env.prod --network biergarten-prodnet \
|
||||
-e DOTNET_RUNNING_IN_CONTAINER=true database.migrations
|
||||
```
|
||||
|
||||
DbUp records every script it has applied, so a re-run applies only what is new.
|
||||
Run this with `sqlserver` already up and healthy, and before the seed job.
|
||||
|
||||
#### Restart tolerance
|
||||
|
||||
`sqlserver`, `api.core`, `seaweedfs` and `frontend` run under
|
||||
`restart: unless-stopped`, so the stack returns on its own after a container
|
||||
crash, a Docker daemon restart or a host reboot, provided Docker itself starts at
|
||||
boot (`systemctl enable docker`). The migration and seed jobs stay out of the
|
||||
stack, so a reboot replays neither.
|
||||
|
||||
The daemon replays restart policies without replaying the `depends_on` graph, so
|
||||
after a reboot the containers come back in arbitrary order and each one tolerates
|
||||
a dependency that arrives late. `api.core` opens its database and S3 connections
|
||||
per request rather than at startup, and `frontend` answers with its own error
|
||||
states while `api.core` is still coming up.
|
||||
|
||||
Health checks report when the stack has converged:
|
||||
|
||||
| Service | Probe |
|
||||
| --- | --- |
|
||||
| `sqlserver` | `sqlcmd -Q 'SELECT 1'`, with a 30s start period and 12 retries |
|
||||
| `api.core` | `GET /health` spoken over bash's `/dev/tcp` — the .NET runtime image carries bash and no HTTP client |
|
||||
| `seaweedfs` | `curl http://localhost:8333/status`, the S3 gateway, which serves once the master, volume and filer servers are up |
|
||||
| `frontend` | `wget --spider http://127.0.0.1:5656/healthz`, answered by Express alone and so independent of the API |
|
||||
|
||||
`sqlserver` also carries `stop_grace_period: 60s`, giving a checkpoint time to
|
||||
finish on shutdown instead of being recovered on the next start.
|
||||
|
||||
Deploy and wait for the stack to report healthy, or check convergence after a
|
||||
reboot:
|
||||
|
||||
```bash
|
||||
docker compose --env-file web/.env.prod -f web/docker-compose.prod.yaml up -d --wait
|
||||
docker compose --env-file web/.env.prod -f web/docker-compose.prod.yaml ps
|
||||
```
|
||||
|
||||
#### Seeding production (`docker-compose.prod.seed.yaml`)
|
||||
|
||||
`database.seed` lives in its own compose file, run on demand once the prod stack
|
||||
is up, `sqlserver` is healthy, and the schema is migrated:
|
||||
|
||||
```bash
|
||||
docker compose --env-file web/.env.prod -f web/docker-compose.prod.seed.yaml \
|
||||
run --rm --build database.seed
|
||||
```
|
||||
|
||||
The job runs as the `biergarten-prod-seed` Compose project and attaches to the
|
||||
external `biergarten-prodnet` network, which the main prod stack creates, so it
|
||||
reaches `sqlserver` and `seaweedfs` under the same hostnames the API uses. It
|
||||
exits when seeding finishes; `--rm` removes the container. Bring the prod stack
|
||||
up first, because the seed project has no visibility into the other project's
|
||||
health checks, so a run started before SQL Server is accepting connections fails.
|
||||
|
||||
#### Production data
|
||||
|
||||
A production deployment seeded this way serves the AI-generated fixture data
|
||||
produced by the pipeline. Per
|
||||
[Pipeline ethics, bias, and known issues](../pipeline/ETHICS-AND-KNOWN-ISSUES.md),
|
||||
anyone interacting with an application seeded from it has to be told upfront that
|
||||
the content is AI-generated.
|
||||
|
||||
The website meets that obligation with `AiDisclosureDialog`
|
||||
(`web/frontend/app/features/home/components/AiDisclosureDialog.tsx`), which
|
||||
the root route renders on every full page load, whichever route the visitor lands
|
||||
on. It is part of the server-rendered HTML rather than mounted after hydration, so
|
||||
the disclosure is on screen with the first paint. Acknowledging it hides the
|
||||
dialog for the remainder of that page load; a new load shows it again.
|
||||
|
||||
Keep the seed job and the dialog together: any deployment that runs
|
||||
`docker-compose.prod.seed.yaml` needs the disclosure in place to satisfy the
|
||||
ethics documentation.
|
||||
|
||||
### 4. Database only (`docker-compose.db.yaml`)
|
||||
|
||||
**Purpose**: Run just the database for local backend development outside Docker
|
||||
@@ -215,6 +324,12 @@ graph TD
|
||||
In development, `api.core` also depends on `seaweedfs`
|
||||
(`condition: service_started`, not a health check) alongside `database.seed`.
|
||||
|
||||
Production has a shorter chain, because the migration and seed jobs run outside
|
||||
the stack: `api.core` waits for `seaweedfs` to pass its health check, and
|
||||
`frontend` waits for `api.core` to report healthy. This ordering applies to
|
||||
`docker compose up`; see [Restart tolerance](#restart-tolerance) for what happens
|
||||
on a host reboot.
|
||||
|
||||
**Health Check Example** (SQL Server):
|
||||
|
||||
```yaml
|
||||
@@ -255,8 +370,10 @@ api.core:
|
||||
|
||||
**Production**:
|
||||
|
||||
- `sqlserverdata-prod` - Production database files
|
||||
- `nuget-cache-prod` - Production NuGet cache
|
||||
- `biergartendata-prod` - Database files, external; survives `down -v`
|
||||
- `seaweedfsdata-prod` - SeaweedFS object storage, external; survives `down -v`
|
||||
|
||||
See [Production data volumes](#production-data-volumes).
|
||||
|
||||
### Mounted volumes
|
||||
|
||||
@@ -269,6 +386,41 @@ volumes:
|
||||
|
||||
Test results are written to host filesystem for CI/CD integration.
|
||||
|
||||
### Production data volumes
|
||||
|
||||
`biergartendata-prod` and `seaweedfsdata-prod` are declared `external: true`.
|
||||
Compose removes only the volumes it owns, so `docker compose down -v` tears the
|
||||
stack down and leaves the database and the uploaded photos in place. Deleting
|
||||
them takes an explicit `docker volume rm`.
|
||||
|
||||
The trade-off is that Compose creates neither one, and `up` fails with a clear
|
||||
error until both exist, hence the `docker volume create` step in
|
||||
[Deploy Production](#3-production-docker-composeprodyaml). External volumes also
|
||||
carry their literal name rather than a `<project>_` prefix, so `docker volume ls`
|
||||
shows `biergartendata-prod` and `seaweedfsdata-prod` unprefixed, unlike the
|
||||
`web_`-prefixed volumes of the dev and test stacks.
|
||||
|
||||
Both live under `/var/lib/docker/volumes`. To put them on a specific disk, point
|
||||
the daemon's `data-root` at it in `/etc/docker/daemon.json`, or mount the disk at
|
||||
`/var/lib/docker`.
|
||||
|
||||
Back up the database through SQL Server rather than by copying its files, since a
|
||||
file copy taken while the engine is running can be torn:
|
||||
|
||||
```bash
|
||||
docker exec prod-env-sqlserver /opt/mssql-tools18/bin/sqlcmd \
|
||||
-S localhost -U sa -P '<db-password>' -C \
|
||||
-Q "BACKUP DATABASE [<db-name>] TO DISK = '/var/opt/mssql/data/backup.bak'"
|
||||
docker cp prod-env-sqlserver:/var/opt/mssql/data/backup.bak .
|
||||
```
|
||||
|
||||
Object storage can be archived directly, with the stack stopped:
|
||||
|
||||
```bash
|
||||
docker run --rm -v seaweedfsdata-prod:/data -v "$PWD:/backup" \
|
||||
alpine tar czf /backup/seaweedfs.tar.gz -C /data .
|
||||
```
|
||||
|
||||
## Networks
|
||||
|
||||
Each environment uses isolated bridge networks:
|
||||
|
||||
@@ -260,17 +260,25 @@ and retrieving photos via an S3-compatible object store.
|
||||
|
||||
```bash
|
||||
SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333 # Required, no default
|
||||
SEAWEEDFS_PUBLIC_URL=http://localhost:8333 # Optional, defaults to SEAWEEDFS_SERVICE_URL
|
||||
SEAWEEDFS_ACCESS_KEY_ID=admin # Required, no default
|
||||
SEAWEEDFS_SECRET_ACCESS_KEY=secret # Required, no default
|
||||
SEAWEEDFS_BUCKET=my-bucket # Required, no default
|
||||
```
|
||||
|
||||
- **Implementation**: `S3FileStorageProvider` throws on construction if any of
|
||||
these four values is missing
|
||||
the four required values is missing
|
||||
- **Local dev**: point at the `seaweedfs` Docker service (S3 API on port 8333)
|
||||
- **Production**: point at any S3-compatible endpoint (AWS S3, a hosted
|
||||
SeaweedFS cluster, and so on); `ForcePathStyle` is always enabled, which
|
||||
SeaweedFS requires and AWS S3 also accepts
|
||||
- **`SEAWEEDFS_PUBLIC_URL`**: the endpoint download URLs are signed against,
|
||||
used by `GetPresignedUrl` and reaching the API response as
|
||||
`PublicUserProfileDto.AvatarUrl`. A presigned URL is signed over its host, so
|
||||
this has to be the address the browser uses (`http://localhost:8333` with the
|
||||
dev compose file, which publishes the S3 API port), separate from the
|
||||
in-network `SEAWEEDFS_SERVICE_URL` the API uploads through. The two can be the
|
||||
same value wherever one address serves both
|
||||
|
||||
## Docker-specific variables
|
||||
|
||||
@@ -317,6 +325,7 @@ from the template in place; edit them manually if you go that route.
|
||||
- `web/docker-compose.dev.yaml` → `web/.env.dev`
|
||||
- `web/docker-compose.test.yaml` → `web/.env.test`
|
||||
- `web/docker-compose.prod.yaml` → `web/.env.prod`
|
||||
- `web/docker-compose.prod.seed.yaml` → `web/.env.prod`
|
||||
|
||||
## Variable reference table
|
||||
|
||||
@@ -340,6 +349,7 @@ from the template in place; edit them manually if you go that route.
|
||||
| `SMTP_FROM_EMAIL` | ✓ | | ✓ | Yes | Email sender address |
|
||||
| `SMTP_FROM_NAME` | ✓ | | ✓ | No | Defaults to `The Biergarten` |
|
||||
| `SEAWEEDFS_SERVICE_URL` | ✓ | | ✓ | Yes | S3-compatible endpoint URL |
|
||||
| `SEAWEEDFS_PUBLIC_URL` | ✓ | | ✓ | No | Endpoint download URLs are signed against; defaults to `SEAWEEDFS_SERVICE_URL` |
|
||||
| `SEAWEEDFS_ACCESS_KEY_ID` | ✓ | | ✓ | Yes | S3 access key |
|
||||
| `SEAWEEDFS_SECRET_ACCESS_KEY` | ✓ | | ✓ | Yes | S3 secret key |
|
||||
| `SEAWEEDFS_BUCKET` | ✓ | | ✓ | Yes | S3 bucket name |
|
||||
@@ -414,6 +424,7 @@ SMTP_FROM_NAME=The Biergarten App
|
||||
|
||||
# SeaweedFS (S3-compatible storage)
|
||||
SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333
|
||||
SEAWEEDFS_PUBLIC_URL=http://localhost:8333
|
||||
SEAWEEDFS_ACCESS_KEY_ID=<generated-with-openssl>
|
||||
SEAWEEDFS_SECRET_ACCESS_KEY=<generated-with-openssl>
|
||||
SEAWEEDFS_BUCKET=my-bucket
|
||||
|
||||
@@ -76,10 +76,11 @@ docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
|
||||
|
||||
You can run individual test projects locally without Docker:
|
||||
|
||||
### Feature slice unit tests
|
||||
### Feature slice and shared unit tests
|
||||
|
||||
Each feature slice has its own test project, covering its command/query
|
||||
handlers:
|
||||
handlers. Shared cross-cutting code (e.g. MediatR pipeline behaviors) has
|
||||
its own test project under `Shared/`:
|
||||
|
||||
```bash
|
||||
cd web/backend
|
||||
@@ -87,9 +88,10 @@ dotnet test Features/Features.Users.Tests/Features.Users.Tests.csproj
|
||||
dotnet test Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj
|
||||
dotnet test Features/Features.Emails.Tests/Features.Emails.Tests.csproj
|
||||
dotnet test Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj
|
||||
dotnet test Shared/Shared.Application.Tests/Shared.Application.Tests.csproj
|
||||
|
||||
# Or run all of them at once via the solution:
|
||||
for proj in Features/*.Tests; do dotnet test "$proj"; done
|
||||
for proj in Features/*.Tests Shared/*.Tests; do dotnet test "$proj"; done
|
||||
```
|
||||
|
||||
**Requirements**:
|
||||
@@ -247,13 +249,16 @@ API.Specs/
|
||||
│ ├── ResendConfirmation.feature # Resend-confirmation scenarios
|
||||
│ ├── TokenRefresh.feature # Refresh token scenarios
|
||||
│ ├── AccessTokenValidation.feature # Protected endpoint access scenarios
|
||||
│ └── NotFound.feature # 404 handling
|
||||
│ ├── NotFound.feature # 404 handling
|
||||
│ └── PublicUserProfile.feature # Public profile & authenticated account scenarios
|
||||
├── Steps/
|
||||
│ ├── AuthSteps.cs # Step definitions for the Auth features
|
||||
│ ├── UserSteps.cs # Step definitions for the public profile & user-account features
|
||||
│ └── ApiGeneralSteps.cs # Shared/general step definitions
|
||||
├── Mocks/
|
||||
│ ├── MockEmailDispatcher.cs # Substitutes Features.Emails' IEmailDispatcher
|
||||
│ └── MockEmailProvider.cs # Substitutes Features.Emails' IEmailProvider
|
||||
│ ├── MockEmailProvider.cs # Substitutes Features.Emails' IEmailProvider
|
||||
│ └── MockFileStorageProvider.cs # Substitutes Features.ImageUploads' IFileStorageProvider
|
||||
└── TestApiFactory.cs # Test server setup (swaps in the mocks above)
|
||||
```
|
||||
|
||||
@@ -278,17 +283,25 @@ Scenario: Successful user registration
|
||||
Features.Users.Tests/
|
||||
├── Commands/
|
||||
│ ├── RegisterUserHandlerTests.cs
|
||||
│ ├── RegisterUserValidatorTests.cs
|
||||
│ ├── ConfirmUserHandlerTests.cs
|
||||
│ ├── ResendConfirmationEmailHandlerTests.cs
|
||||
│ ├── RefreshTokenHandlerTests.cs
|
||||
│ ├── RefreshTokenValidatorTests.cs
|
||||
│ ├── LoginValidatorTests.cs # tests LoginCommand's validator
|
||||
│ ├── UpdateUsernameHandlerTests.cs
|
||||
│ ├── UpdateUsernameValidatorTests.cs
|
||||
│ ├── UpdateEmailHandlerTests.cs
|
||||
│ ├── UpdateEmailValidatorTests.cs
|
||||
│ ├── UpdatePasswordHandlerTests.cs
|
||||
│ ├── UpdatePasswordValidatorTests.cs
|
||||
│ ├── UpdateProfileHandlerTests.cs
|
||||
│ ├── UpdateProfileValidatorTests.cs
|
||||
│ └── DeleteAccountHandlerTests.cs
|
||||
├── Queries/
|
||||
│ ├── LoginHandlerTests.cs # tests LoginCommand, despite the folder name
|
||||
│ ├── GetAllUsersHandlerTests.cs
|
||||
│ ├── GetPublicUserProfileByIdHandlerTests.cs
|
||||
│ └── GetUserByIdHandlerTests.cs
|
||||
├── Services/
|
||||
│ ├── TokenServiceRefreshTests.cs
|
||||
@@ -296,10 +309,13 @@ Features.Users.Tests/
|
||||
└── TestSupport/ # shared test fixtures/helpers
|
||||
```
|
||||
|
||||
Each of the other slices (`Features.Breweries.Tests`, `Features.Emails.Tests`,
|
||||
`Features.PhotoUpload.Tests`) follows the same shape: a `Commands/`/`Queries/`
|
||||
folder with one test file per handler (`Features.PhotoUpload.Tests` also adds a
|
||||
validator test file alongside its handler test).
|
||||
`Features.Breweries.Tests` and `Features.PhotoUpload.Tests` follow the same
|
||||
shape: a `Commands/`/`Queries/` folder with one test file per handler, plus a
|
||||
`*ValidatorTests.cs` file beside each handler whose command or query has a
|
||||
FluentValidation validator (`Features.Breweries.Tests` has one of these in
|
||||
`Queries/` as well, for the nearby-search query). `Features.Emails.Tests`
|
||||
differs: its handlers respond to MediatR notifications, so its test files live
|
||||
under `Notifications/`.
|
||||
|
||||
## Writing tests
|
||||
|
||||
|
||||
@@ -68,8 +68,12 @@ WEBSITE_BASE_URL=http://localhost:3000
|
||||
# ======================
|
||||
# SeaweedFS (S3) Configuration
|
||||
# ======================
|
||||
# Credentials and default bucket for the local SeaweedFS S3-compatible storage
|
||||
# Credentials and default bucket for the local SeaweedFS S3-compatible storage.
|
||||
# SEAWEEDFS_SERVICE_URL is the in-network endpoint the API uploads through;
|
||||
# SEAWEEDFS_PUBLIC_URL is the endpoint download URLs are signed against, so it has to be
|
||||
# reachable from the browser. It defaults to SEAWEEDFS_SERVICE_URL when left blank.
|
||||
SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333
|
||||
SEAWEEDFS_PUBLIC_URL=http://localhost:8333
|
||||
SEAWEEDFS_ACCESS_KEY_ID=admin
|
||||
SEAWEEDFS_SECRET_ACCESS_KEY=secret
|
||||
SEAWEEDFS_BUCKET=my-bucket
|
||||
|
||||
@@ -15,14 +15,14 @@
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.11" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
|
||||
<PackageReference Include="MediatR" Version="12.4.1" />
|
||||
<PackageReference Include="MediatR" Version="12.5.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
|
||||
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
|
||||
<ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" />
|
||||
<ProjectReference Include="..\..\Configuration\Configuration.csproj" />
|
||||
<ProjectReference Include="..\..\Features\Features.Breweries\Features.Breweries.csproj" />
|
||||
<ProjectReference Include="..\..\Features\Features.Users\Features.Users.csproj" />
|
||||
<ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using Features.Users.Services;
|
||||
using Infrastructure.Configuration;
|
||||
using Configuration;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
|
||||
@@ -33,8 +33,9 @@ COPY ["Features/Features.PhotoUpload/Features.PhotoUpload.csproj", "Features/Fea
|
||||
COPY ["Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj", "Features/Features.PhotoUpload.Tests/"]
|
||||
COPY ["Features/Features.Users/Features.Users.csproj", "Features/Features.Users/"]
|
||||
COPY ["Features/Features.Users.Tests/Features.Users.Tests.csproj", "Features/Features.Users.Tests/"]
|
||||
COPY ["Infrastructure/Infrastructure.Configuration/Infrastructure.Configuration.csproj", "Infrastructure/Infrastructure.Configuration/"]
|
||||
COPY ["Configuration/Configuration.csproj", "Configuration/"]
|
||||
COPY ["Shared/Shared.Application/Shared.Application.csproj", "Shared/Shared.Application/"]
|
||||
COPY ["Shared/Shared.Application.Tests/Shared.Application.Tests.csproj", "Shared/Shared.Application.Tests/"]
|
||||
COPY ["Shared/Shared.Contracts/Shared.Contracts.csproj", "Shared/Shared.Contracts/"]
|
||||
RUN dotnet restore "Biergarten.API.slnx"
|
||||
COPY . .
|
||||
|
||||
@@ -46,6 +46,7 @@ internal static class ServiceCollectionExtensions
|
||||
cfg.RegisterServicesFromAssembly(typeof(UploadPhotoCommand).Assembly);
|
||||
cfg.RegisterServicesFromAssembly(typeof(IEmailDispatcher).Assembly);
|
||||
cfg.RegisterServicesFromAssembly(typeof(CityController).Assembly);
|
||||
cfg.AddOpenBehavior(typeof(UnhandledExceptionLoggingBehavior<,>));
|
||||
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
|
||||
});
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\API.Core\API.Core.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" />
|
||||
<ProjectReference Include="..\..\Configuration\Configuration.csproj" />
|
||||
<ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj" />
|
||||
<ProjectReference Include="..\..\Features\Features.Locations\Features.Locations.csproj" />
|
||||
<ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" />
|
||||
|
||||
@@ -23,8 +23,9 @@ COPY ["Features/Features.PhotoUpload/Features.PhotoUpload.csproj", "Features/Fea
|
||||
COPY ["Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj", "Features/Features.PhotoUpload.Tests/"]
|
||||
COPY ["Features/Features.Users/Features.Users.csproj", "Features/Features.Users/"]
|
||||
COPY ["Features/Features.Users.Tests/Features.Users.Tests.csproj", "Features/Features.Users.Tests/"]
|
||||
COPY ["Infrastructure/Infrastructure.Configuration/Infrastructure.Configuration.csproj", "Infrastructure/Infrastructure.Configuration/"]
|
||||
COPY ["Configuration/Configuration.csproj", "Configuration/"]
|
||||
COPY ["Shared/Shared.Application/Shared.Application.csproj", "Shared/Shared.Application/"]
|
||||
COPY ["Shared/Shared.Application.Tests/Shared.Application.Tests.csproj", "Shared/Shared.Application.Tests/"]
|
||||
COPY ["Shared/Shared.Contracts/Shared.Contracts.csproj", "Shared/Shared.Contracts/"]
|
||||
RUN dotnet restore "Biergarten.API.slnx"
|
||||
COPY . .
|
||||
|
||||
130
web/backend/API/API.Specs/Features/PublicUserProfile.feature
Normal file
130
web/backend/API/API.Specs/Features/PublicUserProfile.feature
Normal file
@@ -0,0 +1,130 @@
|
||||
Feature: Public User Profile
|
||||
As a client of the API
|
||||
I want to read a user's public-facing profile
|
||||
So that a public profile page can render without exposing private account fields
|
||||
|
||||
Background:
|
||||
Given the API is running
|
||||
|
||||
Scenario: Public profile exposes only allowlisted fields
|
||||
Given I have registered a new account
|
||||
And I have a valid access token for my account
|
||||
When I retrieve the public profile by ID
|
||||
Then the response has HTTP status 200
|
||||
And the public profile response should match the registered account
|
||||
And the response JSON should have "biography" equal ""
|
||||
And the response JSON should have "avatarUrl" equal ""
|
||||
And the response JSON should not contain "email"
|
||||
And the response JSON should not contain "dateOfBirth"
|
||||
|
||||
Scenario: Public profile exposes the uploaded avatar
|
||||
Given I have registered a new account
|
||||
And I have a valid access token for my account
|
||||
And I have uploaded an avatar
|
||||
When I retrieve the public profile by ID
|
||||
Then the response has HTTP status 200
|
||||
And the public profile response should carry my current avatar
|
||||
|
||||
Scenario: Public profile exposes the current avatar after it is replaced
|
||||
Given I have registered a new account
|
||||
And I have a valid access token for my account
|
||||
And I have uploaded an avatar
|
||||
And I have uploaded a new avatar, replacing the previous one
|
||||
When I retrieve the public profile by ID
|
||||
Then the response has HTTP status 200
|
||||
And the public profile response should carry my current avatar
|
||||
And the public profile response should not carry my previous avatar
|
||||
|
||||
Scenario: Fetching the avatar without authentication succeeds
|
||||
Given I have registered a new account
|
||||
And I have a valid access token for my account
|
||||
And I have uploaded an avatar
|
||||
When I retrieve the public profile by ID without authentication
|
||||
Then the response has HTTP status 200
|
||||
And the public profile response should carry my current avatar
|
||||
|
||||
Scenario: A different user can read someone else's avatar
|
||||
Given I have registered a new account
|
||||
And I have a valid access token for my account
|
||||
And I have uploaded an avatar
|
||||
And I am logged in as a different user
|
||||
When I retrieve the public profile by ID
|
||||
Then the response has HTTP status 200
|
||||
And the public profile response should carry my current avatar
|
||||
|
||||
Scenario: Listing user accounts exposes each account's avatar
|
||||
Given I have registered a new account
|
||||
And I have a valid access token for my account
|
||||
And I have uploaded an avatar
|
||||
When I list user accounts
|
||||
Then the response has HTTP status 200
|
||||
And my listed account should carry my current avatar
|
||||
|
||||
Scenario: Listing user accounts exposes each account's biography
|
||||
Given I have registered a new account
|
||||
And I have a valid access token for my account
|
||||
And I have set my biography to "Drinks lagers."
|
||||
When I list user accounts
|
||||
Then the response has HTTP status 200
|
||||
And my listed account should carry my biography
|
||||
|
||||
Scenario: Public profile for a non-existent account returns not found
|
||||
Given I have registered a new account
|
||||
And I have a valid access token for my account
|
||||
When I retrieve the public profile by a non-existent ID
|
||||
Then the response has HTTP status 404
|
||||
|
||||
Scenario: Fetching the public profile without authentication succeeds
|
||||
Given I have registered a new account
|
||||
When I retrieve the public profile by ID without authentication
|
||||
Then the response has HTTP status 200
|
||||
And the response JSON should have "avatarUrl" equal ""
|
||||
And the response JSON should not contain "email"
|
||||
And the response JSON should not contain "dateOfBirth"
|
||||
|
||||
Scenario: A different user can read someone else's public profile
|
||||
Given I have registered a new account
|
||||
And I am logged in as a different user
|
||||
When I retrieve the public profile by ID
|
||||
Then the response has HTTP status 200
|
||||
And the response JSON should not contain "email"
|
||||
And the response JSON should not contain "dateOfBirth"
|
||||
|
||||
Scenario: Fetching my own account without authentication is rejected
|
||||
When I retrieve my own account without authentication
|
||||
Then the response has HTTP status 401
|
||||
|
||||
Scenario: Listing user accounts without authentication is rejected
|
||||
When I list user accounts without authentication
|
||||
Then the response has HTTP status 401
|
||||
|
||||
Scenario: Listing user accounts as an authenticated user excludes private fields
|
||||
Given I have registered a new account
|
||||
And I have a valid access token for my account
|
||||
When I list user accounts
|
||||
Then the response has HTTP status 200
|
||||
And the response JSON should contain "avatarUrl"
|
||||
And the response JSON should not contain "email"
|
||||
And the response JSON should not contain "dateOfBirth"
|
||||
|
||||
Scenario: The caller can fetch their own account
|
||||
Given I have registered a new account
|
||||
And I have a valid access token for my account
|
||||
When I retrieve my own account
|
||||
Then the response has HTTP status 200
|
||||
And the public profile response should match the registered account
|
||||
|
||||
Scenario: The caller's own account omits the avatar graph
|
||||
Given I have registered a new account
|
||||
And I have a valid access token for my account
|
||||
And I have uploaded an avatar
|
||||
When I retrieve my own account
|
||||
Then the response has HTTP status 200
|
||||
And the response JSON should not contain "userAvatar"
|
||||
|
||||
Scenario: The account endpoint serves the caller named by the access token
|
||||
Given I have registered a new account
|
||||
And I am logged in as a different user
|
||||
When I retrieve my own account
|
||||
Then the response has HTTP status 200
|
||||
And the account response should be for a different account than the registered one
|
||||
55
web/backend/API/API.Specs/Mocks/MockFileStorageProvider.cs
Normal file
55
web/backend/API/API.Specs/Mocks/MockFileStorageProvider.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
using Features.ImageUploads.Services;
|
||||
|
||||
namespace API.Specs.Mocks;
|
||||
|
||||
/// <summary>
|
||||
/// Test double for <see cref="IFileStorageProvider" /> that records uploads in memory, so specs can
|
||||
/// exercise photo-backed features without object storage.
|
||||
/// </summary>
|
||||
public class MockFileStorageProvider : IFileStorageProvider
|
||||
{
|
||||
private readonly List<string> _keys = [];
|
||||
private readonly Dictionary<string, byte[]> _objects = new();
|
||||
|
||||
public IReadOnlyCollection<string> UploadedKeys => _keys.ToList();
|
||||
|
||||
/// <summary>The key of the most recent upload.</summary>
|
||||
/// <exception cref="InvalidOperationException">Thrown when nothing has been uploaded yet.</exception>
|
||||
public string LastUploadedKey =>
|
||||
_keys.Count > 0
|
||||
? _keys[^1]
|
||||
: throw new InvalidOperationException("no file has been uploaded");
|
||||
|
||||
public Task UploadAsync(
|
||||
string key,
|
||||
Stream content,
|
||||
string contentType,
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
using MemoryStream buffer = new();
|
||||
content.CopyTo(buffer);
|
||||
|
||||
if (!_objects.ContainsKey(key))
|
||||
_keys.Add(key);
|
||||
|
||||
_objects[key] = buffer.ToArray();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_objects.Remove(key);
|
||||
_keys.Remove(key);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stands in for a signed download URL: an absolute URL on a host the object store would serve,
|
||||
/// carrying the key in its path, so specs can assert the shape callers actually receive.
|
||||
/// </summary>
|
||||
public string GetPresignedUrl(string key, TimeSpan expiresIn)
|
||||
{
|
||||
return $"https://storage.test/{key}?X-Amz-Expires={(int)expiresIn.TotalSeconds}";
|
||||
}
|
||||
}
|
||||
@@ -167,4 +167,86 @@ public class ApiGeneralSteps(ScenarioContext scenario)
|
||||
actualValue
|
||||
);
|
||||
}
|
||||
|
||||
[Then("the response JSON should not contain {string}")]
|
||||
public void ThenTheResponseJsonShouldNotContainString(string field)
|
||||
{
|
||||
scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
|
||||
|
||||
using JsonDocument doc = JsonDocument.Parse(responseBody!);
|
||||
JsonElement root = doc.RootElement;
|
||||
|
||||
AssertFieldAbsent(root, field);
|
||||
|
||||
if (root.ValueKind == JsonValueKind.Object &&
|
||||
root.TryGetProperty("payload", out JsonElement payloadElem))
|
||||
AssertFieldAbsent(payloadElem, field);
|
||||
}
|
||||
|
||||
[Then("the response JSON should contain {string}")]
|
||||
public void ThenTheResponseJsonShouldContainString(string field)
|
||||
{
|
||||
scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
|
||||
|
||||
using JsonDocument doc = JsonDocument.Parse(responseBody!);
|
||||
JsonElement root = doc.RootElement;
|
||||
|
||||
if (root.ValueKind == JsonValueKind.Object &&
|
||||
!root.TryGetProperty(field, out _) &&
|
||||
root.TryGetProperty("payload", out JsonElement payloadElem))
|
||||
{
|
||||
AssertFieldPresent(payloadElem, field);
|
||||
return;
|
||||
}
|
||||
|
||||
AssertFieldPresent(root, field);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recurses into arrays so a listing endpoint's response can be asserted the same way as a
|
||||
/// single-resource response.
|
||||
/// </summary>
|
||||
private static void AssertFieldAbsent(JsonElement element, string field)
|
||||
{
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
element
|
||||
.TryGetProperty(field, out _)
|
||||
.Should()
|
||||
.BeFalse("Expected field '{0}' to be absent from the response", field);
|
||||
break;
|
||||
case JsonValueKind.Array:
|
||||
foreach (JsonElement item in element.EnumerateArray())
|
||||
AssertFieldAbsent(item, field);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recurses into arrays so every item of a listing endpoint's response must carry the field.
|
||||
/// </summary>
|
||||
private static void AssertFieldPresent(JsonElement element, string field)
|
||||
{
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
element
|
||||
.TryGetProperty(field, out _)
|
||||
.Should()
|
||||
.BeTrue("Expected field '{0}' to be present in the response", field);
|
||||
break;
|
||||
case JsonValueKind.Array:
|
||||
element
|
||||
.EnumerateArray()
|
||||
.Should()
|
||||
.NotBeEmpty(
|
||||
"Expected at least one item to assert field '{0}' against",
|
||||
field
|
||||
);
|
||||
foreach (JsonElement item in element.EnumerateArray())
|
||||
AssertFieldPresent(item, field);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ using System.Text;
|
||||
using System.Text.Json;
|
||||
using Features.Users.Services;
|
||||
using FluentAssertions;
|
||||
using Infrastructure.Configuration;
|
||||
using Configuration;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Reqnroll;
|
||||
@@ -347,6 +347,46 @@ public class AuthSteps(ScenarioContext scenario) : ApiStepsBase(scenario)
|
||||
Scenario[RegisteredUsernameKey] = ParseRegisteredUsername(root);
|
||||
}
|
||||
|
||||
[Given("I am logged in as a different user")]
|
||||
public async Task GivenIAmLoggedInAsADifferentUser()
|
||||
{
|
||||
HttpClient client = GetClient();
|
||||
string suffix = Guid.NewGuid().ToString("N")[..8];
|
||||
var registrationData = new
|
||||
{
|
||||
username = $"otheruser-{suffix}",
|
||||
firstName = "Other",
|
||||
lastName = "User",
|
||||
email = $"otheruser-{suffix}@example.com",
|
||||
dateOfBirth = "1990-01-01",
|
||||
password = "Password1!",
|
||||
};
|
||||
|
||||
HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/register")
|
||||
{
|
||||
Content = new StringContent(
|
||||
JsonSerializer.Serialize(registrationData),
|
||||
Encoding.UTF8,
|
||||
"application/json"
|
||||
),
|
||||
};
|
||||
|
||||
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||
response.EnsureSuccessStatusCode();
|
||||
string responseBody = await response.Content.ReadAsStringAsync();
|
||||
|
||||
using JsonDocument doc = JsonDocument.Parse(responseBody);
|
||||
JsonElement payload = doc.RootElement.GetProperty("payload");
|
||||
string accessToken =
|
||||
(
|
||||
payload.TryGetProperty("accessToken", out JsonElement tokenElem)
|
||||
? tokenElem.GetString()
|
||||
: null
|
||||
) ?? throw new InvalidOperationException("accessToken missing from registration payload");
|
||||
|
||||
Scenario["accessToken"] = accessToken;
|
||||
}
|
||||
|
||||
[Given("I am logged in")]
|
||||
public async Task GivenIAmLoggedIn()
|
||||
{
|
||||
|
||||
@@ -262,46 +262,6 @@ public class BrewerySteps(ScenarioContext scenario) : ApiStepsBase(scenario)
|
||||
await SendAsync(requestMessage);
|
||||
}
|
||||
|
||||
[Given("I am logged in as a different user")]
|
||||
public async Task GivenIAmLoggedInAsADifferentUser()
|
||||
{
|
||||
HttpClient client = GetClient();
|
||||
string suffix = Guid.NewGuid().ToString("N")[..8];
|
||||
var registrationData = new
|
||||
{
|
||||
username = $"otheruser-{suffix}",
|
||||
firstName = "Other",
|
||||
lastName = "User",
|
||||
email = $"otheruser-{suffix}@example.com",
|
||||
dateOfBirth = "1990-01-01",
|
||||
password = "Password1!",
|
||||
};
|
||||
|
||||
HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/register")
|
||||
{
|
||||
Content = new StringContent(
|
||||
JsonSerializer.Serialize(registrationData),
|
||||
Encoding.UTF8,
|
||||
"application/json"
|
||||
),
|
||||
};
|
||||
|
||||
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||
response.EnsureSuccessStatusCode();
|
||||
string responseBody = await response.Content.ReadAsStringAsync();
|
||||
|
||||
using JsonDocument doc = JsonDocument.Parse(responseBody);
|
||||
JsonElement payload = doc.RootElement.GetProperty("payload");
|
||||
string accessToken =
|
||||
(
|
||||
payload.TryGetProperty("accessToken", out JsonElement tokenElem)
|
||||
? tokenElem.GetString()
|
||||
: null
|
||||
) ?? throw new InvalidOperationException("accessToken missing from registration payload");
|
||||
|
||||
Scenario[AccessTokenKey] = accessToken;
|
||||
}
|
||||
|
||||
[Then("retrieving the brewery by ID should now return HTTP status {int}")]
|
||||
public async Task ThenRetrievingTheBreweryByIdShouldNowReturnHttpStatus(int expectedCode)
|
||||
{
|
||||
|
||||
290
web/backend/API/API.Specs/Steps/UserSteps.cs
Normal file
290
web/backend/API/API.Specs/Steps/UserSteps.cs
Normal file
@@ -0,0 +1,290 @@
|
||||
using System.Text.Json;
|
||||
using API.Specs.Mocks;
|
||||
using Features.ImageUploads.Services;
|
||||
using Features.Users.Commands.Profile.UploadAvatar;
|
||||
using Features.Users.Repository;
|
||||
using FluentAssertions;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Reqnroll;
|
||||
|
||||
namespace API.Specs.Steps;
|
||||
|
||||
[Binding]
|
||||
public class UserSteps(ScenarioContext scenario) : ApiStepsBase(scenario)
|
||||
{
|
||||
private const string AccessTokenKey = "accessToken";
|
||||
private const string RegisteredUserIdKey = "registeredUserId";
|
||||
private const string RegisteredUsernameKey = "registeredUsername";
|
||||
private const string CurrentAvatarKeyKey = "currentAvatarKey";
|
||||
private const string PreviousAvatarKeyKey = "previousAvatarKey";
|
||||
private const string BiographyKey = "biography";
|
||||
|
||||
private static readonly byte[] PngSignature = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
|
||||
|
||||
private async Task SendAsync(HttpRequestMessage requestMessage)
|
||||
{
|
||||
HttpClient client = GetClient();
|
||||
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||
string responseBody = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Scenario[ResponseKey] = response;
|
||||
Scenario[ResponseBodyKey] = responseBody;
|
||||
}
|
||||
|
||||
private HttpRequestMessage NewAuthenticatedRequest(HttpMethod method, string url)
|
||||
{
|
||||
HttpRequestMessage requestMessage = new(method, url);
|
||||
if (Scenario.TryGetValue<string>(AccessTokenKey, out string? accessToken))
|
||||
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
|
||||
return requestMessage;
|
||||
}
|
||||
|
||||
private Guid GetRegisteredUserId()
|
||||
{
|
||||
return Scenario.TryGetValue(RegisteredUserIdKey, out Guid id)
|
||||
? id
|
||||
: throw new InvalidOperationException("registered user ID not found in scenario");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the smallest upload the photo validator accepts: a PNG signature padded out to the
|
||||
/// 1 KiB minimum.
|
||||
/// </summary>
|
||||
private static IFormFile CreateAvatarFile()
|
||||
{
|
||||
byte[] content = new byte[2048];
|
||||
PngSignature.CopyTo(content, 0);
|
||||
MemoryStream stream = new(content);
|
||||
|
||||
return new FormFile(stream, 0, content.Length, "file", "avatar.png")
|
||||
{
|
||||
Headers = new HeaderDictionary { ["Content-Type"] = "image/png" },
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uploads an avatar and records the object key the storage provider actually received, rather
|
||||
/// than re-deriving the key template the handler uses.
|
||||
/// </summary>
|
||||
private async Task UploadAvatarAsync()
|
||||
{
|
||||
Guid userId = GetRegisteredUserId();
|
||||
|
||||
using IServiceScope scope = GetFactory().Services.CreateScope();
|
||||
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
||||
await mediator.Send(new UploadAvatarCommand(userId, CreateAvatarFile()));
|
||||
|
||||
if (Scenario.TryGetValue<string>(CurrentAvatarKeyKey, out string? existing))
|
||||
Scenario[PreviousAvatarKeyKey] = existing;
|
||||
|
||||
Scenario[CurrentAvatarKeyKey] = GetStorageProvider().LastUploadedKey;
|
||||
}
|
||||
|
||||
private MockFileStorageProvider GetStorageProvider()
|
||||
{
|
||||
return (MockFileStorageProvider)
|
||||
GetFactory().Services.GetRequiredService<IFileStorageProvider>();
|
||||
}
|
||||
|
||||
private string GetCurrentAvatarKey()
|
||||
{
|
||||
return Scenario.TryGetValue(CurrentAvatarKeyKey, out string? key) && key is not null
|
||||
? key
|
||||
: throw new InvalidOperationException("no avatar has been uploaded in this scenario");
|
||||
}
|
||||
|
||||
private string GetPreviousAvatarKey()
|
||||
{
|
||||
return Scenario.TryGetValue(PreviousAvatarKeyKey, out string? key) && key is not null
|
||||
? key
|
||||
: throw new InvalidOperationException(
|
||||
"only one avatar has been uploaded in this scenario"
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asserts that <paramref name="avatarUrl" /> is something a browser can fetch -- an absolute
|
||||
/// HTTP(S) URL -- pointing at the object stored under <paramref name="expectedKey" />. A bare
|
||||
/// storage key would resolve against the page's own origin, so the shape is the contract here,
|
||||
/// not just the value.
|
||||
/// </summary>
|
||||
private static Uri AssertFetchableUrl(string? avatarUrl, string expectedKey)
|
||||
{
|
||||
avatarUrl.Should().NotBeNullOrWhiteSpace();
|
||||
|
||||
Uri.TryCreate(avatarUrl, UriKind.Absolute, out Uri? uri)
|
||||
.Should()
|
||||
.BeTrue("avatarUrl must be an absolute URL, but was '{0}'", avatarUrl);
|
||||
uri!.Scheme.Should().BeOneOf("http", "https");
|
||||
uri.AbsolutePath.Should().EndWith(expectedKey, "the URL must address the uploaded object");
|
||||
|
||||
return uri;
|
||||
}
|
||||
|
||||
private string GetAvatarUrlFromResponse()
|
||||
{
|
||||
Scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
|
||||
|
||||
using JsonDocument doc = JsonDocument.Parse(responseBody!);
|
||||
return doc.RootElement.GetProperty("avatarUrl").GetString()!;
|
||||
}
|
||||
|
||||
[Given("I have uploaded an avatar")]
|
||||
public async Task GivenIHaveUploadedAnAvatar()
|
||||
{
|
||||
await UploadAvatarAsync();
|
||||
}
|
||||
|
||||
[Given("I have uploaded a new avatar, replacing the previous one")]
|
||||
public async Task GivenIHaveUploadedANewAvatarReplacingThePreviousOne()
|
||||
{
|
||||
await UploadAvatarAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the biography straight through the repository: registration creates the profile row
|
||||
/// with an empty biography, and no endpoint edits it yet.
|
||||
/// </summary>
|
||||
[Given("I have set my biography to {string}")]
|
||||
public async Task GivenIHaveSetMyBiographyTo(string biography)
|
||||
{
|
||||
using IServiceScope scope = GetFactory().Services.CreateScope();
|
||||
IUserProfileRepository profiles =
|
||||
scope.ServiceProvider.GetRequiredService<IUserProfileRepository>();
|
||||
|
||||
await profiles.UpdateBiographyAsync(
|
||||
GetRegisteredUserId(),
|
||||
biography,
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Scenario[BiographyKey] = biography;
|
||||
}
|
||||
|
||||
[Then("the public profile response should carry my current avatar")]
|
||||
public void ThenThePublicProfileResponseShouldCarryMyCurrentAvatar()
|
||||
{
|
||||
AssertFetchableUrl(GetAvatarUrlFromResponse(), GetCurrentAvatarKey());
|
||||
}
|
||||
|
||||
[Then("the public profile response should not carry my previous avatar")]
|
||||
public void ThenThePublicProfileResponseShouldNotCarryMyPreviousAvatar()
|
||||
{
|
||||
Uri uri = AssertFetchableUrl(GetAvatarUrlFromResponse(), GetCurrentAvatarKey());
|
||||
uri.AbsolutePath.Should().NotContain(GetPreviousAvatarKey());
|
||||
}
|
||||
|
||||
[Then("my listed account should carry my current avatar")]
|
||||
public void ThenMyListedAccountShouldCarryMyCurrentAvatar()
|
||||
{
|
||||
AssertFetchableUrl(
|
||||
GetMyListedAccount().GetProperty("avatarUrl").GetString(),
|
||||
GetCurrentAvatarKey()
|
||||
);
|
||||
}
|
||||
|
||||
[Then("my listed account should carry my biography")]
|
||||
public void ThenMyListedAccountShouldCarryMyBiography()
|
||||
{
|
||||
string expected =
|
||||
Scenario.TryGetValue(BiographyKey, out string? biography) && biography is not null
|
||||
? biography
|
||||
: throw new InvalidOperationException("no biography was set in this scenario");
|
||||
|
||||
GetMyListedAccount().GetProperty("biography").GetString().Should().Be(expected);
|
||||
}
|
||||
|
||||
/// <summary>Finds the registered account in a listing response.</summary>
|
||||
private JsonElement GetMyListedAccount()
|
||||
{
|
||||
Scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
|
||||
|
||||
using JsonDocument doc = JsonDocument.Parse(responseBody!);
|
||||
doc.RootElement.ValueKind.Should().Be(JsonValueKind.Array);
|
||||
|
||||
return doc
|
||||
.RootElement.EnumerateArray()
|
||||
.Should()
|
||||
.ContainSingle(item =>
|
||||
item.GetProperty("userAccountId").GetGuid() == GetRegisteredUserId()
|
||||
)
|
||||
.Subject.Clone();
|
||||
}
|
||||
|
||||
[When("I retrieve the public profile by ID")]
|
||||
public async Task WhenIRetrieveThePublicProfileById()
|
||||
{
|
||||
await SendAsync(
|
||||
NewAuthenticatedRequest(HttpMethod.Get, $"/api/user/{GetRegisteredUserId()}/profile")
|
||||
);
|
||||
}
|
||||
|
||||
[When("I retrieve the public profile by ID without authentication")]
|
||||
public async Task WhenIRetrieveThePublicProfileByIdWithoutAuthentication()
|
||||
{
|
||||
await SendAsync(
|
||||
new HttpRequestMessage(HttpMethod.Get, $"/api/user/{GetRegisteredUserId()}/profile")
|
||||
);
|
||||
}
|
||||
|
||||
[When("I retrieve the public profile by a non-existent ID")]
|
||||
public async Task WhenIRetrieveThePublicProfileByANonExistentId()
|
||||
{
|
||||
await SendAsync(
|
||||
NewAuthenticatedRequest(HttpMethod.Get, $"/api/user/{Guid.NewGuid()}/profile")
|
||||
);
|
||||
}
|
||||
|
||||
[When("I retrieve my own account without authentication")]
|
||||
public async Task WhenIRetrieveMyOwnAccountWithoutAuthentication()
|
||||
{
|
||||
await SendAsync(new HttpRequestMessage(HttpMethod.Get, "/api/user/authenticated"));
|
||||
}
|
||||
|
||||
[When("I list user accounts without authentication")]
|
||||
public async Task WhenIListUserAccountsWithoutAuthentication()
|
||||
{
|
||||
await SendAsync(new HttpRequestMessage(HttpMethod.Get, "/api/user"));
|
||||
}
|
||||
|
||||
[When("I list user accounts")]
|
||||
public async Task WhenIListUserAccounts()
|
||||
{
|
||||
await SendAsync(NewAuthenticatedRequest(HttpMethod.Get, "/api/user"));
|
||||
}
|
||||
|
||||
[When("I retrieve my own account")]
|
||||
public async Task WhenIRetrieveMyOwnAccount()
|
||||
{
|
||||
await SendAsync(NewAuthenticatedRequest(HttpMethod.Get, "/api/user/authenticated"));
|
||||
}
|
||||
|
||||
[Then("the public profile response should match the registered account")]
|
||||
public void ThenThePublicProfileResponseShouldMatchTheRegisteredAccount()
|
||||
{
|
||||
Scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
|
||||
string? username = Scenario.TryGetValue<string>(RegisteredUsernameKey, out string? u)
|
||||
? u
|
||||
: throw new InvalidOperationException("registered username not found in scenario");
|
||||
|
||||
using JsonDocument doc = JsonDocument.Parse(responseBody!);
|
||||
JsonElement root = doc.RootElement;
|
||||
|
||||
root.GetProperty("userAccountId").GetGuid().Should().Be(GetRegisteredUserId());
|
||||
root.GetProperty("username").GetString().Should().Be(username);
|
||||
}
|
||||
|
||||
[Then("the account response should be for a different account than the registered one")]
|
||||
public void ThenTheAccountResponseShouldBeForADifferentAccountThanTheRegisteredOne()
|
||||
{
|
||||
Scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
|
||||
|
||||
using JsonDocument doc = JsonDocument.Parse(responseBody!);
|
||||
doc.RootElement.GetProperty("userAccountId")
|
||||
.GetGuid()
|
||||
.Should()
|
||||
.NotBe(GetRegisteredUserId());
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using API.Core;
|
||||
using API.Specs.Mocks;
|
||||
using Features.Emails.Services;
|
||||
using Features.ImageUploads.Services;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -32,6 +33,15 @@ internal class TestApiFactory : WebApplicationFactory<Program>
|
||||
services.Remove(emailDispatcherDescriptor);
|
||||
|
||||
services.AddScoped<IEmailDispatcher, MockEmailDispatcher>();
|
||||
|
||||
ServiceDescriptor? fileStorageDescriptor = services.SingleOrDefault(d =>
|
||||
d.ServiceType == typeof(IFileStorageProvider)
|
||||
);
|
||||
|
||||
if (fileStorageDescriptor != null)
|
||||
services.Remove(fileStorageDescriptor);
|
||||
|
||||
services.AddSingleton<IFileStorageProvider, MockFileStorageProvider>();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
<Project Path="Domain/Domain.Entities/Domain.Entities.csproj" />
|
||||
<Project Path="Domain/Domain.Exceptions/Domain.Exceptions.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Infrastructure/">
|
||||
<Project Path="Infrastructure/Infrastructure.Configuration/Infrastructure.Configuration.csproj" />
|
||||
<Folder Name="/Configuration/">
|
||||
<Project Path="Configuration/Configuration.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Features/" />
|
||||
<Folder Name="/Features/Users/">
|
||||
@@ -47,5 +47,6 @@
|
||||
<Folder Name="/Shared/">
|
||||
<Project Path="Shared/Shared.Contracts/Shared.Contracts.csproj" />
|
||||
<Project Path="Shared/Shared.Application/Shared.Application.csproj" />
|
||||
<Project Path="Shared/Shared.Application.Tests/Shared.Application.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>Infrastructure.Configuration</RootNamespace>
|
||||
<RootNamespace>Configuration</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -1,6 +1,6 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace Infrastructure.Configuration;
|
||||
namespace Configuration;
|
||||
|
||||
public static class ConfigurationHelpers
|
||||
{
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Infrastructure.Configuration;
|
||||
namespace Configuration;
|
||||
|
||||
public static class ConfigurationKeys
|
||||
{
|
||||
@@ -23,6 +23,14 @@ public static class ConfigurationKeys
|
||||
public const string SmtpFromName = "SMTP_FROM_NAME";
|
||||
|
||||
public const string SeaweedFsServiceUrl = "SEAWEEDFS_SERVICE_URL";
|
||||
|
||||
/// <summary>
|
||||
/// The browser-reachable base URL of the object store, used to sign download URLs. Falls back to
|
||||
/// <see cref="SeaweedFsServiceUrl" /> when unset: SigV4 covers the host, so a URL signed against
|
||||
/// the container-internal endpoint is only usable by callers that can resolve that host.
|
||||
/// </summary>
|
||||
public const string SeaweedFsPublicUrl = "SEAWEEDFS_PUBLIC_URL";
|
||||
|
||||
public const string SeaweedFsAccessKeyId = "SEAWEEDFS_ACCESS_KEY_ID";
|
||||
public const string SeaweedFsSecretAccessKey = "SEAWEEDFS_SECRET_ACCESS_KEY";
|
||||
public const string SeaweedFsBucket = "SEAWEEDFS_BUCKET";
|
||||
@@ -21,6 +21,6 @@
|
||||
/>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" />
|
||||
<ProjectReference Include="..\..\Configuration\Configuration.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Infrastructure.Configuration;
|
||||
using Configuration;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Infrastructure.Configuration;
|
||||
using Configuration;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" />
|
||||
<ProjectReference Include="..\..\Configuration\Configuration.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="scripts/**/*.sql" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using Database.Connection;
|
||||
using Infrastructure.Configuration;
|
||||
using Configuration;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="idunno.Password.Generator" Version="2.0.0" />
|
||||
<PackageReference Include="MediatR" Version="12.4.1" />
|
||||
<PackageReference Include="MediatR" Version="12.5.0" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" />
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.11" />
|
||||
<PackageReference Include="SkiaSharp" Version="4.151.1" />
|
||||
|
||||
@@ -19,7 +19,7 @@ namespace Database.Seed;
|
||||
|
||||
internal class Program
|
||||
{
|
||||
private static IMediator CreateMediator()
|
||||
private static ServiceProvider BuildServiceProvider()
|
||||
{
|
||||
IConfiguration configuration = new ConfigurationBuilder().AddEnvironmentVariables().Build();
|
||||
|
||||
@@ -38,11 +38,10 @@ internal class Program
|
||||
.RegisterServicesFromAssemblyContaining<UploadPhotoCommand>()
|
||||
);
|
||||
|
||||
using ServiceProvider provider = services.BuildServiceProvider();
|
||||
return provider.GetRequiredService<IMediator>();
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
public static async Task Main()
|
||||
public static async Task<int> Main()
|
||||
{
|
||||
using CancellationTokenSource cts = new();
|
||||
Console.CancelKeyPress += (_, e) =>
|
||||
@@ -54,20 +53,24 @@ internal class Program
|
||||
|
||||
try
|
||||
{
|
||||
IMediator mediator = CreateMediator();
|
||||
await using ServiceProvider provider = BuildServiceProvider();
|
||||
IMediator mediator = provider.GetRequiredService<IMediator>();
|
||||
SeedRepository reader = new SeedRepository(
|
||||
connectionString: "Data Source=SeedData/biergarten_seed_2026-08-25T20-45-50.697244Z.sqlite"
|
||||
);
|
||||
await new BiergartenDataSeeder(mediator, reader).Run(cts.Token);
|
||||
return 0;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[yellow]Seeding cancelled.[/]");
|
||||
return 1;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Seeding failed.[/]");
|
||||
AnsiConsole.WriteException(ex);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,13 +58,13 @@ public sealed class SeedRepository : IDisposable
|
||||
}
|
||||
|
||||
private sealed record BreweryRow(
|
||||
int Id,
|
||||
long Id,
|
||||
string NameEn,
|
||||
string DescriptionEn,
|
||||
string NameLocal,
|
||||
string DescriptionLocal,
|
||||
int AddressId,
|
||||
int CityId,
|
||||
long AddressId,
|
||||
long CityId,
|
||||
double Longitude,
|
||||
double Latitude,
|
||||
string AddressLine1,
|
||||
@@ -101,27 +101,27 @@ public sealed class SeedRepository : IDisposable
|
||||
|
||||
return [.. rows.Select(row => new BreweryResult
|
||||
{
|
||||
Id = row.Id,
|
||||
Id = (int)row.Id,
|
||||
NameEn = row.NameEn,
|
||||
DescriptionEn = row.DescriptionEn,
|
||||
NameLocal = row.NameLocal,
|
||||
DescriptionLocal = row.DescriptionLocal,
|
||||
Address = new BreweryAddress
|
||||
{
|
||||
Id = row.AddressId,
|
||||
CityId = row.CityId,
|
||||
BreweryId = row.Id,
|
||||
Id = (int)row.AddressId,
|
||||
CityId = (int)row.CityId,
|
||||
BreweryId = (int)row.Id,
|
||||
Longitude = row.Longitude,
|
||||
Latitude = row.Latitude,
|
||||
AddressLine1 = row.AddressLine1,
|
||||
PostalCode = row.PostalCode,
|
||||
City = cities[row.CityId],
|
||||
City = cities[(int)row.CityId],
|
||||
},
|
||||
})];
|
||||
}
|
||||
|
||||
private sealed record UserRow(
|
||||
int Id,
|
||||
long Id,
|
||||
string FirstName,
|
||||
string LastName,
|
||||
string Gender,
|
||||
@@ -130,8 +130,8 @@ public sealed class SeedRepository : IDisposable
|
||||
double ActivityWeight,
|
||||
string Email,
|
||||
string DateOfBirth,
|
||||
int AddressId,
|
||||
int CityId,
|
||||
long AddressId,
|
||||
long CityId,
|
||||
double Longitude,
|
||||
double Latitude
|
||||
);
|
||||
@@ -172,16 +172,16 @@ public sealed class SeedRepository : IDisposable
|
||||
DateOfBirth = row.DateOfBirth,
|
||||
Address = new UserAddress
|
||||
{
|
||||
Id = row.AddressId,
|
||||
CityId = row.CityId,
|
||||
UserId = row.Id,
|
||||
Id = (int)row.AddressId,
|
||||
CityId = (int)row.CityId,
|
||||
UserId = (int)row.Id,
|
||||
Longitude = row.Longitude,
|
||||
Latitude = row.Latitude,
|
||||
City = cities[row.CityId],
|
||||
City = cities[(int)row.CityId],
|
||||
},
|
||||
User = new UserResult
|
||||
{
|
||||
Id = row.Id,
|
||||
Id = (int)row.Id,
|
||||
FirstName = row.FirstName,
|
||||
LastName = row.LastName,
|
||||
Gender = row.Gender,
|
||||
@@ -193,7 +193,7 @@ public sealed class SeedRepository : IDisposable
|
||||
}
|
||||
|
||||
private sealed record CityRow(
|
||||
int Id,
|
||||
long Id,
|
||||
string CityName,
|
||||
string StateProvince,
|
||||
string Iso31662,
|
||||
@@ -231,10 +231,10 @@ public sealed class SeedRepository : IDisposable
|
||||
IEnumerable<CityRow> rows = await connection.QueryAsync<CityRow>(command);
|
||||
|
||||
return rows.ToDictionary(
|
||||
row => row.Id,
|
||||
row => (int)row.Id,
|
||||
row => new City
|
||||
{
|
||||
Id = row.Id,
|
||||
Id = (int)row.Id,
|
||||
CityName = row.CityName,
|
||||
StateProvinceName = row.StateProvince,
|
||||
ISO_3166_2 = row.Iso31662,
|
||||
|
||||
@@ -5,11 +5,11 @@ COPY . .
|
||||
RUN dotnet restore "Biergarten.API.slnx"
|
||||
RUN dotnet build "Biergarten.API.slnx" -c $BUILD_CONFIGURATION
|
||||
|
||||
# Runs every Features.*.Tests project (unit + DbMocker-backed repository tests). API.Specs is
|
||||
# excluded here since it's a separate BDD/integration suite, run by its own compose service
|
||||
# against the live, seeded database.
|
||||
# Runs every Features.*.Tests and Shared.*.Tests project (unit + DbMocker-backed repository
|
||||
# tests). API.Specs is excluded here since it's a separate BDD/integration suite, run by its own
|
||||
# compose service against the live, seeded database.
|
||||
FROM build AS final
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
RUN mkdir -p /app/test-results
|
||||
ENTRYPOINT ["sh", "-c", "set -e; for proj in Features/*.Tests; do dotnet test \"$proj\" -c ${BUILD_CONFIGURATION:-Release} --logger \"trx;LogFileName=/app/test-results/$(basename \"$proj\").trx\"; done"]
|
||||
ENTRYPOINT ["sh", "-c", "set -e; for proj in Features/*.Tests Shared/*.Tests; do dotnet test \"$proj\" -c ${BUILD_CONFIGURATION:-Release} --logger \"trx;LogFileName=/app/test-results/$(basename \"$proj\").trx\"; done"]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Domain.Entities;
|
||||
|
||||
public sealed class UserAccount
|
||||
@@ -19,4 +21,11 @@ public sealed class UserAccount
|
||||
|
||||
// Navigation properties
|
||||
public UserCredential? UserCredential { get; set; }
|
||||
public UserProfile? UserProfile { get; set; }
|
||||
|
||||
// GET /api/user/authenticated serializes this entity directly, and the avatar graph carries the
|
||||
// storage key and uploader of the underlying Photo. Clients read the avatar from
|
||||
// PublicUserProfileDto.AvatarUrl, which is a signed URL, so keep this one out of the response.
|
||||
[JsonIgnore]
|
||||
public UserAvatar? UserAvatar { get; set; }
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" />
|
||||
<ProjectReference Include="..\..\Configuration\Configuration.csproj" />
|
||||
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
|
||||
<ProjectReference Include="..\Features.Emails.Templates\Features.Emails.Templates.csproj" />
|
||||
<ProjectReference Include="..\Features.Users\Features.Users.csproj" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using Features.Emails.Templates.Rendering;
|
||||
using Infrastructure.Configuration;
|
||||
using Configuration;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace Features.Emails.Services;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Infrastructure.Configuration;
|
||||
using Configuration;
|
||||
using MailKit.Net.Smtp;
|
||||
using MailKit.Security;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
@@ -12,17 +12,13 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AWSSDK.S3" Version="4.0.102.4" />
|
||||
<PackageReference
|
||||
Include="Microsoft.Extensions.Configuration.Abstractions"
|
||||
Version="10.0.1"
|
||||
/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
|
||||
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
|
||||
<ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" />
|
||||
<ProjectReference Include="..\..\Configuration\Configuration.csproj" />
|
||||
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
|
||||
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using Amazon.S3;
|
||||
using Amazon.S3.Model;
|
||||
using Infrastructure.Configuration;
|
||||
using Configuration;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace Features.ImageUploads.Services;
|
||||
@@ -13,11 +13,14 @@ public class S3FileStorageProvider : IFileStorageProvider, IDisposable
|
||||
{
|
||||
private readonly string _bucket;
|
||||
private readonly IAmazonS3 _client;
|
||||
private readonly IAmazonS3 _presignClient;
|
||||
private readonly Protocol _presignProtocol;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="S3FileStorageProvider" /> class, reading storage
|
||||
/// configuration (<c>SEAWEEDFS_SERVICE_URL</c>, <c>SEAWEEDFS_ACCESS_KEY_ID</c>,
|
||||
/// <c>SEAWEEDFS_SECRET_ACCESS_KEY</c>, <c>SEAWEEDFS_BUCKET</c>) from <paramref name="configuration" />.
|
||||
/// <c>SEAWEEDFS_SECRET_ACCESS_KEY</c>, <c>SEAWEEDFS_BUCKET</c>, and the optional
|
||||
/// <c>SEAWEEDFS_PUBLIC_URL</c>) from <paramref name="configuration" />.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Thrown when any of the required configuration values is not set.
|
||||
@@ -45,6 +48,31 @@ public class S3FileStorageProvider : IFileStorageProvider, IDisposable
|
||||
AmazonS3Config config = new() { ServiceURL = serviceUrl, ForcePathStyle = true };
|
||||
|
||||
_client = new AmazonS3Client(accessKeyId, secretAccessKey, config);
|
||||
|
||||
// A presigned URL is signed over its host, so one signed against the container-internal
|
||||
// endpoint is only usable from inside the network. Sign downloads against the public
|
||||
// endpoint instead, so a browser can fetch the object.
|
||||
string? configuredPublicUrl = configuration[ConfigurationKeys.SeaweedFsPublicUrl];
|
||||
string publicUrl = string.IsNullOrWhiteSpace(configuredPublicUrl)
|
||||
? serviceUrl
|
||||
: configuredPublicUrl;
|
||||
|
||||
_presignClient =
|
||||
publicUrl == serviceUrl
|
||||
? _client
|
||||
: new AmazonS3Client(
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
new AmazonS3Config { ServiceURL = publicUrl, ForcePathStyle = true }
|
||||
);
|
||||
|
||||
// GetPreSignedUrlRequest.Protocol defaults to HTTPS and ignores the ServiceURL scheme, so
|
||||
// take the scheme from the endpoint the URL is signed against.
|
||||
_presignProtocol =
|
||||
Uri.TryCreate(publicUrl, UriKind.Absolute, out Uri? publicUri)
|
||||
&& publicUri.Scheme == Uri.UriSchemeHttp
|
||||
? Protocol.HTTP
|
||||
: Protocol.HTTPS;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -82,14 +110,18 @@ public class S3FileStorageProvider : IFileStorageProvider, IDisposable
|
||||
BucketName = _bucket,
|
||||
Key = key,
|
||||
Expires = DateTime.UtcNow.Add(expiresIn),
|
||||
Protocol = _presignProtocol,
|
||||
};
|
||||
|
||||
return _client.GetPreSignedURL(request);
|
||||
return _presignClient.GetPreSignedURL(request);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
if (!ReferenceEquals(_presignClient, _client))
|
||||
_presignClient.Dispose();
|
||||
|
||||
_client.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Features.Users\Features.Users.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" />
|
||||
<ProjectReference Include="..\..\Configuration\Configuration.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using Domain.Entities;
|
||||
using Features.ImageUploads.Services;
|
||||
using Features.Users.Dtos;
|
||||
using Features.Users.Queries.GetAllUsers;
|
||||
using Features.Users.Repository;
|
||||
using FluentAssertions;
|
||||
@@ -8,19 +10,98 @@ namespace Features.Users.Tests.Queries;
|
||||
|
||||
public class GetAllUsersHandlerTests
|
||||
{
|
||||
private readonly Mock<IUserListRepository> _repoMock = new();
|
||||
private readonly Mock<IFileStorageProvider> _storageMock = new();
|
||||
private readonly GetAllUsersHandler _handler;
|
||||
|
||||
public GetAllUsersHandlerTests()
|
||||
{
|
||||
_storageMock
|
||||
.Setup(s => s.GetPresignedUrl(It.IsAny<string>(), It.IsAny<TimeSpan>()))
|
||||
.Returns((string key, TimeSpan _) => $"https://storage.test/{key}?signed");
|
||||
|
||||
_handler = new GetAllUsersHandler(_repoMock.Object, _storageMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_PassesLimitAndOffset_ToRepository()
|
||||
{
|
||||
Mock<IUserListRepository> repoMock = new();
|
||||
GetAllUsersHandler handler = new(repoMock.Object);
|
||||
repoMock.Setup(r => r.GetAllAsync(10, 5)).ReturnsAsync(Array.Empty<UserAccount>());
|
||||
_repoMock.Setup(r => r.GetAllAsync(10, 5)).ReturnsAsync(Array.Empty<UserAccount>());
|
||||
|
||||
IEnumerable<UserAccount> result = await handler.Handle(
|
||||
IEnumerable<PublicUserProfileDto> result = await _handler.Handle(
|
||||
new GetAllUsersQuery(10, 5),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result.Should().BeEmpty();
|
||||
repoMock.Verify(r => r.GetAllAsync(10, 5), Times.Once);
|
||||
_repoMock.Verify(r => r.GetAllAsync(10, 5), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_MapsUsersToPublicProfileDtos_AndExcludesPrivateFields()
|
||||
{
|
||||
UserAccount user = new()
|
||||
{
|
||||
UserAccountId = Guid.NewGuid(),
|
||||
Username = "someone",
|
||||
FirstName = "Some",
|
||||
LastName = "One",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
_repoMock.Setup(r => r.GetAllAsync(null, null)).ReturnsAsync([user]);
|
||||
|
||||
IEnumerable<PublicUserProfileDto> result = await _handler.Handle(
|
||||
new GetAllUsersQuery(null, null),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result
|
||||
.Should()
|
||||
.ContainSingle()
|
||||
.Which.Should()
|
||||
.BeEquivalentTo(user.ToPublicProfileDto(_storageMock.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_MapsEachUsersOwnAvatarUrl()
|
||||
{
|
||||
string avatarKey = $"avatars/{Guid.NewGuid()}/{Guid.NewGuid()}";
|
||||
UserAccount withAvatar = new()
|
||||
{
|
||||
UserAccountId = Guid.NewGuid(),
|
||||
Username = "has-avatar",
|
||||
FirstName = "Has",
|
||||
LastName = "Avatar",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UserProfile = new UserProfile { Biography = "Drinks lagers." },
|
||||
UserAvatar = new UserAvatar { Photo = new Photo { Hyperlink = avatarKey } },
|
||||
};
|
||||
UserAccount withoutAvatar = new()
|
||||
{
|
||||
UserAccountId = Guid.NewGuid(),
|
||||
Username = "no-avatar",
|
||||
FirstName = "No",
|
||||
LastName = "Avatar",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
_repoMock.Setup(r => r.GetAllAsync(null, null)).ReturnsAsync([withAvatar, withoutAvatar]);
|
||||
|
||||
IEnumerable<PublicUserProfileDto> result = await _handler.Handle(
|
||||
new GetAllUsersQuery(null, null),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result
|
||||
.Select(dto => dto.AvatarUrl)
|
||||
.Should()
|
||||
.Equal($"https://storage.test/{avatarKey}?signed", string.Empty);
|
||||
_storageMock.Verify(
|
||||
s => s.GetPresignedUrl(avatarKey, UserDtoMapper.AvatarUrlLifetime),
|
||||
Times.Once
|
||||
);
|
||||
_storageMock.Verify(
|
||||
s => s.GetPresignedUrl(It.IsAny<string>(), It.IsAny<TimeSpan>()),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Features.ImageUploads.Services;
|
||||
using Features.Users.Dtos;
|
||||
using Features.Users.Queries.GetPublicUserProfileById;
|
||||
using Features.Users.Repository;
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
|
||||
namespace Features.Users.Tests.Queries;
|
||||
|
||||
public class GetPublicUserProfileByIdHandlerTests
|
||||
{
|
||||
private readonly GetPublicUserProfileByIdHandler _handler;
|
||||
private readonly Mock<IUserListRepository> _repoMock = new();
|
||||
private readonly Mock<IFileStorageProvider> _storageMock = new();
|
||||
|
||||
public GetPublicUserProfileByIdHandlerTests()
|
||||
{
|
||||
_storageMock
|
||||
.Setup(s => s.GetPresignedUrl(It.IsAny<string>(), It.IsAny<TimeSpan>()))
|
||||
.Returns((string key, TimeSpan _) => $"https://storage.test/{key}?signed");
|
||||
|
||||
_handler = new GetPublicUserProfileByIdHandler(_repoMock.Object, _storageMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ReturnsPublicProfile_WhenFound()
|
||||
{
|
||||
string avatarKey = $"avatars/{Guid.NewGuid()}/{Guid.NewGuid()}";
|
||||
UserAccount user = new()
|
||||
{
|
||||
UserAccountId = Guid.NewGuid(),
|
||||
Username = "test",
|
||||
FirstName = "Jane",
|
||||
LastName = "Doe",
|
||||
Email = "jane@example.com",
|
||||
DateOfBirth = new DateTime(1990, 1, 1),
|
||||
CreatedAt = new DateTime(2024, 1, 1),
|
||||
UserProfile = new UserProfile { Biography = "Drinks lagers." },
|
||||
UserAvatar = new UserAvatar { Photo = new Photo { Hyperlink = avatarKey } },
|
||||
};
|
||||
_repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user);
|
||||
|
||||
PublicUserProfileDto result = await _handler.Handle(
|
||||
new GetPublicUserProfileByIdQuery(user.UserAccountId),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result
|
||||
.Should()
|
||||
.Be(
|
||||
new PublicUserProfileDto(
|
||||
user.UserAccountId,
|
||||
user.Username,
|
||||
user.FirstName,
|
||||
user.LastName,
|
||||
"Drinks lagers.",
|
||||
$"https://storage.test/{avatarKey}?signed",
|
||||
user.CreatedAt
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>Photo.Hyperlink</c> holds an object key, so the DTO has to carry a signed URL rather than
|
||||
/// the stored value: a bare key resolves against the calling page's own origin.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Handle_SignsTheAvatarKey_RatherThanReturningIt()
|
||||
{
|
||||
string avatarKey = $"avatars/{Guid.NewGuid()}/{Guid.NewGuid()}";
|
||||
UserAccount user = new()
|
||||
{
|
||||
UserAccountId = Guid.NewGuid(),
|
||||
Username = "test",
|
||||
FirstName = "Jane",
|
||||
LastName = "Doe",
|
||||
CreatedAt = new DateTime(2024, 1, 1),
|
||||
UserAvatar = new UserAvatar { Photo = new Photo { Hyperlink = avatarKey } },
|
||||
};
|
||||
_repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user);
|
||||
|
||||
PublicUserProfileDto result = await _handler.Handle(
|
||||
new GetPublicUserProfileByIdQuery(user.UserAccountId),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result.AvatarUrl.Should().NotBe(avatarKey);
|
||||
Uri.TryCreate(result.AvatarUrl, UriKind.Absolute, out Uri? uri).Should().BeTrue();
|
||||
uri!.Scheme.Should().BeOneOf("http", "https");
|
||||
uri.AbsolutePath.Should().EndWith(avatarKey);
|
||||
_storageMock.Verify(
|
||||
s => s.GetPresignedUrl(avatarKey, UserDtoMapper.AvatarUrlLifetime),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ReturnsEmptyBiographyAndAvatarUrl_WhenProfileMissing()
|
||||
{
|
||||
UserAccount user = new()
|
||||
{
|
||||
UserAccountId = Guid.NewGuid(),
|
||||
Username = "test",
|
||||
FirstName = "Jane",
|
||||
LastName = "Doe",
|
||||
CreatedAt = new DateTime(2024, 1, 1),
|
||||
};
|
||||
_repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user);
|
||||
|
||||
PublicUserProfileDto result = await _handler.Handle(
|
||||
new GetPublicUserProfileByIdQuery(user.UserAccountId),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result.Biography.Should().BeEmpty();
|
||||
result.AvatarUrl.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ReturnsEmptyAvatarUrl_WhenProfileHasNoAvatar()
|
||||
{
|
||||
UserAccount user = new()
|
||||
{
|
||||
UserAccountId = Guid.NewGuid(),
|
||||
Username = "test",
|
||||
FirstName = "Jane",
|
||||
LastName = "Doe",
|
||||
CreatedAt = new DateTime(2024, 1, 1),
|
||||
UserProfile = new UserProfile { Biography = "Drinks lagers." },
|
||||
};
|
||||
_repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user);
|
||||
|
||||
PublicUserProfileDto result = await _handler.Handle(
|
||||
new GetPublicUserProfileByIdQuery(user.UserAccountId),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result.Biography.Should().Be("Drinks lagers.");
|
||||
result.AvatarUrl.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ReturnsEmptyAvatarUrl_WhenAvatarHasNoPhoto()
|
||||
{
|
||||
UserAccount user = new()
|
||||
{
|
||||
UserAccountId = Guid.NewGuid(),
|
||||
Username = "test",
|
||||
FirstName = "Jane",
|
||||
LastName = "Doe",
|
||||
CreatedAt = new DateTime(2024, 1, 1),
|
||||
UserProfile = new UserProfile { Biography = "Drinks lagers." },
|
||||
UserAvatar = new UserAvatar(),
|
||||
};
|
||||
_repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user);
|
||||
|
||||
PublicUserProfileDto result = await _handler.Handle(
|
||||
new GetPublicUserProfileByIdQuery(user.UserAccountId),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result.AvatarUrl.Should().BeEmpty();
|
||||
_storageMock.Verify(
|
||||
s => s.GetPresignedUrl(It.IsAny<string>(), It.IsAny<TimeSpan>()),
|
||||
Times.Never
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_Throws_WhenNotFound()
|
||||
{
|
||||
Guid id = Guid.NewGuid();
|
||||
_repoMock.Setup(r => r.GetByIdAsync(id)).ReturnsAsync((UserAccount?)null);
|
||||
|
||||
Func<Task<PublicUserProfileDto>> act = async () =>
|
||||
await _handler.Handle(new GetPublicUserProfileByIdQuery(id), CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<NotFoundException>();
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ using Features.Users.Identity;
|
||||
using Features.Users.Services;
|
||||
using Features.Users.Tests.TestSupport;
|
||||
using FluentAssertions;
|
||||
using Infrastructure.Configuration;
|
||||
using Configuration;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Moq;
|
||||
|
||||
@@ -4,7 +4,7 @@ using Domain.Exceptions;
|
||||
using Features.Users.Services;
|
||||
using Features.Users.Tests.TestSupport;
|
||||
using FluentAssertions;
|
||||
using Infrastructure.Configuration;
|
||||
using Configuration;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Moq;
|
||||
|
||||
|
||||
@@ -1,40 +1,74 @@
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Features.Users.Dtos;
|
||||
using Features.Users.Queries.GetAllUsers;
|
||||
using Features.Users.Queries.GetPublicUserProfileById;
|
||||
using Features.Users.Queries.GetUserById;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Shared.Contracts;
|
||||
|
||||
namespace Features.Users.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Exposes read endpoints for user accounts.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every endpoint requires <c>[Authorize(AuthenticationSchemes = "JWT")]</c> except
|
||||
/// <see cref="GetPublicProfile" />, which is <see cref="AllowAnonymousAttribute" /> since it
|
||||
/// only returns an allowlisted DTO. <see cref="GetAuthenticated" /> serves the caller's own
|
||||
/// account, taken from the access token. Neither <see cref="GetAll" /> nor
|
||||
/// <see cref="GetPublicProfile" /> may expose another user's <c>Email</c> or
|
||||
/// <c>DateOfBirth</c>.
|
||||
/// </remarks>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize(AuthenticationSchemes = "JWT")]
|
||||
public class UserController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
/// <summary>Gets a page of user accounts, ordered by creation date descending.</summary>
|
||||
/// <summary>Gets a page of user accounts' public profiles, ordered by creation date descending.</summary>
|
||||
/// <param name="limit"><c>null</c> for no limit.</param>
|
||||
/// <param name="offset"><c>null</c> for no offset.</param>
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<IEnumerable<UserAccount>>> GetAll(
|
||||
public async Task<ActionResult<IEnumerable<PublicUserProfileDto>>> GetAll(
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] int? offset
|
||||
)
|
||||
{
|
||||
IEnumerable<UserAccount> users = await mediator.Send(new GetAllUsersQuery(limit, offset));
|
||||
IEnumerable<PublicUserProfileDto> users = await mediator.Send(
|
||||
new GetAllUsersQuery(limit, offset)
|
||||
);
|
||||
return Ok(users);
|
||||
}
|
||||
|
||||
/// <summary>Gets the user account with the given ID.</summary>
|
||||
/// <summary>Gets the authenticated caller's own user account.</summary>
|
||||
/// <exception cref="NotFoundException">
|
||||
/// Thrown when no user account exists with the given ID, resulting in a 404 response.
|
||||
/// Thrown when no user account exists for the caller's ID, resulting in a 404 response.
|
||||
/// </exception>
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<ActionResult<UserAccount>> GetById(Guid id)
|
||||
[HttpGet("authenticated")]
|
||||
public async Task<ActionResult<UserAccount>> GetAuthenticated()
|
||||
{
|
||||
UserAccount user = await mediator.Send(new GetUserByIdQuery(id));
|
||||
UserAccount user = await mediator.Send(
|
||||
new GetUserByIdQuery(User.GetAuthenticatedUserId())
|
||||
);
|
||||
return Ok(user);
|
||||
}
|
||||
|
||||
/// <summary>Gets the public profile for the user account with the given ID.</summary>
|
||||
/// <remarks>
|
||||
/// This endpoint is anonymous-accessible and requires no ownership. Anyone can view any
|
||||
/// account's public profile. The response includes only fields that are safe to show publicly.
|
||||
/// Unlike <see cref="GetAuthenticated"/>, it never includes <c>Email</c> or <c>DateOfBirth</c>.
|
||||
/// </remarks>
|
||||
/// <exception cref="NotFoundException">
|
||||
/// Thrown when no user account exists with the given ID. The API returns HTTP status 404.
|
||||
/// </exception>
|
||||
[HttpGet("{id:guid}/profile")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult<PublicUserProfileDto>> GetPublicProfile(Guid id)
|
||||
{
|
||||
PublicUserProfileDto profile = await mediator.Send(new GetPublicUserProfileByIdQuery(id));
|
||||
return Ok(profile);
|
||||
}
|
||||
}
|
||||
|
||||
40
web/backend/Features/Features.Users/Dtos/UserDtoMapper.cs
Normal file
40
web/backend/Features/Features.Users/Dtos/UserDtoMapper.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
using Domain.Entities;
|
||||
using Features.ImageUploads.Services;
|
||||
|
||||
namespace Features.Users.Dtos;
|
||||
|
||||
/// <summary>Converts user domain entities into API response models.</summary>
|
||||
public static class UserDtoMapper
|
||||
{
|
||||
/// <summary>
|
||||
/// How long a presigned avatar URL stays valid. Long enough to outlive the page that embeds it,
|
||||
/// short enough that a leaked URL stops working.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan AvatarUrlLifetime = TimeSpan.FromHours(1);
|
||||
|
||||
/// <summary>Converts a user account into its public-profile representation.</summary>
|
||||
/// <param name="user">The account to convert.</param>
|
||||
/// <param name="fileStorageProvider">
|
||||
/// Signs the download URL for the account's avatar. <c>Photo.Hyperlink</c> holds an object key
|
||||
/// rather than a URL, so it has to be signed before a client can fetch it.
|
||||
/// </param>
|
||||
public static PublicUserProfileDto ToPublicProfileDto(
|
||||
this UserAccount user,
|
||||
IFileStorageProvider fileStorageProvider
|
||||
)
|
||||
{
|
||||
string? avatarKey = user.UserAvatar?.Photo?.Hyperlink;
|
||||
|
||||
return new PublicUserProfileDto(
|
||||
UserAccountId : user.UserAccountId,
|
||||
Username : user.Username,
|
||||
FirstName : user.FirstName,
|
||||
LastName : user.LastName,
|
||||
Biography : user.UserProfile?.Biography ?? string.Empty,
|
||||
AvatarUrl : string.IsNullOrWhiteSpace(avatarKey)
|
||||
? string.Empty
|
||||
: fileStorageProvider.GetPresignedUrl(avatarKey, AvatarUrlLifetime),
|
||||
CreatedAt : user.CreatedAt
|
||||
);
|
||||
}
|
||||
}
|
||||
12
web/backend/Features/Features.Users/Dtos/UserDtos.cs
Normal file
12
web/backend/Features/Features.Users/Dtos/UserDtos.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace Features.Users.Dtos;
|
||||
|
||||
/// <summary>Represents the subset of a user account that is safe to expose on a public profile.</summary>
|
||||
public record PublicUserProfileDto(
|
||||
Guid UserAccountId,
|
||||
string Username,
|
||||
string FirstName,
|
||||
string LastName,
|
||||
string Biography,
|
||||
string AvatarUrl,
|
||||
DateTime CreatedAt
|
||||
);
|
||||
@@ -14,7 +14,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
|
||||
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" />
|
||||
<ProjectReference Include="..\..\Configuration\Configuration.csproj" />
|
||||
<ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" />
|
||||
<ProjectReference Include="..\Features.PhotoUpload\Features.PhotoUpload.csproj" />
|
||||
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
using Domain.Entities;
|
||||
using Features.ImageUploads.Services;
|
||||
using Features.Users.Dtos;
|
||||
using Features.Users.Repository;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Users.Queries.GetAllUsers;
|
||||
|
||||
public class GetAllUsersHandler(IUserListRepository repository)
|
||||
: IRequestHandler<GetAllUsersQuery, IEnumerable<UserAccount>>
|
||||
public class GetAllUsersHandler(
|
||||
IUserListRepository repository,
|
||||
IFileStorageProvider fileStorageProvider
|
||||
) : IRequestHandler<GetAllUsersQuery, IEnumerable<PublicUserProfileDto>>
|
||||
{
|
||||
public Task<IEnumerable<UserAccount>> Handle(
|
||||
public async Task<IEnumerable<PublicUserProfileDto>> Handle(
|
||||
GetAllUsersQuery request,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
return repository.GetAllAsync(request.Limit, request.Offset);
|
||||
IEnumerable<UserAccount> users = await repository.GetAllAsync(request.Limit, request.Offset);
|
||||
return users.Select(user => user.ToPublicProfileDto(fileStorageProvider));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
using Domain.Entities;
|
||||
using Features.Users.Dtos;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Users.Queries.GetAllUsers;
|
||||
|
||||
/// <summary>
|
||||
/// Requests a page of user accounts, ordered by creation date descending.
|
||||
/// Requests a page of user accounts' public profiles, ordered by creation date descending.
|
||||
/// </summary>
|
||||
/// <param name="Limit"><c>null</c> for no limit.</param>
|
||||
/// <param name="Offset"><c>null</c> for no offset.</param>
|
||||
public record GetAllUsersQuery(int? Limit, int? Offset) : IRequest<IEnumerable<UserAccount>>;
|
||||
public record GetAllUsersQuery(int? Limit, int? Offset) : IRequest<IEnumerable<PublicUserProfileDto>>;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Features.ImageUploads.Services;
|
||||
using Features.Users.Dtos;
|
||||
using Features.Users.Repository;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Users.Queries.GetPublicUserProfileById;
|
||||
|
||||
public class GetPublicUserProfileByIdHandler(
|
||||
IUserListRepository repository,
|
||||
IFileStorageProvider fileStorageProvider
|
||||
) : IRequestHandler<GetPublicUserProfileByIdQuery, PublicUserProfileDto>
|
||||
{
|
||||
/// <exception cref="NotFoundException">Thrown when no user account exists with the given ID.</exception>
|
||||
public async Task<PublicUserProfileDto> Handle(
|
||||
GetPublicUserProfileByIdQuery request,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
UserAccount? user = await repository.GetByIdAsync(request.UserAccountId);
|
||||
if (user is null)
|
||||
throw new NotFoundException($"User with ID {request.UserAccountId} not found");
|
||||
return user.ToPublicProfileDto(fileStorageProvider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using Features.Users.Dtos;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Users.Queries.GetPublicUserProfileById;
|
||||
|
||||
public record GetPublicUserProfileByIdQuery(Guid UserAccountId) : IRequest<PublicUserProfileDto>;
|
||||
@@ -3,4 +3,7 @@ using MediatR;
|
||||
|
||||
namespace Features.Users.Queries.GetUserById;
|
||||
|
||||
/// <param name="UserAccountId">
|
||||
/// The account to fetch. This query returns private fields, including <c>Email</c>.
|
||||
/// </param>
|
||||
public record GetUserByIdQuery(Guid UserAccountId) : IRequest<UserAccount>;
|
||||
|
||||
@@ -13,38 +13,102 @@ public class UserListRepository(ISqlConnectionFactory connectionFactory)
|
||||
IUserListRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Left-joins <c>Social.UserProfile</c>, so <see cref="UserAccount.UserProfile" /> is
|
||||
/// <c>null</c> for an account whose profile has yet to be created, and left-joins the
|
||||
/// account's active <c>Social.UserAvatar</c> (<c>ValidTo IS NULL</c>) with its
|
||||
/// <c>Media.Photo</c>, so <see cref="UserAccount.UserAvatar" /> is <c>null</c> for an account
|
||||
/// with no avatar.
|
||||
/// </remarks>
|
||||
public async Task<UserAccount?> GetByIdAsync(Guid id)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
return await connection.QueryFirstOrDefaultAsync<UserAccount>(
|
||||
IEnumerable<UserAccount> results = await connection.QueryAsync<
|
||||
UserAccount,
|
||||
UserProfile,
|
||||
Photo,
|
||||
UserAvatar,
|
||||
UserAccount
|
||||
>(
|
||||
"""
|
||||
SELECT
|
||||
UserAccountID,
|
||||
Username,
|
||||
FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion
|
||||
FROM Auth.UserAccount
|
||||
WHERE UserAccountID = @UserAccountId
|
||||
ua.UserAccountID, ua.Username, ua.FirstName, ua.LastName,
|
||||
ua.Email, ua.CreatedAt, ua.UpdatedAt, ua.DateOfBirth,
|
||||
ua.RowVersion, up.UserProfileID, up.UserAccountID,
|
||||
up.Biography, up.RowVersion, p.PhotoID, p.UploadedByID,
|
||||
p.Hyperlink, p.UploadedAt, p.RowVersion, av.UserAvatarID,
|
||||
av.UserProfileID, av.PhotoID, av.ValidFrom, av.ValidTo,
|
||||
av.RowVersion
|
||||
FROM Auth.UserAccount ua
|
||||
LEFT JOIN Social.UserProfile up
|
||||
ON ua.UserAccountID = up.UserAccountID
|
||||
LEFT JOIN Social.UserAvatar av
|
||||
ON up.UserProfileID = av.UserProfileID
|
||||
AND av.ValidTo IS NULL
|
||||
LEFT JOIN Media.Photo p ON av.PhotoID = p.PhotoID
|
||||
WHERE ua.UserAccountID = @UserAccountId
|
||||
""",
|
||||
new { UserAccountId = id }
|
||||
MapUserRow,
|
||||
new { UserAccountId = id },
|
||||
splitOn: AvatarSplitOn
|
||||
);
|
||||
|
||||
return results.SingleOrDefault();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Always applies pagination via <c>OFFSET</c>/<c>FETCH</c>, ordered by creation date descending.
|
||||
/// A <c>null</c> <paramref name="limit" />/<paramref name="offset" /> is treated as "no limit"/"no offset".
|
||||
/// Joins the same profile and active-avatar rows as <see cref="GetByIdAsync" />, both of which are
|
||||
/// part of a public profile. Does not select <c>Email</c> or <c>DateOfBirth</c>: this listing is
|
||||
/// consumed only as public profiles, so those fields are left at their default rather than read
|
||||
/// from the database.
|
||||
/// </remarks>
|
||||
public async Task<IEnumerable<UserAccount>> GetAllAsync(int? limit, int? offset)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
return await connection.QueryAsync<UserAccount>(
|
||||
return await connection.QueryAsync<
|
||||
UserAccount,
|
||||
UserProfile,
|
||||
Photo,
|
||||
UserAvatar,
|
||||
UserAccount
|
||||
>(
|
||||
"""
|
||||
SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion
|
||||
FROM Auth.UserAccount
|
||||
ORDER BY CreatedAt DESC
|
||||
SELECT
|
||||
ua.UserAccountID, ua.Username, ua.FirstName, ua.LastName,
|
||||
ua.CreatedAt, ua.UpdatedAt, ua.RowVersion,
|
||||
up.UserProfileID, up.UserAccountID, up.Biography, up.RowVersion,
|
||||
p.PhotoID, p.UploadedByID, p.Hyperlink, p.UploadedAt, p.RowVersion,
|
||||
av.UserAvatarID, av.UserProfileID, av.PhotoID, av.ValidFrom, av.ValidTo,
|
||||
av.RowVersion
|
||||
FROM Auth.UserAccount ua
|
||||
LEFT JOIN Social.UserProfile up ON ua.UserAccountID = up.UserAccountID
|
||||
LEFT JOIN Social.UserAvatar av
|
||||
ON up.UserProfileID = av.UserProfileID AND av.ValidTo IS NULL
|
||||
LEFT JOIN Media.Photo p ON av.PhotoID = p.PhotoID
|
||||
ORDER BY ua.CreatedAt DESC
|
||||
OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY
|
||||
""",
|
||||
new { Offset = offset ?? 0, Limit = limit ?? int.MaxValue }
|
||||
MapUserRow,
|
||||
new { Offset = offset ?? 0, Limit = limit ?? int.MaxValue },
|
||||
splitOn: AvatarSplitOn
|
||||
);
|
||||
}
|
||||
|
||||
private const string AvatarSplitOn = "UserProfileID,PhotoID,UserAvatarID";
|
||||
|
||||
private static UserAccount MapUserRow(
|
||||
UserAccount user,
|
||||
UserProfile? profile,
|
||||
Photo? photo,
|
||||
UserAvatar? avatar
|
||||
)
|
||||
{
|
||||
user.UserProfile = profile;
|
||||
avatar?.Photo = photo;
|
||||
user.UserAvatar = avatar;
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using Domain.Exceptions;
|
||||
using Features.Users.Identity;
|
||||
using Infrastructure.Configuration;
|
||||
using Configuration;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
using MediatR;
|
||||
|
||||
namespace Shared.Application.Tests.Behaviors;
|
||||
|
||||
// ReSharper disable NotAccessedPositionalProperty.Global
|
||||
public sealed record TestRequest(string Username, string Password) : IRequest<TestResponse>;
|
||||
|
||||
public sealed record TestResponse(string Value);
|
||||
// ReSharper restore NotAccessedPositionalProperty.Global
|
||||
@@ -0,0 +1,110 @@
|
||||
using FluentAssertions;
|
||||
using FluentValidation;
|
||||
using FluentValidation.Results;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Shared.Application.Behaviors;
|
||||
|
||||
namespace Shared.Application.Tests.Behaviors;
|
||||
|
||||
public class UnhandledExceptionLoggingBehaviorTests
|
||||
{
|
||||
private readonly Mock<
|
||||
ILogger<UnhandledExceptionLoggingBehavior<TestRequest, TestResponse>>
|
||||
> _loggerMock = new();
|
||||
|
||||
private UnhandledExceptionLoggingBehavior<TestRequest, TestResponse> CreateBehavior()
|
||||
{
|
||||
_loggerMock.Setup(l => l.IsEnabled(LogLevel.Error)).Returns(true);
|
||||
return new(_loggerMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenHandlerThrowsGenericException_LogsOnceAndRethrows()
|
||||
{
|
||||
UnhandledExceptionLoggingBehavior<TestRequest, TestResponse> behavior = CreateBehavior();
|
||||
TestRequest request = new("aaron", "pa$$word123");
|
||||
InvalidOperationException thrown = new("Something went wrong.");
|
||||
Task<TestResponse> next(CancellationToken _ = default) => throw thrown;
|
||||
|
||||
Func<Task> act = async () => await behavior.Handle(request, next, CancellationToken.None);
|
||||
|
||||
(await act.Should().ThrowAsync<InvalidOperationException>())
|
||||
.Which.Should()
|
||||
.BeSameAs(thrown);
|
||||
VerifyLogError(Times.Once());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenValidationExceptionThrown_DoesNotLogAndRethrows()
|
||||
{
|
||||
UnhandledExceptionLoggingBehavior<TestRequest, TestResponse> behavior = CreateBehavior();
|
||||
TestRequest request = new("aaron", "pa$$word123");
|
||||
ValidationException thrown = new([new ValidationFailure("Username", "is required")]);
|
||||
Task<TestResponse> next(CancellationToken _ = default) => throw thrown;
|
||||
|
||||
Func<Task> act = async () => await behavior.Handle(request, next, CancellationToken.None);
|
||||
|
||||
(await act.Should().ThrowAsync<ValidationException>()).Which.Should().BeSameAs(thrown);
|
||||
VerifyLogError(Times.Never());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenHandlerSucceeds_DoesNotLogAndReturnsResponse()
|
||||
{
|
||||
UnhandledExceptionLoggingBehavior<TestRequest, TestResponse> behavior = CreateBehavior();
|
||||
TestRequest request = new("aaron", "pa$$word123");
|
||||
TestResponse response = new("ok");
|
||||
Task<TestResponse> next(CancellationToken _ = default) => Task.FromResult(response);
|
||||
|
||||
TestResponse result = await behavior.Handle(request, next, CancellationToken.None);
|
||||
|
||||
result.Should().BeSameAs(response);
|
||||
VerifyLogError(Times.Never());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenHandlerThrows_RedactsSensitiveFieldsInLoggedPayload()
|
||||
{
|
||||
UnhandledExceptionLoggingBehavior<TestRequest, TestResponse> behavior = CreateBehavior();
|
||||
TestRequest request = new("aaron", "pa$$word123");
|
||||
Task<TestResponse> next(CancellationToken _ = default) =>
|
||||
throw new InvalidOperationException("Something went wrong.");
|
||||
|
||||
Func<Task> act = async () => await behavior.Handle(request, next, CancellationToken.None);
|
||||
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||
|
||||
string loggedMessage = CaptureLoggedMessage();
|
||||
loggedMessage.Should().Contain("aaron").And.NotContain("pa$$word123");
|
||||
}
|
||||
|
||||
private void VerifyLogError(Times times) =>
|
||||
_loggerMock.Verify(
|
||||
l =>
|
||||
l.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.IsAny<It.IsAnyType>(),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
times
|
||||
);
|
||||
|
||||
private string CaptureLoggedMessage()
|
||||
{
|
||||
IInvocation? invocation = _loggerMock
|
||||
.Invocations.Where(i => i.Method.Name == nameof(ILogger.Log))
|
||||
.Should()
|
||||
.ContainSingle()
|
||||
.Subject;
|
||||
|
||||
object state = invocation.Arguments[2];
|
||||
object exception = invocation.Arguments[3];
|
||||
object formatter = invocation.Arguments[4];
|
||||
System.Reflection.MethodInfo invokeMethod = formatter.GetType().GetMethod("Invoke")!;
|
||||
|
||||
return (string)invokeMethod.Invoke(formatter, [state, exception])!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
using FluentAssertions;
|
||||
using FluentValidation;
|
||||
using FluentValidation.Results;
|
||||
using MediatR;
|
||||
using Moq;
|
||||
using Shared.Application.Behaviors;
|
||||
|
||||
namespace Shared.Application.Tests.Behaviors;
|
||||
|
||||
public class ValidationBehaviorTests
|
||||
{
|
||||
private static ValidationBehavior<TestRequest, TestResponse> CreateBehavior(
|
||||
params IValidator<TestRequest>[] validators
|
||||
) => new(validators);
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenRequestPassesAllValidators_InvokesNextUnchanged()
|
||||
{
|
||||
Mock<IValidator<TestRequest>> validatorMock = new();
|
||||
validatorMock
|
||||
.Setup(v =>
|
||||
v.ValidateAsync(
|
||||
It.IsAny<ValidationContext<TestRequest>>(),
|
||||
It.IsAny<CancellationToken>()
|
||||
)
|
||||
)
|
||||
.ReturnsAsync(new ValidationResult());
|
||||
ValidationBehavior<TestRequest, TestResponse> behavior = CreateBehavior(
|
||||
validatorMock.Object
|
||||
);
|
||||
TestRequest request = new("aaron", "pa$$word123");
|
||||
TestResponse response = new("ok");
|
||||
bool nextInvoked = false;
|
||||
Task<TestResponse> next(CancellationToken _ = default)
|
||||
{
|
||||
nextInvoked = true;
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
|
||||
TestResponse result = await behavior.Handle(request, next, CancellationToken.None);
|
||||
|
||||
nextInvoked.Should().BeTrue();
|
||||
result.Should().BeSameAs(response);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenValidatorReportsFailures_ThrowsValidationExceptionAndNeverInvokesNext()
|
||||
{
|
||||
ValidationFailure failure = new("Username", "is required");
|
||||
Mock<IValidator<TestRequest>> validatorMock = new();
|
||||
validatorMock
|
||||
.Setup(v =>
|
||||
v.ValidateAsync(
|
||||
It.IsAny<ValidationContext<TestRequest>>(),
|
||||
It.IsAny<CancellationToken>()
|
||||
)
|
||||
)
|
||||
.ReturnsAsync(new ValidationResult([failure]));
|
||||
ValidationBehavior<TestRequest, TestResponse> behavior = CreateBehavior(
|
||||
validatorMock.Object
|
||||
);
|
||||
TestRequest request = new("", "pa$$word123");
|
||||
bool nextInvoked = false;
|
||||
Task<TestResponse> next(CancellationToken _ = default)
|
||||
{
|
||||
nextInvoked = true;
|
||||
return Task.FromResult(new TestResponse("ok"));
|
||||
}
|
||||
|
||||
Func<Task> act = async () => await behavior.Handle(request, next, CancellationToken.None);
|
||||
|
||||
(await act.Should().ThrowAsync<ValidationException>())
|
||||
.Which.Errors.Should()
|
||||
.ContainSingle()
|
||||
.Which.Should()
|
||||
.BeSameAs(failure);
|
||||
nextInvoked.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenMultipleValidatorsAreRegistered_RunsAllOfThem()
|
||||
{
|
||||
Mock<IValidator<TestRequest>> firstValidatorMock = new();
|
||||
firstValidatorMock
|
||||
.Setup(v =>
|
||||
v.ValidateAsync(
|
||||
It.IsAny<ValidationContext<TestRequest>>(),
|
||||
It.IsAny<CancellationToken>()
|
||||
)
|
||||
)
|
||||
.ReturnsAsync(new ValidationResult());
|
||||
Mock<IValidator<TestRequest>> secondValidatorMock = new();
|
||||
secondValidatorMock
|
||||
.Setup(v =>
|
||||
v.ValidateAsync(
|
||||
It.IsAny<ValidationContext<TestRequest>>(),
|
||||
It.IsAny<CancellationToken>()
|
||||
)
|
||||
)
|
||||
.ReturnsAsync(new ValidationResult());
|
||||
ValidationBehavior<TestRequest, TestResponse> behavior = CreateBehavior(
|
||||
firstValidatorMock.Object,
|
||||
secondValidatorMock.Object
|
||||
);
|
||||
TestRequest request = new("aaron", "pa$$word123");
|
||||
static Task<TestResponse> next(CancellationToken _ = default) =>
|
||||
Task.FromResult(new TestResponse("ok"));
|
||||
|
||||
await behavior.Handle(request, next, CancellationToken.None);
|
||||
|
||||
firstValidatorMock.Verify(
|
||||
v =>
|
||||
v.ValidateAsync(
|
||||
It.IsAny<ValidationContext<TestRequest>>(),
|
||||
It.IsAny<CancellationToken>()
|
||||
),
|
||||
Times.Once()
|
||||
);
|
||||
secondValidatorMock.Verify(
|
||||
v =>
|
||||
v.ValidateAsync(
|
||||
It.IsAny<ValidationContext<TestRequest>>(),
|
||||
It.IsAny<CancellationToken>()
|
||||
),
|
||||
Times.Once()
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenMultipleValidatorsReportFailures_AggregatesErrorsFromAll()
|
||||
{
|
||||
ValidationFailure firstFailure = new("Username", "is required");
|
||||
ValidationFailure secondFailure = new("Username", "must be at least 3 characters");
|
||||
Mock<IValidator<TestRequest>> firstValidatorMock = new();
|
||||
firstValidatorMock
|
||||
.Setup(v =>
|
||||
v.ValidateAsync(
|
||||
It.IsAny<ValidationContext<TestRequest>>(),
|
||||
It.IsAny<CancellationToken>()
|
||||
)
|
||||
)
|
||||
.ReturnsAsync(new ValidationResult([firstFailure]));
|
||||
Mock<IValidator<TestRequest>> secondValidatorMock = new();
|
||||
secondValidatorMock
|
||||
.Setup(v =>
|
||||
v.ValidateAsync(
|
||||
It.IsAny<ValidationContext<TestRequest>>(),
|
||||
It.IsAny<CancellationToken>()
|
||||
)
|
||||
)
|
||||
.ReturnsAsync(new ValidationResult([secondFailure]));
|
||||
ValidationBehavior<TestRequest, TestResponse> behavior = CreateBehavior(
|
||||
firstValidatorMock.Object,
|
||||
secondValidatorMock.Object
|
||||
);
|
||||
TestRequest request = new("", "pa$$word123");
|
||||
Task<TestResponse> next(CancellationToken _ = default) =>
|
||||
Task.FromResult(new TestResponse("ok"));
|
||||
|
||||
Func<Task> act = async () => await behavior.Handle(request, next, CancellationToken.None);
|
||||
|
||||
(await act.Should().ThrowAsync<ValidationException>())
|
||||
.Which.Errors.Should()
|
||||
.BeEquivalentTo([firstFailure, secondFailure]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenNoValidatorsAreRegistered_InvokesNext()
|
||||
{
|
||||
ValidationBehavior<TestRequest, TestResponse> behavior = CreateBehavior();
|
||||
TestRequest request = new("aaron", "pa$$word123");
|
||||
TestResponse response = new("ok");
|
||||
Task<TestResponse> next(CancellationToken _ = default) => Task.FromResult(response);
|
||||
|
||||
TestResponse result = await behavior.Handle(request, next, CancellationToken.None);
|
||||
|
||||
result.Should().BeSameAs(response);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>Shared.Application.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="FluentAssertions" Version="7.2.0" />
|
||||
<PackageReference Include="FluentValidation" Version="11.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Shared.Application\Shared.Application.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,107 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Shared.Application.Behaviors;
|
||||
|
||||
public abstract class JsonLogger
|
||||
{
|
||||
private static readonly string[] SensitiveFieldNames = ["password", "token"];
|
||||
|
||||
protected static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
TypeInfoResolver = new DefaultJsonTypeInfoResolver
|
||||
{
|
||||
Modifiers = { RedactSensitiveProperties },
|
||||
},
|
||||
};
|
||||
|
||||
private static void RedactSensitiveProperties(JsonTypeInfo typeInfo)
|
||||
{
|
||||
if (typeInfo.Kind != JsonTypeInfoKind.Object)
|
||||
return;
|
||||
|
||||
foreach (JsonPropertyInfo property in typeInfo.Properties)
|
||||
{
|
||||
if (property.PropertyType != typeof(string))
|
||||
continue;
|
||||
|
||||
if (
|
||||
SensitiveFieldNames.Any(name =>
|
||||
property.Name.Contains(name, StringComparison.OrdinalIgnoreCase)
|
||||
)
|
||||
)
|
||||
property.Get = static _ => "***REDACTED***";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MediatR pipeline behavior that logs unhandled exceptions together with the
|
||||
/// request type and
|
||||
/// payload, then rethrows them unchanged for <c>GlobalExceptionFilter</c> to
|
||||
/// map to a status code.
|
||||
/// </summary>
|
||||
public partial class UnhandledExceptionLoggingBehavior<TRequest, TResponse>
|
||||
: JsonLogger,
|
||||
IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : IRequest<TResponse>
|
||||
{
|
||||
private readonly ILogger<UnhandledExceptionLoggingBehavior<TRequest, TResponse>> _logger;
|
||||
|
||||
public UnhandledExceptionLoggingBehavior(
|
||||
ILogger<UnhandledExceptionLoggingBehavior<TRequest, TResponse>> logger
|
||||
)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TResponse> Handle(
|
||||
TRequest request,
|
||||
RequestHandlerDelegate<TResponse> next,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await next(cancellationToken);
|
||||
}
|
||||
catch (ValidationException)
|
||||
{
|
||||
// Expected: ValidationBehavior already reports these as request errors
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Error))
|
||||
LogUnhandledException(ex, typeof(TRequest).Name, Serialize(request));
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Serialize(TRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Serialize(request, SerializerOptions);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return "<unable to serialize request payload>";
|
||||
}
|
||||
}
|
||||
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Error,
|
||||
Message = "Unhandled exception while processing {RequestName}: {RequestPayload}"
|
||||
)]
|
||||
private partial void LogUnhandledException(
|
||||
Exception ex,
|
||||
string requestName,
|
||||
string requestPayload
|
||||
);
|
||||
}
|
||||
@@ -5,14 +5,15 @@ using MediatR;
|
||||
namespace Shared.Application.Behaviors;
|
||||
|
||||
/// <summary>
|
||||
/// MediatR pipeline behavior that runs registered FluentValidation validators before the handler,
|
||||
/// MediatR pipeline behavior that runs registered FluentValidation validators
|
||||
/// before the handler,
|
||||
/// throwing <see cref="ValidationException" /> if any report failures.
|
||||
/// </summary>
|
||||
public class ValidationBehavior<TRequest, TResponse>(IEnumerable<IValidator<TRequest>> validators)
|
||||
: IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : IRequest<TResponse>
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
/// <inheritdoc />
|
||||
public async Task<TResponse> Handle(
|
||||
TRequest request,
|
||||
RequestHandlerDelegate<TResponse> next,
|
||||
@@ -20,7 +21,7 @@ public class ValidationBehavior<TRequest, TResponse>(IEnumerable<IValidator<TReq
|
||||
)
|
||||
{
|
||||
if (!validators.Any())
|
||||
return await next();
|
||||
return await next(cancellationToken);
|
||||
|
||||
ValidationContext<TRequest> context = new(request);
|
||||
|
||||
@@ -34,6 +35,6 @@ public class ValidationBehavior<TRequest, TResponse>(IEnumerable<IValidator<TReq
|
||||
if (failures.Count > 0)
|
||||
throw new ValidationException(failures);
|
||||
|
||||
return await next();
|
||||
return await next(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MediatR" Version="12.4.1" />
|
||||
<PackageReference Include="MediatR" Version="12.5.0" />
|
||||
<PackageReference Include="FluentValidation" Version="11.3.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -70,6 +70,7 @@ services:
|
||||
DB_USER: "${DB_USER}"
|
||||
DB_PASSWORD: "${DB_PASSWORD}"
|
||||
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
|
||||
SEAWEEDFS_PUBLIC_URL: "${SEAWEEDFS_PUBLIC_URL}"
|
||||
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
|
||||
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
|
||||
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
|
||||
|
||||
@@ -72,6 +72,7 @@ services:
|
||||
DB_USER: "${DB_USER}"
|
||||
DB_PASSWORD: "${DB_PASSWORD}"
|
||||
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
|
||||
SEAWEEDFS_PUBLIC_URL: "${SEAWEEDFS_PUBLIC_URL}"
|
||||
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
|
||||
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
|
||||
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
|
||||
@@ -110,6 +111,7 @@ services:
|
||||
CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}"
|
||||
WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}"
|
||||
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
|
||||
SEAWEEDFS_PUBLIC_URL: "${SEAWEEDFS_PUBLIC_URL}"
|
||||
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
|
||||
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
|
||||
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
|
||||
|
||||
@@ -84,6 +84,7 @@ services:
|
||||
DB_USER: "${DB_USER}"
|
||||
DB_PASSWORD: "${DB_PASSWORD}"
|
||||
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
|
||||
SEAWEEDFS_PUBLIC_URL: "${SEAWEEDFS_PUBLIC_URL}"
|
||||
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
|
||||
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
|
||||
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
|
||||
|
||||
33
web/docker-compose.prod.seed.yaml
Normal file
33
web/docker-compose.prod.seed.yaml
Normal file
@@ -0,0 +1,33 @@
|
||||
# docker compose --env-file web/.env.prod -f web/docker-compose.prod.seed.yaml run --rm --build database.seed
|
||||
name: biergarten-prod-seed
|
||||
|
||||
services:
|
||||
database.seed:
|
||||
env_file: ".env.prod"
|
||||
image: database.seed
|
||||
container_name: prod-env-database-seed
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Database/Database.Seed/Dockerfile
|
||||
args:
|
||||
BUILD_CONFIGURATION: Release
|
||||
APP_UID: 1000
|
||||
environment:
|
||||
DOTNET_RUNNING_IN_CONTAINER: "true"
|
||||
DB_SERVER: "${DB_SERVER}"
|
||||
DB_NAME: "${DB_NAME}"
|
||||
DB_USER: "${DB_USER}"
|
||||
DB_PASSWORD: "${DB_PASSWORD}"
|
||||
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
|
||||
SEAWEEDFS_PUBLIC_URL: "${SEAWEEDFS_PUBLIC_URL}"
|
||||
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
|
||||
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
|
||||
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
|
||||
restart: "no"
|
||||
networks:
|
||||
- prodnet
|
||||
|
||||
networks:
|
||||
prodnet:
|
||||
name: biergarten-prodnet
|
||||
external: true
|
||||
@@ -6,10 +6,10 @@ services:
|
||||
container_name: prod-env-sqlserver
|
||||
environment:
|
||||
ACCEPT_EULA: "Y"
|
||||
SA_PASSWORD: "${DB_PASSWORD}"
|
||||
MSSQL_SA_PASSWORD: "${DB_PASSWORD}"
|
||||
MSSQL_PID: "Express"
|
||||
volumes:
|
||||
- sqlserverdata-prod:/var/opt/mssql
|
||||
- biergartendata-prod:/var/opt/mssql
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
@@ -20,29 +20,8 @@ services:
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 30s
|
||||
networks:
|
||||
- prodnet
|
||||
|
||||
database.migrations:
|
||||
env_file: ".env.prod"
|
||||
image: database.migrations
|
||||
container_name: prod-env-database-migrations
|
||||
depends_on:
|
||||
sqlserver:
|
||||
condition: service_healthy
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Database/Database.Migrations/Dockerfile
|
||||
args:
|
||||
BUILD_CONFIGURATION: Release
|
||||
APP_UID: 1000
|
||||
environment:
|
||||
DOTNET_RUNNING_IN_CONTAINER: "true"
|
||||
DB_SERVER: "${DB_SERVER}"
|
||||
DB_NAME: "${DB_NAME}"
|
||||
DB_USER: "${DB_USER}"
|
||||
DB_PASSWORD: "${DB_PASSWORD}"
|
||||
restart: "no"
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 60s
|
||||
networks:
|
||||
- prodnet
|
||||
|
||||
@@ -51,7 +30,7 @@ services:
|
||||
image: api.core
|
||||
container_name: prod-env-api-core
|
||||
depends_on:
|
||||
sqlserver:
|
||||
seaweedfs:
|
||||
condition: service_healthy
|
||||
build:
|
||||
context: ./backend
|
||||
@@ -59,8 +38,6 @@ services:
|
||||
args:
|
||||
BUILD_CONFIGURATION: Release
|
||||
APP_UID: 1000
|
||||
# No published ports: only the frontend service is reachable from outside
|
||||
# the Docker network in production. api.core stays internal to prodnet.
|
||||
environment:
|
||||
ASPNETCORE_ENVIRONMENT: "Production"
|
||||
ASPNETCORE_URLS: "http://0.0.0.0:8080"
|
||||
@@ -73,11 +50,46 @@ services:
|
||||
REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}"
|
||||
CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}"
|
||||
WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}"
|
||||
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
|
||||
SEAWEEDFS_PUBLIC_URL: "${SEAWEEDFS_PUBLIC_URL}"
|
||||
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
|
||||
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
|
||||
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"bash",
|
||||
"-c",
|
||||
'exec 3<>/dev/tcp/127.0.0.1/8080; printf "GET /health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n" >&3; head -1 <&3 | grep -q " 200 "',
|
||||
]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- prodnet
|
||||
|
||||
seaweedfs:
|
||||
env_file: ".env.prod"
|
||||
image: chrislusf/seaweedfs
|
||||
container_name: prod-env-seaweedfs
|
||||
environment:
|
||||
AWS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
|
||||
AWS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
|
||||
S3_BUCKET: "${SEAWEEDFS_BUCKET}"
|
||||
volumes:
|
||||
- nuget-cache-prod:/root/.nuget/packages
|
||||
- seaweedfsdata-prod:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://localhost:8333/status"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- prodnet
|
||||
|
||||
frontend:
|
||||
env_file: ".env.prod"
|
||||
@@ -85,27 +97,34 @@ services:
|
||||
container_name: prod-env-frontend
|
||||
depends_on:
|
||||
api.core:
|
||||
condition: service_started
|
||||
condition: service_healthy
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "127.0.0.1:5656:5656"
|
||||
environment:
|
||||
NODE_ENV: "production"
|
||||
PORT: "3000"
|
||||
PORT: "5656"
|
||||
API_BASE_URL: "http://api.core:8080"
|
||||
SESSION_SECRET: "${SESSION_SECRET}"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--spider", "--tries=1", "http://127.0.0.1:5656/healthz"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 15s
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- prodnet
|
||||
|
||||
volumes:
|
||||
sqlserverdata-prod:
|
||||
driver: local
|
||||
nuget-cache-prod:
|
||||
driver: local
|
||||
biergartendata-prod:
|
||||
external: true
|
||||
seaweedfsdata-prod:
|
||||
external: true
|
||||
|
||||
networks:
|
||||
prodnet:
|
||||
name: biergarten-prodnet
|
||||
driver: bridge
|
||||
|
||||
@@ -70,6 +70,7 @@ services:
|
||||
DB_USER: "${DB_USER}"
|
||||
DB_PASSWORD: "${DB_PASSWORD}"
|
||||
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
|
||||
SEAWEEDFS_PUBLIC_URL: "${SEAWEEDFS_PUBLIC_URL}"
|
||||
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
|
||||
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
|
||||
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
|
||||
|
||||
@@ -34,38 +34,38 @@ h6,
|
||||
--color-base-100: oklch(96% 0.012 82); /* warm parchment */
|
||||
--color-base-200: oklch(92% 0.018 80); /* brushed paper */
|
||||
--color-base-300: oklch(87% 0.025 78); /* tinted linen */
|
||||
--color-base-content: oklch(28% 0.025 58); /* dark brown ink — 15.6:1 on base-100 */
|
||||
--color-base-content: oklch(28% 0.025 58); /* dark brown ink */
|
||||
|
||||
--color-primary: oklch(65% 0.085 62); /* mellow amber */
|
||||
--color-primary-content: oklch(97% 0.02 62); /* warm near-white — 7.2:1 on primary */
|
||||
--color-primary: oklch(47.5% 0.085 62); /* mellow amber */
|
||||
--color-primary-content: oklch(97% 0.02 62); /* warm near-white */
|
||||
|
||||
--color-secondary: oklch(42% 0.05 42); /* softened mahogany */
|
||||
--color-secondary-content: oklch(96% 0.01 76); /* off-white — 14.2:1 on secondary */
|
||||
--color-secondary-content: oklch(96% 0.01 76); /* off-white */
|
||||
|
||||
--color-accent: oklch(93% 0.015 90); /* frothy cream */
|
||||
--color-accent-content: oklch(28% 0.025 58); /* dark brown — 12.8:1 on accent */
|
||||
--color-accent-content: oklch(28% 0.025 58); /* dark brown */
|
||||
|
||||
--color-neutral: oklch(28% 0.02 46); /* warm roast dark */
|
||||
--color-neutral-content: oklch(92% 0.012 80); /* pale parchment — 12.0:1 on neutral */
|
||||
--color-neutral-content: oklch(92% 0.012 80); /* pale parchment */
|
||||
|
||||
--color-info: oklch(46% 0.065 145); /* muted hop green */
|
||||
--color-info-content: oklch(97% 0.008 145); /* near-white — 14.2:1 on info */
|
||||
--color-info-content: oklch(97% 0.008 145); /* near-white */
|
||||
|
||||
--color-success: oklch(70% 0.06 122); /* soft barley gold */
|
||||
--color-success-content: oklch(97% 0.02 122); /* warm near-white — 5.7:1 on success */
|
||||
--color-success: oklch(46.5% 0.06 122); /* soft barley gold */
|
||||
--color-success-content: oklch(97% 0.02 122); /* warm near-white */
|
||||
|
||||
--color-warning: oklch(72% 0.09 56); /* toned amber */
|
||||
--color-warning-content: oklch(97% 0.02 56); /* warm near-white — 4.7:1 on warning */
|
||||
--color-warning: oklch(47.5% 0.09 56); /* toned amber */
|
||||
--color-warning-content: oklch(97% 0.02 56); /* warm near-white */
|
||||
|
||||
--color-error: oklch(54% 0.09 22); /* restrained cherry */
|
||||
--color-error-content: oklch(97% 0.006 15); /* near-white — 11.1:1 on error */
|
||||
--color-error: oklch(48% 0.09 22); /* restrained cherry */
|
||||
--color-error-content: oklch(97% 0.006 15); /* near-white */
|
||||
|
||||
--color-surface: oklch(88% 0.02 82); /* mid parchment, elevated cards */
|
||||
--color-surface-content: oklch(28% 0.025 58); /* dark brown — 9.2:1 on surface */
|
||||
--color-surface-content: oklch(28% 0.025 58); /* dark brown */
|
||||
|
||||
--color-muted: oklch(42% 0.055 62); /* amber-brown — 14.2:1 on base-100, 8.3:1 on surface */
|
||||
--color-muted: oklch(42% 0.055 62); /* amber-brown */
|
||||
--color-highlight: oklch(78% 0.055 65); /* warm amber, hover and active states */
|
||||
--color-highlight-content: oklch(22% 0.025 55); /* dark brown — 4.9:1 on highlight */
|
||||
--color-highlight-content: oklch(22% 0.025 55); /* dark brown */
|
||||
|
||||
--radius-selector: 0.375rem;
|
||||
--radius-field: 0.5rem;
|
||||
@@ -91,38 +91,38 @@ h6,
|
||||
--color-base-100: oklch(14% 0.006 45); /* charred barrel black */
|
||||
--color-base-200: oklch(18% 0.008 43); /* roasted malt dark */
|
||||
--color-base-300: oklch(23% 0.01 42); /* deep brown */
|
||||
--color-base-content: oklch(88% 0.008 75); /* warm off-white — 9.4:1 on base-100 */
|
||||
--color-base-content: oklch(88% 0.008 75); /* warm off-white */
|
||||
|
||||
--color-primary: oklch(68% 0.055 60); /* golden amber */
|
||||
--color-primary-content: oklch(92% 0.012 50); /* warm off-white — 4.6:1 on primary */
|
||||
--color-primary-content: oklch(30.5% 0.012 50); /* warm off-white */
|
||||
|
||||
--color-secondary: oklch(48% 0.035 40); /* deep mahogany ale */
|
||||
--color-secondary-content: oklch(97% 0.005 75); /* near-white — 13.9:1 on secondary */
|
||||
--color-secondary: oklch(62.5% 0.035 40); /* deep mahogany ale */
|
||||
--color-secondary-content: oklch(23.5% 0.005 75); /* near-white */
|
||||
|
||||
--color-accent: oklch(82% 0.01 88); /* frothy cream head */
|
||||
--color-accent-content: oklch(18% 0.012 55); /* near-black — 6.2:1 on accent */
|
||||
--color-accent-content: oklch(18% 0.012 55); /* near-black */
|
||||
|
||||
--color-neutral: oklch(20% 0.008 45); /* near-black with warmth */
|
||||
--color-neutral-content: oklch(88% 0.007 78); /* warm off-white — 9.3:1 on neutral */
|
||||
--color-neutral-content: oklch(88% 0.007 78); /* warm off-white */
|
||||
|
||||
--color-info: oklch(60% 0.04 145); /* cool hop green */
|
||||
--color-info-content: oklch(86% 0.006 145); /* pale green-white — 4.6:1 on info */
|
||||
--color-info: oklch(61.5% 0.04 145); /* cool hop green */
|
||||
--color-info-content: oklch(23% 0.006 145); /* pale green-white */
|
||||
|
||||
--color-success: oklch(66% 0.038 120); /* fresh barley */
|
||||
--color-success-content: oklch(90% 0.012 120); /* pale barley-white — 4.6:1 on success */
|
||||
--color-success-content: oklch(28.5% 0.012 120); /* pale barley-white */
|
||||
|
||||
--color-warning: oklch(70% 0.055 55); /* amber harvest */
|
||||
--color-warning-content: oklch(94% 0.012 55); /* warm near-white — 4.7:1 on warning */
|
||||
--color-warning-content: oklch(32.5% 0.012 55); /* warm near-white */
|
||||
|
||||
--color-error: oklch(50% 0.06 20); /* deep cherry kriek */
|
||||
--color-error-content: oklch(97% 0.004 15); /* near-white — 13.1:1 on error */
|
||||
--color-error: oklch(62.5% 0.06 20); /* deep cherry kriek */
|
||||
--color-error-content: oklch(23% 0.004 15); /* near-white */
|
||||
|
||||
--color-surface: oklch(26% 0.012 45); /* elevated dark panel */
|
||||
--color-surface-content: oklch(88% 0.008 75); /* warm off-white — 9.2:1 on surface */
|
||||
--color-surface-content: oklch(88% 0.008 75); /* warm off-white */
|
||||
|
||||
--color-muted: oklch(78% 0.018 72); /* warm grey — 4.7:1 on base-100, 4.6:1 on surface */
|
||||
--color-muted: oklch(78% 0.018 72); /* warm grey */
|
||||
--color-highlight: oklch(32% 0.025 48); /* warm dark brown, hover and active states */
|
||||
--color-highlight-content: oklch(88% 0.008 75); /* warm off-white — 9.0:1 on highlight */
|
||||
--color-highlight-content: oklch(88% 0.008 75); /* warm off-white */
|
||||
|
||||
--radius-selector: 0.375rem;
|
||||
--radius-field: 0.5rem;
|
||||
@@ -148,40 +148,38 @@ h6,
|
||||
--color-base-100: oklch(13% 0.01 295); /* blackberry-stained near-black */
|
||||
--color-base-200: oklch(17% 0.013 292); /* deep purple-black */
|
||||
--color-base-300: oklch(22% 0.016 290); /* dark grape */
|
||||
--color-base-content: oklch(90% 0.014 300); /* pale lavender-white — 10.7:1 on base-100 */
|
||||
--color-base-content: oklch(90% 0.014 300); /* pale lavender-white */
|
||||
|
||||
--color-primary: oklch(72% 0.075 295); /* cassis berry purple */
|
||||
--color-primary-content: oklch(95% 0.01 295); /* pale lavender — 4.5:1 on primary */
|
||||
--color-primary-content: oklch(34% 0.01 295); /* pale lavender */
|
||||
|
||||
--color-secondary: oklch(68% 0.06 10); /* sour cherry rose */
|
||||
--color-secondary-content: oklch(92% 0.006 10); /* warm near-white — 4.6:1 on secondary */
|
||||
--color-secondary-content: oklch(30% 0.006 10); /* warm near-white */
|
||||
|
||||
--color-accent: oklch(75% 0.045 130); /* tart lime zest */
|
||||
--color-accent-content: oklch(98.5% 0.01 130); /* near-white — 4.8:1 on accent */
|
||||
--color-accent-content: oklch(37.5% 0.01 130); /* near-white */
|
||||
|
||||
--color-neutral: oklch(18% 0.016 290); /* deep blackened grape */
|
||||
--color-neutral-content: oklch(88% 0.01 295); /* pale lavender — 9.3:1 on neutral */
|
||||
--color-neutral-content: oklch(88% 0.01 295); /* pale lavender */
|
||||
|
||||
--color-info: oklch(62% 0.04 250); /* muted indigo */
|
||||
--color-info-content: oklch(88% 0.008 250); /* pale indigo-white — 4.8:1 on info */
|
||||
--color-info-content: oklch(23% 0.008 250); /* pale indigo-white */
|
||||
|
||||
--color-success: oklch(65% 0.04 145); /* elderberry green */
|
||||
--color-success-content: oklch(90% 0.008 145); /* pale green-white — 4.7:1 on success */
|
||||
--color-success-content: oklch(27.5% 0.008 145); /* pale green-white */
|
||||
|
||||
--color-warning: oklch(70% 0.05 65); /* sour apricot */
|
||||
--color-warning-content: oklch(97% 0.03 65); /* near-white — 5.6:1 on warning */
|
||||
--color-warning-content: oklch(32.5% 0.03 65); /* near-white */
|
||||
|
||||
--color-error: oklch(50% 0.055 22); /* kriek red */
|
||||
--color-error-content: oklch(97% 0.006 22); /* near-white — 13.2:1 on error */
|
||||
--color-error: oklch(62% 0.055 22); /* kriek red */
|
||||
--color-error-content: oklch(22% 0.006 22); /* near-white */
|
||||
|
||||
--color-surface: oklch(27% 0.022 292); /* lifted purple-black panel */
|
||||
--color-surface-content: oklch(90% 0.014 300); /* pale lavender-white — 10.4:1 on surface */
|
||||
--color-surface-content: oklch(90% 0.014 300); /* pale lavender-white */
|
||||
|
||||
--color-muted: oklch(
|
||||
77.6% 0.022 300
|
||||
); /* desaturated lavender — 4.6:1 on base-100, 4.5:1 on surface */
|
||||
--color-muted: oklch(77.6% 0.022 300); /* desaturated lavender */
|
||||
--color-highlight: oklch(35% 0.04 295); /* cassis-tinted hover and active state */
|
||||
--color-highlight-content: oklch(90% 0.014 300); /* pale lavender-white — 10.1:1 on highlight */
|
||||
--color-highlight-content: oklch(90% 0.014 300); /* pale lavender-white */
|
||||
|
||||
--radius-selector: 0.5rem;
|
||||
--radius-field: 0.5rem;
|
||||
@@ -207,38 +205,38 @@ h6,
|
||||
--color-base-100: oklch(99% 0.007 112); /* near-white with faint barley-green tint */
|
||||
--color-base-200: oklch(96% 0.012 114); /* pale barley wash */
|
||||
--color-base-300: oklch(92% 0.019 116); /* light straw */
|
||||
--color-base-content: oklch(20% 0.022 122); /* deep green-black — 19.5:1 on base-100 */
|
||||
--color-base-content: oklch(20% 0.022 122); /* deep green-black */
|
||||
|
||||
--color-primary: oklch(52% 0.085 118); /* fresh-cut barley green */
|
||||
--color-primary-content: oklch(97% 0.005 118); /* near-white — 12.5:1 on primary */
|
||||
--color-primary: oklch(50% 0.085 118); /* fresh-cut barley green */
|
||||
--color-primary-content: oklch(97% 0.005 118); /* near-white */
|
||||
|
||||
--color-secondary: oklch(44% 0.055 128); /* muted sage stem */
|
||||
--color-secondary-content: oklch(97% 0.005 128); /* near-white — 14.8:1 on secondary */
|
||||
--color-secondary-content: oklch(97% 0.005 128); /* near-white */
|
||||
|
||||
--color-accent: oklch(93% 0.03 148); /* pale morning dew */
|
||||
--color-accent-content: oklch(22% 0.022 148); /* deep green — 13.4:1 on accent */
|
||||
--color-accent-content: oklch(22% 0.022 148); /* deep green */
|
||||
|
||||
--color-neutral: oklch(76% 0.028 118); /* dried straw, surface differentiation */
|
||||
--color-neutral-content: oklch(98.9% 0.005 118); /* near-white — 4.6:1 on neutral */
|
||||
--color-neutral-content: oklch(38.4% 0.005 118); /* near-white */
|
||||
|
||||
--color-info: oklch(38% 0.065 232); /* clear summer sky */
|
||||
--color-info-content: oklch(98% 0.005 232); /* near-white — 16.8:1 on info */
|
||||
--color-info-content: oklch(98% 0.005 232); /* near-white */
|
||||
|
||||
--color-success: oklch(38% 0.085 145); /* young shoot green */
|
||||
--color-success-content: oklch(98% 0.005 145); /* near-white — 16.8:1 on success */
|
||||
--color-success-content: oklch(98% 0.005 145); /* near-white */
|
||||
|
||||
--color-warning: oklch(68% 0.1 76); /* ripening grain amber */
|
||||
--color-warning-content: oklch(92.5% 0.005 72); /* warm near-white — 4.5:1 on warning */
|
||||
--color-warning: oklch(51% 0.1 76); /* ripening grain amber */
|
||||
--color-warning-content: oklch(92.5% 0.005 72); /* warm near-white */
|
||||
|
||||
--color-error: oklch(52% 0.1 18); /* dusty rose red */
|
||||
--color-error-content: oklch(98% 0.005 15); /* near-white — 12.5:1 on error */
|
||||
--color-error: oklch(51.5% 0.1 18); /* dusty rose red */
|
||||
--color-error-content: oklch(98% 0.005 15); /* near-white */
|
||||
|
||||
--color-surface: oklch(94% 0.012 112); /* soft barley-wash panel */
|
||||
--color-surface-content: oklch(20% 0.022 122); /* deep green-black — 14.0:1 on surface */
|
||||
--color-surface-content: oklch(20% 0.022 122); /* deep green-black */
|
||||
|
||||
--color-muted: oklch(38% 0.055 120); /* sage green — 18.1:1 on base-100, 13.0:1 on surface */
|
||||
--color-muted: oklch(38% 0.055 120); /* sage green */
|
||||
--color-highlight: oklch(85% 0.04 118); /* green-tinted hover and active state */
|
||||
--color-highlight-content: oklch(20% 0.022 122); /* deep green-black — 7.8:1 on highlight */
|
||||
--color-highlight-content: oklch(20% 0.022 122); /* deep green-black */
|
||||
|
||||
--radius-selector: 2rem;
|
||||
--radius-field: 2rem;
|
||||
@@ -249,3 +247,33 @@ h6,
|
||||
--depth: 0;
|
||||
--noise: 0;
|
||||
}
|
||||
|
||||
/* WCAG 1.4.11: form-field borders at >= 3:1 against every base layer */
|
||||
:is(.input, .select, .textarea):not(
|
||||
.input-neutral,
|
||||
.input-primary,
|
||||
.input-secondary,
|
||||
.input-accent,
|
||||
.input-info,
|
||||
.input-success,
|
||||
.input-warning,
|
||||
.input-error,
|
||||
.select-neutral,
|
||||
.select-primary,
|
||||
.select-secondary,
|
||||
.select-accent,
|
||||
.select-info,
|
||||
.select-success,
|
||||
.select-warning,
|
||||
.select-error,
|
||||
.textarea-neutral,
|
||||
.textarea-primary,
|
||||
.textarea-secondary,
|
||||
.textarea-accent,
|
||||
.textarea-info,
|
||||
.textarea-success,
|
||||
.textarea-warning,
|
||||
.textarea-error
|
||||
) {
|
||||
--input-color: color-mix(in oklab, var(--color-base-content) 56%, #0000);
|
||||
}
|
||||
|
||||
BIN
web/frontend/app/assets/bar-background.jpg
Normal file
BIN
web/frontend/app/assets/bar-background.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 203 KiB |
@@ -21,19 +21,26 @@ const FormField = ({
|
||||
}: FormFieldProps) => {
|
||||
return (
|
||||
<Field className={className ?? 'space-y-1'}>
|
||||
<Label htmlFor={inputProps.id} className={labelClassName ?? 'label font-medium'}>
|
||||
<Label
|
||||
htmlFor={inputProps.id}
|
||||
className={labelClassName ?? 'label text-sm font-semibold text-base-content'}
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
|
||||
<input
|
||||
{...inputProps}
|
||||
className={inputClassName ?? `input w-full ${error ? 'input-error' : ''}`}
|
||||
className={
|
||||
inputClassName ?? `input h-11 w-full text-base ${error ? 'input-error' : ''}`
|
||||
}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<Description className={hintClassName ?? 'label text-error'}>{error}</Description>
|
||||
<Description className={hintClassName ?? 'text-xs text-error'}>{error}</Description>
|
||||
) : hint ? (
|
||||
<Description className={hintClassName ?? 'label'}>{hint}</Description>
|
||||
<Description className={hintClassName ?? 'text-xs text-[var(--color-muted)]'}>
|
||||
{hint}
|
||||
</Description>
|
||||
) : null}
|
||||
</Field>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, userEvent, within } from 'storybook/test';
|
||||
import { expect, screen, userEvent, within } from 'storybook/test';
|
||||
import Navbar from './Navbar';
|
||||
|
||||
const navbarDescription = `Top-level navigation for the Biergarten website. These stories cover guest, authenticated, and mobile states so you can review branding, route visibility, account menu behavior, and responsive collapse without leaving Storybook.`;
|
||||
@@ -47,8 +47,10 @@ export const Authenticated: Story = {
|
||||
const userButton = canvas.getByRole('button', { name: /hans/i });
|
||||
await expect(userButton).toBeInTheDocument();
|
||||
await userEvent.click(userButton);
|
||||
await expect(canvas.getByRole('menuitem', { name: /dashboard/i })).toBeInTheDocument();
|
||||
await expect(canvas.getByRole('menuitem', { name: /logout/i })).toBeInTheDocument();
|
||||
// MenuItems is anchor-positioned, which portals it outside canvasElement.
|
||||
await expect(screen.getByRole('menuitem', { name: /my profile/i })).toBeInTheDocument();
|
||||
await expect(screen.getByRole('menuitem', { name: /dashboard/i })).toBeInTheDocument();
|
||||
await expect(screen.getByRole('menuitem', { name: /logout/i })).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/react';
|
||||
import { Link } from 'react-router';
|
||||
import { Link, useLocation } from 'react-router';
|
||||
import NavLinks from './NavLinks';
|
||||
import UserMenu from './UserMenu';
|
||||
|
||||
@@ -12,82 +12,93 @@ interface NavbarProps {
|
||||
} | null;
|
||||
}
|
||||
|
||||
const Navbar = ({ auth }: NavbarProps) => (
|
||||
<Disclosure
|
||||
as="nav"
|
||||
className="sticky top-0 z-50 border-b border-base-300 bg-base-100 shadow-md"
|
||||
>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div className="navbar mx-auto max-w-7xl px-2 sm:px-4">
|
||||
<div className="navbar-start gap-2">
|
||||
<DisclosureButton
|
||||
className="btn btn-ghost btn-square lg:hidden"
|
||||
aria-label="Toggle navigation"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
className="h-5 w-5 stroke-current"
|
||||
const Navbar = ({ auth }: NavbarProps) => {
|
||||
const { pathname } = useLocation();
|
||||
const onAuthRoute = pathname === '/login' || pathname === '/register';
|
||||
const showAuthActions = !auth && !onAuthRoute;
|
||||
|
||||
return (
|
||||
<Disclosure
|
||||
as="nav"
|
||||
className="sticky top-0 z-50 border-b border-base-300 bg-base-100 shadow-md"
|
||||
>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div className="navbar mx-auto max-w-7xl px-2 sm:px-4">
|
||||
<div className="navbar-start gap-2">
|
||||
<DisclosureButton
|
||||
className="btn btn-ghost btn-square lg:hidden"
|
||||
aria-label="Toggle navigation"
|
||||
>
|
||||
{open ? (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
) : (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</DisclosureButton>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
className="h-5 w-5 stroke-current"
|
||||
>
|
||||
{open ? (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
) : (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</DisclosureButton>
|
||||
|
||||
<Link to="/" className="text-xl font-bold">
|
||||
🍺 The Biergarten App
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<NavLinks variant="desktop" showRegister={!auth} />
|
||||
|
||||
<div className="navbar-end gap-2">
|
||||
{!auth && (
|
||||
<Link
|
||||
to="/register"
|
||||
className="btn btn-ghost btn-sm hidden sm:inline-flex"
|
||||
>
|
||||
Register User
|
||||
<Link to="/" className="lg:text-xl text-lg font-bold">
|
||||
The Biergarten App
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{auth ? (
|
||||
<>
|
||||
<Link to="/dashboard" className="btn btn-primary btn-sm">
|
||||
Dashboard
|
||||
<NavLinks variant="desktop" showRegister={showAuthActions} />
|
||||
|
||||
<div className="navbar-end gap-2">
|
||||
{showAuthActions && (
|
||||
<Link
|
||||
to="/register"
|
||||
className="btn btn-ghost btn-sm hidden sm:inline-flex"
|
||||
>
|
||||
Register User
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<UserMenu username={auth.username} />
|
||||
</>
|
||||
) : (
|
||||
<Link to="/login" className="btn btn-primary btn-sm">
|
||||
Login
|
||||
</Link>
|
||||
)}
|
||||
{auth ? (
|
||||
<>
|
||||
<Link to="/dashboard" className="btn btn-primary btn-sm">
|
||||
Dashboard
|
||||
</Link>
|
||||
|
||||
<UserMenu
|
||||
username={auth.username}
|
||||
userAccountId={auth.userAccountId}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
!onAuthRoute && (
|
||||
<Link to="/login" className="btn btn-primary btn-sm">
|
||||
Login
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DisclosurePanel className="border-t border-base-300 bg-base-100 px-4 py-3 lg:hidden">
|
||||
<NavLinks variant="mobile" showRegister={!auth} />
|
||||
</DisclosurePanel>
|
||||
</>
|
||||
)}
|
||||
</Disclosure>
|
||||
);
|
||||
<DisclosurePanel className="border-t border-base-300 bg-base-100 px-4 py-3 lg:hidden">
|
||||
<NavLinks variant="mobile" showRegister={showAuthActions} />
|
||||
</DisclosurePanel>
|
||||
</>
|
||||
)}
|
||||
</Disclosure>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
|
||||
@@ -3,25 +3,40 @@ import { Link } from 'react-router';
|
||||
|
||||
interface UserMenuProps {
|
||||
username: string;
|
||||
userAccountId: string;
|
||||
}
|
||||
|
||||
const UserMenu = ({ username }: UserMenuProps) => (
|
||||
<Menu as="div" className="relative">
|
||||
const UserMenu = ({ username, userAccountId }: UserMenuProps) => (
|
||||
<Menu>
|
||||
<MenuButton className="btn btn-ghost btn-sm">{username}</MenuButton>
|
||||
<MenuItems className="menu absolute right-0 z-60 mt-2 w-52 rounded-box border border-base-300 bg-base-100 p-2 shadow-xl focus:outline-none">
|
||||
<MenuItems
|
||||
anchor="bottom end"
|
||||
transition
|
||||
className="menu z-60 w-52 rounded-box border border-base-300 bg-base-100 p-2 shadow-xl [--anchor-gap:8px] focus:outline-none transition duration-100 ease-out data-[closed]:scale-95 data-[closed]:opacity-0"
|
||||
>
|
||||
<MenuItem>
|
||||
{({ focus }) => (
|
||||
<Link to="/dashboard" className={focus ? 'active' : ''}>
|
||||
Dashboard
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
to={`/users/${userAccountId}`}
|
||||
className="rounded-field block px-3 py-1.5 data-[focus]:bg-base-200"
|
||||
>
|
||||
My profile
|
||||
</Link>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
{({ focus }) => (
|
||||
<Link to="/logout" className={focus ? 'active' : ''}>
|
||||
Logout
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
to="/dashboard"
|
||||
className="rounded-field block px-3 py-1.5 data-[focus]:bg-base-200"
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<Link
|
||||
to="/logout"
|
||||
className="rounded-field block px-3 py-1.5 data-[focus]:bg-base-200"
|
||||
>
|
||||
Logout
|
||||
</Link>
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</Menu>
|
||||
|
||||
@@ -11,7 +11,7 @@ const AuthFlowDemoCard = () => (
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<p className="font-semibold">Login</p>
|
||||
<p className="text-sm text-base-content/60">
|
||||
<p className="text-sm text-base-content/70">
|
||||
POST to <code className="kbd kbd-sm">/api/auth/login</code> with
|
||||
username & password
|
||||
</p>
|
||||
@@ -20,7 +20,7 @@ const AuthFlowDemoCard = () => (
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<p className="font-semibold">Register</p>
|
||||
<p className="text-sm text-base-content/60">
|
||||
<p className="text-sm text-base-content/70">
|
||||
POST to <code className="kbd kbd-sm">/api/auth/register</code> with full
|
||||
user details
|
||||
</p>
|
||||
@@ -29,7 +29,7 @@ const AuthFlowDemoCard = () => (
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<p className="font-semibold">Session</p>
|
||||
<p className="text-sm text-base-content/60">
|
||||
<p className="text-sm text-base-content/70">
|
||||
JWT access & refresh tokens stored in an HTTP-only cookie
|
||||
</p>
|
||||
</div>
|
||||
@@ -37,7 +37,7 @@ const AuthFlowDemoCard = () => (
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<p className="font-semibold">Protected Routes</p>
|
||||
<p className="text-sm text-base-content/60">
|
||||
<p className="text-sm text-base-content/70">
|
||||
This dashboard requires authentication via{' '}
|
||||
<code className="kbd kbd-sm">requireAuth()</code>
|
||||
</p>
|
||||
@@ -46,7 +46,7 @@ const AuthFlowDemoCard = () => (
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<p className="font-semibold">Token Refresh</p>
|
||||
<p className="text-sm text-base-content/60">
|
||||
<p className="text-sm text-base-content/70">
|
||||
POST to <code className="kbd kbd-sm">/api/auth/refresh</code> with the
|
||||
refresh token in an <code className="kbd kbd-sm">X-Refresh-Token</code>{' '}
|
||||
header
|
||||
@@ -56,7 +56,7 @@ const AuthFlowDemoCard = () => (
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<p className="font-semibold">Account Management</p>
|
||||
<p className="text-sm text-base-content/60">
|
||||
<p className="text-sm text-base-content/70">
|
||||
PATCH <code className="kbd kbd-sm">/api/auth/username</code>,{' '}
|
||||
<code className="kbd kbd-sm">/email</code>,{' '}
|
||||
<code className="kbd kbd-sm">/password</code>,{' '}
|
||||
|
||||
@@ -14,7 +14,7 @@ const SectionCard = ({ icon, title, description, open, onToggle, children }: Sec
|
||||
<div className="card-body">
|
||||
<div className="flex w-full items-center justify-between gap-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-base-content/60 mt-1">{icon}</span>
|
||||
<span className="text-base-content/70 mt-1">{icon}</span>
|
||||
<div>
|
||||
<h2 className="card-title text-lg">{title}</h2>
|
||||
<p className="text-sm text-base-content/70">{description}</p>
|
||||
|
||||
@@ -15,7 +15,7 @@ const SessionInfoCard = ({ username, userAccountId }: SessionInfoCardProps) => (
|
||||
</p>
|
||||
|
||||
<div className="bg-base-200 rounded-box p-4 mt-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-base-content/50 mb-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-base-content/70 mb-3">
|
||||
Session Info
|
||||
</p>
|
||||
<div className="stats stats-vertical w-full">
|
||||
|
||||
@@ -37,7 +37,7 @@ export const loader = async ({ request }: Route.LoaderArgs) => {
|
||||
|
||||
let details = { firstName: '', lastName: '', email: '', dateOfBirth: '' };
|
||||
try {
|
||||
const account = await getUserAccount(auth.userAccountId);
|
||||
const account = await getUserAccount(auth.accessToken);
|
||||
details = {
|
||||
firstName: account.firstName,
|
||||
lastName: account.lastName,
|
||||
@@ -208,11 +208,13 @@ const AccountPage = ({ loaderData, actionData }: Route.ComponentProps) => {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200">
|
||||
<div className="mx-auto max-w-2xl px-6 py-10 space-y-6">
|
||||
<div className="mx-auto max-w-7xl px-5 pt-10 pb-10 space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Settings className="size-7" aria-hidden="true" />
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Account Settings</h1>
|
||||
<h1 className="font-serif text-5xl font-bold leading-tight">
|
||||
Account Settings
|
||||
</h1>
|
||||
<p className="text-base-content/70">
|
||||
Manage the account details for{' '}
|
||||
<span className="font-mono">{username}</span>
|
||||
@@ -257,7 +259,7 @@ const AccountPage = ({ loaderData, actionData }: Route.ComponentProps) => {
|
||||
|
||||
<Link
|
||||
to="/dashboard"
|
||||
className="link link-hover text-sm text-base-content/60 inline-block"
|
||||
className="link link-hover text-sm text-base-content/70 inline-block"
|
||||
>
|
||||
← Back to dashboard
|
||||
</Link>
|
||||
|
||||
@@ -20,7 +20,7 @@ const Dashboard = ({ loaderData }: Route.ComponentProps) => {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200">
|
||||
<div className="mx-auto max-w-4xl px-6 py-10 space-y-6">
|
||||
<div className="mx-auto max-w-7xl px-5 pt-10 pb-10 space-y-6">
|
||||
<SessionInfoCard username={username} userAccountId={userAccountId} />
|
||||
<AuthFlowDemoCard />
|
||||
</div>
|
||||
|
||||
@@ -162,8 +162,14 @@ export const refreshTokens = async (refreshToken: string) => {
|
||||
return data.payload;
|
||||
};
|
||||
|
||||
export const getUserAccount = async (userAccountId: string): Promise<UserAccountDetails> => {
|
||||
const res = await fetch(`${API_BASE_URL}/api/user/${userAccountId}`);
|
||||
/**
|
||||
* Fetches the signed-in caller's own account. The backend derives the account from the access
|
||||
* token, so the caller passes no ID.
|
||||
*/
|
||||
export const getUserAccount = async (accessToken: string): Promise<UserAccountDetails> => {
|
||||
const res = await fetch(`${API_BASE_URL}/api/user/authenticated`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
interface AuthCardHeadingProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
const AuthCardHeading = ({ title, subtitle }: AuthCardHeadingProps) => (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h1 className="text-[1.875rem]">{title}</h1>
|
||||
<p className="text-[var(--color-muted)]">{subtitle}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default AuthCardHeading;
|
||||
21
web/frontend/app/features/auth/components/AuthErrorAlert.tsx
Normal file
21
web/frontend/app/features/auth/components/AuthErrorAlert.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
interface AuthErrorAlertProps {
|
||||
message: string;
|
||||
/** Per-field messages to list beneath the summary. */
|
||||
details?: readonly string[];
|
||||
}
|
||||
|
||||
const AuthErrorAlert = ({ message, details }: AuthErrorAlertProps) => (
|
||||
<div role="alert" className="alert alert-error alert-soft flex-col items-start">
|
||||
<span>{message}</span>
|
||||
|
||||
{details?.length ? (
|
||||
<ul className="list-inside list-disc text-sm">
|
||||
{details.map((detail) => (
|
||||
<li key={detail}>{detail}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default AuthErrorAlert;
|
||||
@@ -0,0 +1,151 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { expect, fireEvent, userEvent, within } from 'storybook/test';
|
||||
import AuthCardHeading from './AuthCardHeading';
|
||||
import AuthErrorAlert from './AuthErrorAlert';
|
||||
import AuthSplitLayout from './AuthSplitLayout';
|
||||
import AuthSwitchPrompt from './AuthSwitchPrompt';
|
||||
import LoginForm from './LoginForm';
|
||||
import RegisterForm from './RegisterForm';
|
||||
import RegisterStepProgress from './RegisterStepProgress';
|
||||
import { useRegisterWizard } from '../hooks/useRegisterWizard';
|
||||
import { loginSchema, type LoginSchema } from '../schemas';
|
||||
|
||||
const noop = () => undefined;
|
||||
|
||||
const authSplitLayoutDescription = `The shared split-screen shell behind /login and /register: a full-bleed bar photo carrying the hero headline, and a solid card holding the page's own heading, form, and switch prompt. The grid splits 6fr/5fr from 820px up and stacks hero over card below it. Both stories compose the real forms, so they show the production layout at any viewport.`;
|
||||
|
||||
const LoginPanel = ({ error }: { error?: string }) => {
|
||||
const form = useForm<LoginSchema>({ resolver: zodResolver(loginSchema) });
|
||||
|
||||
return (
|
||||
<>
|
||||
<AuthCardHeading title="Sign in" subtitle="Sign in to your Biergarten account" />
|
||||
|
||||
{error ? <AuthErrorAlert message={error} /> : null}
|
||||
|
||||
<LoginForm
|
||||
onSubmit={form.handleSubmit(noop)}
|
||||
formState={form.formState}
|
||||
register={form.register}
|
||||
submitting={false}
|
||||
/>
|
||||
|
||||
<AuthSwitchPrompt lead="New to Biergarten?" to="/register" label="Create an account" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const RegisterPanel = () => {
|
||||
const wizard = useRegisterWizard(noop);
|
||||
|
||||
return (
|
||||
<>
|
||||
<RegisterStepProgress
|
||||
step={wizard.step}
|
||||
totalSteps={wizard.totalSteps}
|
||||
name={wizard.name}
|
||||
/>
|
||||
|
||||
<AuthCardHeading title={wizard.title} subtitle={wizard.subtitle} />
|
||||
|
||||
<RegisterForm wizard={wizard} submitting={false} />
|
||||
|
||||
<AuthSwitchPrompt lead="Already have an account?" to="/login" label="Sign in" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: 'Layout/AuthSplitLayout',
|
||||
component: AuthSplitLayout,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
docs: {
|
||||
description: {
|
||||
component: authSplitLayoutDescription,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof AuthSplitLayout>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const loginArgs = {
|
||||
headline: 'Good to see you again.',
|
||||
blurb: 'Sign in to pick up where you left off with our partner breweries.',
|
||||
};
|
||||
|
||||
const registerArgs = {
|
||||
headline: 'Pull up a chair.',
|
||||
blurb: 'Create an account to explore partner breweries and their beers.',
|
||||
};
|
||||
|
||||
export const Login: Story = {
|
||||
args: {
|
||||
...loginArgs,
|
||||
children: <LoginPanel />,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByRole('heading', { name: 'Sign in' })).toBeInTheDocument();
|
||||
await expect(canvas.getByRole('button', { name: /show password/i })).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const LoginWithServerError: Story = {
|
||||
args: {
|
||||
...loginArgs,
|
||||
children: <LoginPanel error="Invalid username or password." />,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByRole('alert')).toHaveTextContent('Invalid username or password.');
|
||||
},
|
||||
};
|
||||
|
||||
export const Register: Story = {
|
||||
args: {
|
||||
...registerArgs,
|
||||
children: <RegisterPanel />,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText(/step 1 of 3 · account/i)).toBeInTheDocument();
|
||||
await expect(canvas.getByRole('button', { name: /continue/i })).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const RegisterFinalStep: Story = {
|
||||
args: Register.args,
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.type(canvas.getByLabelText(/^username$/i), 'jane_doe');
|
||||
await userEvent.type(canvas.getByLabelText(/^email$/i), 'jane@example.com');
|
||||
await userEvent.click(canvas.getByRole('button', { name: /continue/i }));
|
||||
|
||||
await userEvent.type(await canvas.findByLabelText(/first name/i), 'Jane');
|
||||
await userEvent.type(canvas.getByLabelText(/last name/i), 'Doe');
|
||||
await fireEvent.change(canvas.getByLabelText(/date of birth/i), {
|
||||
target: { value: '2000-01-15' },
|
||||
});
|
||||
await userEvent.click(canvas.getByRole('button', { name: /continue/i }));
|
||||
|
||||
await expect(
|
||||
await canvas.findByRole('heading', { name: 'Secure your account' }),
|
||||
).toBeInTheDocument();
|
||||
await expect(canvas.getByRole('button', { name: /create account/i })).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const RegisterMobile: Story = {
|
||||
args: Register.args,
|
||||
parameters: {
|
||||
viewport: {
|
||||
defaultViewport: 'mobile1',
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import barBackground from '../../../assets/bar-background.jpg';
|
||||
|
||||
interface AuthSplitLayoutProps {
|
||||
headline: string;
|
||||
blurb: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const AuthSplitLayout = ({ headline, blurb, children }: AuthSplitLayoutProps) => (
|
||||
<div className="relative flex flex-1 bg-base-200">
|
||||
<div
|
||||
className="absolute inset-0 bg-base-300 bg-cover bg-center"
|
||||
style={{ backgroundImage: `url(${barBackground})` }}
|
||||
/>
|
||||
|
||||
{/* Darkens the photo from the bottom up so the white hero text stays legible */}
|
||||
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(0deg,#000000b3_30%,#ffffff44_100%)]" />
|
||||
|
||||
<div className="relative mx-auto grid w-full max-w-7xl grid-cols-1 content-between gap-6 min-[820px]:grid-cols-[minmax(0,6fr)_minmax(0,5fr)]">
|
||||
<section className="flex flex-col justify-end gap-4 px-5 pt-8 pb-2 text-white min-[820px]:px-10 min-[820px]:py-14">
|
||||
<h2 className="text-[2rem] leading-[1.1] text-balance [text-shadow:0_2px_24px_#0006] min-[820px]:text-[3.25rem]">
|
||||
{headline}
|
||||
</h2>
|
||||
|
||||
<p className="max-w-[27.5rem] text-base leading-[1.5] min-[820px]:text-[1.1875rem]">
|
||||
{blurb}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<main className="flex items-center justify-center px-4 pt-4 pb-7 min-[820px]:py-10 min-[820px]:pr-10 min-[820px]:pl-0">
|
||||
<div className="flex w-full max-w-[27.5rem] flex-col gap-4 rounded-box bg-base-100 px-5 py-6 shadow-[0_20px_50px_#0005] min-[820px]:p-8">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default AuthSplitLayout;
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Link } from 'react-router';
|
||||
|
||||
interface AuthSwitchPromptProps {
|
||||
lead: string;
|
||||
to: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const AuthSwitchPrompt = ({ lead, to, label }: AuthSwitchPromptProps) => (
|
||||
<p className="text-center text-[0.9375rem] text-[var(--color-muted)]">
|
||||
{lead}{' '}
|
||||
<Link to={to} className="font-semibold text-secondary hover:text-primary">
|
||||
{label}
|
||||
</Link>
|
||||
</p>
|
||||
);
|
||||
|
||||
export default AuthSwitchPrompt;
|
||||
@@ -10,7 +10,7 @@ const ConfirmSuccess = ({ confirmedDate }: ConfirmSuccessProps) => (
|
||||
<h1 className="card-title text-2xl">Email Confirmed!</h1>
|
||||
<p className="text-base-content/70">Your email address has been successfully verified.</p>
|
||||
<div className="bg-base-200 rounded-box w-full p-3 text-sm text-left">
|
||||
<span className="text-base-content/50 text-xs uppercase tracking-widest font-semibold">
|
||||
<span className="text-base-content/70 text-xs uppercase tracking-widest font-semibold">
|
||||
Confirmed at
|
||||
</span>
|
||||
<p className="font-mono mt-1">{new Date(confirmedDate).toLocaleString()}</p>
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Link } from 'react-router';
|
||||
|
||||
const LoginCallout = () => (
|
||||
<>
|
||||
<div className="divider text-xs">Already have an account?</div>
|
||||
|
||||
<div className="text-center space-y-2">
|
||||
<Link to="/login" className="btn btn-outline btn-sm w-full">
|
||||
Sign in
|
||||
</Link>
|
||||
<Link to="/" className="link link-hover text-sm text-base-content/60">
|
||||
Back to home
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
export default LoginCallout;
|
||||
@@ -1,8 +1,9 @@
|
||||
import { BaseSyntheticEvent } from 'react';
|
||||
import { BaseSyntheticEvent, useState } from 'react';
|
||||
import { FormState, UseFormRegister } from 'react-hook-form';
|
||||
import FormField from '../../../components/ui/forms/FormField';
|
||||
import SubmitButton from '../../../components/ui/forms/SubmitButton';
|
||||
import type { LoginSchema } from '../schemas';
|
||||
import ShowPasswordToggle from './ShowPasswordToggle';
|
||||
|
||||
interface LoginFormProps {
|
||||
onSubmit: (e?: BaseSyntheticEvent) => Promise<Awaited<void> | undefined>;
|
||||
@@ -11,34 +12,42 @@ interface LoginFormProps {
|
||||
submitting: boolean;
|
||||
}
|
||||
|
||||
const LoginForm = (props: LoginFormProps) => (
|
||||
<form onSubmit={props.onSubmit} className="space-y-3">
|
||||
<FormField
|
||||
id="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
placeholder="your_username"
|
||||
label="Username"
|
||||
error={props.formState.errors.username?.message}
|
||||
{...props.register('username')}
|
||||
/>
|
||||
const LoginForm = (props: LoginFormProps) => {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
<FormField
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="••••••••"
|
||||
label="Password"
|
||||
error={props.formState.errors.password?.message}
|
||||
{...props.register('password')}
|
||||
/>
|
||||
return (
|
||||
<form onSubmit={props.onSubmit} className="flex flex-col gap-4">
|
||||
<FormField
|
||||
id="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
label="Username"
|
||||
error={props.formState.errors.username?.message}
|
||||
{...props.register('username')}
|
||||
/>
|
||||
|
||||
<SubmitButton
|
||||
isSubmitting={props.submitting}
|
||||
idleText="Sign In"
|
||||
submittingText="Signing in..."
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
<FormField
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="current-password"
|
||||
label="Password"
|
||||
error={props.formState.errors.password?.message}
|
||||
{...props.register('password')}
|
||||
/>
|
||||
|
||||
<ShowPasswordToggle
|
||||
shown={showPassword}
|
||||
onToggle={() => setShowPassword((shown) => !shown)}
|
||||
/>
|
||||
|
||||
<SubmitButton
|
||||
isSubmitting={props.submitting}
|
||||
idleText="Sign in"
|
||||
submittingText="Signing in…"
|
||||
className="btn btn-primary min-h-12 w-full"
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginForm;
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { HomeSimpleDoor, UserPlus } from 'iconoir-react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
const RegisterCallout = () => (
|
||||
<>
|
||||
<div className="divider text-xs">New here?</div>
|
||||
<div className="text-center space-y-2">
|
||||
<Link to="/register" className="btn btn-outline btn-sm w-full gap-2">
|
||||
<UserPlus className="size-4" aria-hidden="true" />
|
||||
Create an account
|
||||
</Link>
|
||||
<Link
|
||||
to="/"
|
||||
className="link link-hover text-sm text-base-content/60 inline-flex items-center gap-1"
|
||||
>
|
||||
<HomeSimpleDoor className="size-4" aria-hidden="true" />
|
||||
Back to home
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
export default RegisterCallout;
|
||||
@@ -1,25 +1,30 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { expect, fireEvent, userEvent, within } from 'storybook/test';
|
||||
import AuthCardHeading from './AuthCardHeading';
|
||||
import RegisterForm from './RegisterForm';
|
||||
import { registerSchema, type RegisterSchema } from '../schemas';
|
||||
import RegisterStepProgress from './RegisterStepProgress';
|
||||
import { useRegisterWizard } from '../hooks/useRegisterWizard';
|
||||
import { type RegisterSchema } from '../schemas';
|
||||
|
||||
const registerFormDescription = `Renders the real \`RegisterForm\` component wired to the real \`registerSchema\` (zod) through the same react-hook-form setup used on the register page, including its cross-field rule that the confirmation must match the password, so these stories validate actual client-side validation behavior rather than a re-implemented form.`;
|
||||
const registerFormDescription = `Renders the real \`RegisterForm\` driven by the real \`useRegisterWizard\` hook, so these stories exercise the production three-step flow: each step validates its own fields through \`registerSchema\` (zod) before the next one opens, entered values survive going back, and only the final step submits.`;
|
||||
|
||||
const RegisterFormHarness = () => {
|
||||
const [submitted, setSubmitted] = useState<RegisterSchema | null>(null);
|
||||
const form = useForm<RegisterSchema>({ resolver: zodResolver(registerSchema) });
|
||||
const wizard = useRegisterWizard(setSubmitted);
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-lg space-y-3 rounded-box bg-base-100 p-6 shadow-lg">
|
||||
<RegisterForm
|
||||
onSubmit={form.handleSubmit((values) => setSubmitted(values))}
|
||||
formState={form.formState}
|
||||
register={form.register}
|
||||
submitting={false}
|
||||
<div className="flex w-full max-w-md flex-col gap-4 rounded-box bg-base-100 p-6 shadow-lg">
|
||||
<RegisterStepProgress
|
||||
step={wizard.step}
|
||||
totalSteps={wizard.totalSteps}
|
||||
name={wizard.name}
|
||||
/>
|
||||
|
||||
<AuthCardHeading title={wizard.title} subtitle={wizard.subtitle} />
|
||||
|
||||
<RegisterForm wizard={wizard} submitting={false} />
|
||||
|
||||
{submitted ? <p data-testid="submit-result">Registered {submitted.username}</p> : null}
|
||||
</div>
|
||||
);
|
||||
@@ -42,75 +47,120 @@ const meta = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Empty: Story = {};
|
||||
type Canvas = ReturnType<typeof within>;
|
||||
|
||||
const fillValidRegistration = async (canvas: ReturnType<typeof within>) => {
|
||||
const adultDateOfBirth = '2000-01-15';
|
||||
|
||||
const underageDateOfBirth = () => {
|
||||
const tenYearsAgo = new Date();
|
||||
tenYearsAgo.setFullYear(tenYearsAgo.getFullYear() - 10);
|
||||
return tenYearsAgo.toISOString().slice(0, 10);
|
||||
};
|
||||
|
||||
const completeAccountStep = async (canvas: Canvas) => {
|
||||
await userEvent.type(canvas.getByLabelText(/^username$/i), 'jane_doe');
|
||||
await userEvent.type(canvas.getByLabelText(/^email$/i), 'jane@example.com');
|
||||
await userEvent.click(canvas.getByRole('button', { name: /continue/i }));
|
||||
await canvas.findByLabelText(/first name/i);
|
||||
};
|
||||
|
||||
const completeDetailsStep = async (canvas: Canvas, dateOfBirth = adultDateOfBirth) => {
|
||||
await userEvent.type(canvas.getByLabelText(/first name/i), 'Jane');
|
||||
await userEvent.type(canvas.getByLabelText(/last name/i), 'Doe');
|
||||
await userEvent.type(canvas.getByLabelText(/^email$/i), 'jane@example.com');
|
||||
await fireEvent.change(canvas.getByLabelText(/date of birth/i), {
|
||||
target: { value: '2000-01-15' },
|
||||
target: { value: dateOfBirth },
|
||||
});
|
||||
await userEvent.type(canvas.getByLabelText('Password'), 'StrongPass1!');
|
||||
await userEvent.type(canvas.getByLabelText(/confirm password/i), 'StrongPass1!');
|
||||
await userEvent.click(canvas.getByRole('button', { name: /continue/i }));
|
||||
};
|
||||
|
||||
export const RejectsEmptySubmission: Story = {
|
||||
export const Empty: Story = {};
|
||||
|
||||
export const BlocksAdvanceOnInvalidAccount: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole('button', { name: /create account/i }));
|
||||
await userEvent.click(canvas.getByRole('button', { name: /continue/i }));
|
||||
await expect(
|
||||
canvas.getByText(/username must be at least 3 characters/i),
|
||||
await canvas.findByText(/username must be at least 3 characters/i),
|
||||
).toBeInTheDocument();
|
||||
await expect(canvas.getByText(/first name is required/i)).toBeInTheDocument();
|
||||
await expect(canvas.queryByTestId('submit-result')).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const RejectsInvalidEmail: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await fillValidRegistration(canvas);
|
||||
await userEvent.clear(canvas.getByLabelText(/^email$/i));
|
||||
await userEvent.type(canvas.getByLabelText(/^email$/i), 'jane@example');
|
||||
await userEvent.click(canvas.getByRole('button', { name: /create account/i }));
|
||||
await expect(canvas.getByText(/invalid email address/i)).toBeInTheDocument();
|
||||
await expect(canvas.queryByTestId('submit-result')).not.toBeInTheDocument();
|
||||
await expect(canvas.getByText(/step 1 of 3/i)).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const RejectsWeakPassword: Story = {
|
||||
export const ClearsFieldErrorOnEdit: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await fillValidRegistration(canvas);
|
||||
await userEvent.clear(canvas.getByLabelText('Password'));
|
||||
await userEvent.type(canvas.getByLabelText('Password'), 'weakpassword');
|
||||
await userEvent.clear(canvas.getByLabelText(/confirm password/i));
|
||||
await userEvent.type(canvas.getByLabelText(/confirm password/i), 'weakpassword');
|
||||
await userEvent.click(canvas.getByRole('button', { name: /create account/i }));
|
||||
await expect(canvas.getByText(/uppercase letter/i)).toBeInTheDocument();
|
||||
await expect(canvas.queryByTestId('submit-result')).not.toBeInTheDocument();
|
||||
await userEvent.click(canvas.getByRole('button', { name: /continue/i }));
|
||||
await canvas.findByText(/username must be at least 3 characters/i);
|
||||
|
||||
await userEvent.type(canvas.getByLabelText(/^username$/i), 'jane_doe');
|
||||
await expect(
|
||||
canvas.queryByText(/username must be at least 3 characters/i),
|
||||
).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const AdvancesToPersonalDetails: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await completeAccountStep(canvas);
|
||||
await expect(canvas.getByRole('heading', { name: 'About you' })).toBeInTheDocument();
|
||||
await expect(canvas.getByText(/step 2 of 3 · personal details/i)).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const KeepsValuesWhenGoingBack: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await completeAccountStep(canvas);
|
||||
await userEvent.click(canvas.getByRole('button', { name: /back/i }));
|
||||
await expect(await canvas.findByLabelText(/^username$/i)).toHaveValue('jane_doe');
|
||||
await expect(canvas.getByLabelText(/^email$/i)).toHaveValue('jane@example.com');
|
||||
},
|
||||
};
|
||||
|
||||
export const RejectsUnderageDateOfBirth: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await completeAccountStep(canvas);
|
||||
await completeDetailsStep(canvas, underageDateOfBirth());
|
||||
await expect(await canvas.findByText(/at least 19 years old/i)).toBeInTheDocument();
|
||||
await expect(canvas.getByText(/step 2 of 3/i)).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const RejectsMismatchedConfirmPassword: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await fillValidRegistration(canvas);
|
||||
await userEvent.clear(canvas.getByLabelText(/confirm password/i));
|
||||
await completeAccountStep(canvas);
|
||||
await completeDetailsStep(canvas);
|
||||
await userEvent.type(await canvas.findByLabelText('Password'), 'StrongPass1!');
|
||||
await userEvent.type(canvas.getByLabelText(/confirm password/i), 'Different1!');
|
||||
await userEvent.click(canvas.getByRole('button', { name: /create account/i }));
|
||||
await expect(canvas.getByText(/passwords must match/i)).toBeInTheDocument();
|
||||
await expect(await canvas.findByText(/passwords must match/i)).toBeInTheDocument();
|
||||
await expect(canvas.queryByTestId('submit-result')).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const RevealsBothPasswords: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await completeAccountStep(canvas);
|
||||
await completeDetailsStep(canvas);
|
||||
await userEvent.click(await canvas.findByRole('button', { name: /show password/i }));
|
||||
await expect(canvas.getByLabelText('Password')).toHaveAttribute('type', 'text');
|
||||
await expect(canvas.getByLabelText(/confirm password/i)).toHaveAttribute('type', 'text');
|
||||
},
|
||||
};
|
||||
|
||||
export const AcceptsValidRegistration: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await fillValidRegistration(canvas);
|
||||
await completeAccountStep(canvas);
|
||||
await completeDetailsStep(canvas);
|
||||
await userEvent.type(await canvas.findByLabelText('Password'), 'StrongPass1!');
|
||||
await userEvent.type(canvas.getByLabelText(/confirm password/i), 'StrongPass1!');
|
||||
await userEvent.click(canvas.getByRole('button', { name: /create account/i }));
|
||||
await expect(canvas.getByTestId('submit-result')).toHaveTextContent('jane_doe');
|
||||
await expect(await canvas.findByTestId('submit-result')).toHaveTextContent('jane_doe');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,97 +1,129 @@
|
||||
import { BaseSyntheticEvent } from 'react';
|
||||
import { FormState, UseFormRegister } from 'react-hook-form';
|
||||
import { Button } from '@headlessui/react';
|
||||
import { useState } from 'react';
|
||||
import FormField from '../../../components/ui/forms/FormField';
|
||||
import SubmitButton from '../../../components/ui/forms/SubmitButton';
|
||||
import type { RegisterSchema } from '../schemas';
|
||||
import type { RegisterWizard } from '../hooks/useRegisterWizard';
|
||||
import { minimumAgeYears } from '../schemas';
|
||||
import ShowPasswordToggle from './ShowPasswordToggle';
|
||||
|
||||
interface RegisterFormProps {
|
||||
onSubmit: (e?: BaseSyntheticEvent) => Promise<Awaited<void> | undefined>;
|
||||
formState: FormState<RegisterSchema>;
|
||||
register: UseFormRegister<RegisterSchema>;
|
||||
wizard: RegisterWizard;
|
||||
submitting: boolean;
|
||||
}
|
||||
|
||||
const RegisterForm = (props: RegisterFormProps) => (
|
||||
<form onSubmit={props.onSubmit} className="space-y-3">
|
||||
<FormField
|
||||
id="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
placeholder="your_username"
|
||||
label="Username"
|
||||
hint="3-64 characters, alphanumeric and . _ -"
|
||||
error={props.formState.errors.username?.message}
|
||||
{...props.register('username')}
|
||||
/>
|
||||
const RegisterForm = ({ wizard, submitting }: RegisterFormProps) => {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const { step, isLastStep, canGoBack, register, formState } = wizard;
|
||||
const { errors } = formState;
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
id="firstName"
|
||||
type="text"
|
||||
autoComplete="given-name"
|
||||
placeholder="Jane"
|
||||
label="First Name"
|
||||
error={props.formState.errors.firstName?.message}
|
||||
{...props.register('firstName')}
|
||||
/>
|
||||
return (
|
||||
<form onSubmit={wizard.onSubmit} className="flex flex-col gap-4" noValidate>
|
||||
{step === 1 && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<FormField
|
||||
id="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
label="Username"
|
||||
hint="3-64 characters, alphanumeric and . _ -"
|
||||
error={errors.username?.message}
|
||||
{...register('username')}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
id="lastName"
|
||||
type="text"
|
||||
autoComplete="family-name"
|
||||
placeholder="Doe"
|
||||
label="Last Name"
|
||||
error={props.formState.errors.lastName?.message}
|
||||
{...props.register('lastName')}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
label="Email"
|
||||
error={errors.email?.message}
|
||||
{...register('email')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="jane@example.com"
|
||||
label="Email"
|
||||
error={props.formState.errors.email?.message}
|
||||
{...props.register('email')}
|
||||
/>
|
||||
{step === 2 && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-1 gap-3.5 min-[420px]:grid-cols-2">
|
||||
<FormField
|
||||
id="firstName"
|
||||
type="text"
|
||||
autoComplete="given-name"
|
||||
label="First name"
|
||||
error={errors.firstName?.message}
|
||||
{...register('firstName')}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
id="dateOfBirth"
|
||||
type="date"
|
||||
label="Date of Birth"
|
||||
hint="Must be 19 years or older"
|
||||
error={props.formState.errors.dateOfBirth?.message}
|
||||
{...props.register('dateOfBirth')}
|
||||
/>
|
||||
<FormField
|
||||
id="lastName"
|
||||
type="text"
|
||||
autoComplete="family-name"
|
||||
label="Last name"
|
||||
error={errors.lastName?.message}
|
||||
{...register('lastName')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder="••••••••"
|
||||
label="Password"
|
||||
hint="8+ chars: uppercase, lowercase, digit, special character"
|
||||
error={props.formState.errors.password?.message}
|
||||
{...props.register('password')}
|
||||
/>
|
||||
<FormField
|
||||
id="dateOfBirth"
|
||||
type="date"
|
||||
label="Date of birth"
|
||||
hint={`Must be ${minimumAgeYears} years or older.`}
|
||||
error={errors.dateOfBirth?.message}
|
||||
{...register('dateOfBirth')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder="••••••••"
|
||||
label="Confirm Password"
|
||||
error={props.formState.errors.confirmPassword?.message}
|
||||
{...props.register('confirmPassword')}
|
||||
/>
|
||||
{step === 3 && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<FormField
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
label="Password"
|
||||
hint="8+ chars: uppercase, lowercase, digit, special character"
|
||||
error={errors.password?.message}
|
||||
{...register('password')}
|
||||
/>
|
||||
|
||||
<SubmitButton
|
||||
isSubmitting={props.submitting}
|
||||
idleText="Create Account"
|
||||
submittingText="Creating account..."
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
<FormField
|
||||
id="confirmPassword"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
label="Confirm password"
|
||||
error={errors.confirmPassword?.message}
|
||||
{...register('confirmPassword')}
|
||||
/>
|
||||
|
||||
<ShowPasswordToggle
|
||||
shown={showPassword}
|
||||
onToggle={() => setShowPassword((shown) => !shown)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
{canGoBack && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={wizard.back}
|
||||
className="btn btn-outline min-h-12"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="flex-1">
|
||||
<SubmitButton
|
||||
isSubmitting={submitting}
|
||||
idleText={isLastStep ? 'Create account' : 'Continue'}
|
||||
submittingText="Creating account…"
|
||||
className="btn btn-primary min-h-12 w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegisterForm;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
interface RegisterStepProgressProps {
|
||||
step: number;
|
||||
totalSteps: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const RegisterStepProgress = ({ step, totalSteps, name }: RegisterStepProgressProps) => (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-1.5" aria-hidden="true">
|
||||
{Array.from({ length: totalSteps }, (_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`h-1 flex-1 rounded ${index < step ? 'bg-primary' : 'bg-base-300'}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-[0.8125rem] font-semibold text-[var(--color-muted)]">
|
||||
Step {step} of {totalSteps} · {name}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default RegisterStepProgress;
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Button } from '@headlessui/react';
|
||||
|
||||
interface ShowPasswordToggleProps {
|
||||
shown: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
const ShowPasswordToggle = ({ shown, onToggle }: ShowPasswordToggleProps) => (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="inline-flex min-h-11 items-center self-start text-[0.9375rem] font-semibold text-secondary hover:underline"
|
||||
>
|
||||
{shown ? 'Hide password' : 'Show password'}
|
||||
</Button>
|
||||
);
|
||||
|
||||
export default ShowPasswordToggle;
|
||||
87
web/frontend/app/features/auth/hooks/useRegisterWizard.ts
Normal file
87
web/frontend/app/features/auth/hooks/useRegisterWizard.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useForm, type FieldErrors, type Resolver } from 'react-hook-form';
|
||||
import { earliestStepForFields, lastRegisterStepIndex, registerSteps } from '../registerSteps';
|
||||
import { registerSchema, type RegisterSchema } from '../schemas';
|
||||
|
||||
const resolveRegisterSchema = zodResolver(registerSchema);
|
||||
|
||||
/**
|
||||
* Drives the multi-step register form: one react-hook-form instance spans every
|
||||
* step so values survive going back and forward, and each step validates its own
|
||||
* fields before the next one opens.
|
||||
*/
|
||||
export const useRegisterWizard = (onValid: (values: RegisterSchema) => void) => {
|
||||
const [stepIndex, setStepIndex] = useState(0);
|
||||
|
||||
const currentStep = registerSteps[stepIndex];
|
||||
const isLastStep = stepIndex === lastRegisterStepIndex;
|
||||
|
||||
// Every step submits through `handleSubmit`, so each one has to report only its
|
||||
// own fields: resolve the whole schema, then keep the errors this step owns. The
|
||||
// last step keeps all of them, which is also what runs the password-match
|
||||
// refinement across steps.
|
||||
const resolver: Resolver<RegisterSchema> = async (values, context, options) => {
|
||||
const result = await resolveRegisterSchema(values, context, options);
|
||||
if (isLastStep) return result;
|
||||
|
||||
const errors = Object.fromEntries(
|
||||
Object.entries(result.errors).filter(([name]) =>
|
||||
currentStep.fields.includes(name as keyof RegisterSchema),
|
||||
),
|
||||
) as FieldErrors<RegisterSchema>;
|
||||
|
||||
return Object.keys(errors).length ? { values: {}, errors } : { values, errors: {} };
|
||||
};
|
||||
|
||||
const { register, handleSubmit, formState, setFocus } = useForm<RegisterSchema>({
|
||||
resolver,
|
||||
mode: 'onTouched',
|
||||
});
|
||||
|
||||
const onSubmit = handleSubmit(
|
||||
(values) => {
|
||||
if (isLastStep) {
|
||||
onValid(values);
|
||||
return;
|
||||
}
|
||||
|
||||
setStepIndex((index) => index + 1);
|
||||
},
|
||||
// The last step validates fields from earlier steps too, so open whichever
|
||||
// step owns the problem rather than reporting it out of sight.
|
||||
(errors: FieldErrors<RegisterSchema>) => {
|
||||
const step = earliestStepForFields(Object.keys(errors) as (keyof RegisterSchema)[]);
|
||||
if (step !== null) setStepIndex(step);
|
||||
},
|
||||
);
|
||||
|
||||
// Move focus into each new step, which otherwise swaps out from under the
|
||||
// keyboard while focus sits on the button that opened it.
|
||||
const isInitialStep = useRef(true);
|
||||
useEffect(() => {
|
||||
if (isInitialStep.current) {
|
||||
isInitialStep.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const [firstField] = registerSteps[stepIndex].fields;
|
||||
if (firstField) setFocus(firstField);
|
||||
}, [stepIndex, setFocus]);
|
||||
|
||||
return {
|
||||
step: stepIndex + 1,
|
||||
totalSteps: registerSteps.length,
|
||||
name: currentStep.name,
|
||||
title: currentStep.title,
|
||||
subtitle: currentStep.subtitle,
|
||||
isLastStep,
|
||||
canGoBack: stepIndex > 0,
|
||||
back: () => setStepIndex((index) => Math.max(index - 1, 0)),
|
||||
onSubmit,
|
||||
register,
|
||||
formState,
|
||||
};
|
||||
};
|
||||
|
||||
export type RegisterWizard = ReturnType<typeof useRegisterWizard>;
|
||||
49
web/frontend/app/features/auth/registerSteps.ts
Normal file
49
web/frontend/app/features/auth/registerSteps.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { z } from 'zod';
|
||||
import { registerFields, type RegisterSchema } from './schemas';
|
||||
|
||||
interface RegisterStep {
|
||||
name: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
/** The slice of the register schema this step collects. */
|
||||
schema: z.ZodObject;
|
||||
fields: readonly (keyof RegisterSchema)[];
|
||||
}
|
||||
|
||||
const defineStep = (step: Omit<RegisterStep, 'fields'>): RegisterStep => ({
|
||||
...step,
|
||||
fields: Object.keys(step.schema.shape) as (keyof RegisterSchema)[],
|
||||
});
|
||||
|
||||
export const registerSteps: readonly RegisterStep[] = [
|
||||
defineStep({
|
||||
name: 'Account',
|
||||
title: 'Create your account',
|
||||
subtitle: 'Choose how you will sign in.',
|
||||
schema: registerFields.pick({ username: true, email: true }),
|
||||
}),
|
||||
defineStep({
|
||||
name: 'Personal details',
|
||||
title: 'About you',
|
||||
subtitle: 'Tell us a little about yourself.',
|
||||
schema: registerFields.pick({ firstName: true, lastName: true, dateOfBirth: true }),
|
||||
}),
|
||||
defineStep({
|
||||
name: 'Password',
|
||||
title: 'Secure your account',
|
||||
subtitle: 'Pick a strong password.',
|
||||
schema: registerFields.pick({ password: true, confirmPassword: true }),
|
||||
}),
|
||||
];
|
||||
|
||||
export const lastRegisterStepIndex = registerSteps.length - 1;
|
||||
|
||||
/** The step a field belongs to, so an error can open the step that owns it. */
|
||||
export const stepIndexForField = (field: keyof RegisterSchema) =>
|
||||
registerSteps.findIndex((step) => step.fields.includes(field));
|
||||
|
||||
/** The earliest step owning any of `fields`, or `null` when none of them do. */
|
||||
export const earliestStepForFields = (fields: readonly (keyof RegisterSchema)[]) => {
|
||||
const indexes = fields.map(stepIndexForField).filter((index) => index >= 0);
|
||||
return indexes.length ? Math.min(...indexes) : null;
|
||||
};
|
||||
@@ -1,10 +1,12 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { LogIn } from 'iconoir-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { redirect, useNavigation, useSubmit } from 'react-router';
|
||||
import { createAuthSession, getOptionalAuth, login } from '../auth.server';
|
||||
import AuthCardHeading from '../components/AuthCardHeading';
|
||||
import AuthErrorAlert from '../components/AuthErrorAlert';
|
||||
import AuthSplitLayout from '../components/AuthSplitLayout';
|
||||
import AuthSwitchPrompt from '../components/AuthSwitchPrompt';
|
||||
import LoginForm from '../components/LoginForm';
|
||||
import RegisterCallout from '../components/RegisterCallout';
|
||||
import { useActionErrorToast } from '../hooks/useActionErrorToast';
|
||||
import { loginSchema, type LoginSchema } from '../schemas';
|
||||
import type { Route } from './+types/login';
|
||||
@@ -54,34 +56,23 @@ const Login = ({ actionData }: Route.ComponentProps) => {
|
||||
useActionErrorToast(actionData?.error);
|
||||
|
||||
return (
|
||||
<div className="hero min-h-screen bg-base-200">
|
||||
<div className="card w-full max-w-md bg-base-100 shadow-xl">
|
||||
<div className="card-body gap-4">
|
||||
<div className="text-center">
|
||||
<h1 className="card-title text-3xl justify-center gap-2">
|
||||
<LogIn className="size-7" aria-hidden="true" />
|
||||
Login
|
||||
</h1>
|
||||
<p className="text-base-content/70">Sign in to your Biergarten account</p>
|
||||
</div>
|
||||
<AuthSplitLayout
|
||||
headline="Good to see you again."
|
||||
blurb="Sign in to pick up where you left off with our partner breweries."
|
||||
>
|
||||
<AuthCardHeading title="Sign in" subtitle="Sign in to your Biergarten account" />
|
||||
|
||||
{actionData?.error && (
|
||||
<div role="alert" className="alert alert-error alert-soft">
|
||||
<span>{actionData.error}</span>
|
||||
</div>
|
||||
)}
|
||||
{actionData?.error && <AuthErrorAlert message={actionData.error} />}
|
||||
|
||||
<LoginForm
|
||||
onSubmit={onSubmit}
|
||||
formState={formState}
|
||||
register={register}
|
||||
submitting={isSubmitting}
|
||||
/>
|
||||
<LoginForm
|
||||
onSubmit={onSubmit}
|
||||
formState={formState}
|
||||
register={register}
|
||||
submitting={isSubmitting}
|
||||
/>
|
||||
|
||||
<RegisterCallout />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AuthSwitchPrompt lead="New to Biergarten?" to="/register" label="Create an account" />
|
||||
</AuthSplitLayout>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user