Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/content/docs/changelog/index.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: Overview
lastUpdated: 2026-07-13
lastUpdated: 2026-08-17
description: Release notes and version history for fullstackhero.
sidebar:
order: 1
Expand All @@ -11,6 +11,11 @@ seo:

Notable changes to the kit, newest first.

## 2026-08-17

- **Identity: two people editing the same profile no longer silently overwrite each other (fix).** `PUT /api/v1/identity/profile` replaces the whole representation and carried no concurrency token, so overlapping saves resolved as last-write-wins with the losing change gone and no error raised anywhere - and because both front-ends compensate with a client-side read-modify-write, a save built on a stale read confidently echoed every old field back over the newer write. `GET /profile` now returns the user's `ConcurrencyStamp` as a strong `ETag`, and `PUT /profile` honours `If-Match`: a stale token is answered with `412 Precondition Failed` and nothing is written. The header is optional, so existing callers are unaffected; `If-Match: *` is accepted, a weak validator never matches (`If-Match` mandates strong comparison), and a malformed header is a `400` rather than a `412` that would trap a client in an unwinnable retry loop. The precondition runs before any storage call, so a rejected update never orphans an uploaded avatar nor clears the current one. Two smaller fixes came with it: Identity's own `ConcurrencyFailure` result, previously surfacing as a generic `500`, now maps to the same `412`, and the sign-in refresh no longer runs when the update failed. No migration - `AspNetUsers.ConcurrencyStamp` was already an EF Core concurrency token. The tenant dashboard sends the header and retries once on `412`, since the stamp also rotates on writes a user doesn't think of as profile edits (a password change, a new avatar, a failed sign-in). See [Identity](/docs/modules/identity/).
- **CORS: the framework now exposes `ETag` and allows `If-Match`.** An `ETag`/`If-Match` contract is invisible to a browser on another origin unless the server says so: `ETag` is not a CORS-safelisted response header, and the kit's policy never called `WithExposedHeaders`, so a front-end read `null` and silently stopped sending the precondition. The policy now exposes `ETag` on both the permissive and restricted branches, and `if-match` ships in `CorsOptions.AllowedHeaders` in `appsettings.json` and `appsettings.Production.json`. If you replace the policy with your own, keep both, otherwise the profile precondition above degrades back to a lost update with no error to notice.

## 2026-07-13

- **Dashboard: tenants can now edit their own branding from Settings.** A new **Settings → Branding** tab lets a tenant admin holding `Tenants.UpdateTheme` customise their **light and dark palettes** and **brand asset URLs** (logo, dark-mode logo, favicon) with a live preview - mirroring the operator's existing tenant-branding card, but self-service and with no `tenant:` header, since the theme endpoints are already scoped to the current tenant. The tab renders only for holders of that permission; a direct-URL visit without it hits the API's `403`, surfaced as an error band. Editing is draft-based - a **Reset to defaults** action and per-palette reset are available, and unsaved edits are preserved while you work (a co-admin's concurrent change appears on a manual refresh rather than overwriting your form).
Expand Down
32 changes: 29 additions & 3 deletions src/content/docs/modules/identity.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: Identity module
lastUpdated: 2026-06-11
lastUpdated: 2026-08-17
description: JWT bearer + refresh tokens, ASP.NET Identity with roles + permissions, user groups, operator impersonation, two-factor TOTP, sessions, and password-policy enforcement.
sidebar:
label: Identity
Expand Down Expand Up @@ -145,6 +145,32 @@ endpoints.MapPost("/users", handler)

`EnrollTwoFactorCommand` returns a QR code + secret; `VerifyEnrollTwoFactorCommand(code)` activates; `DisableTwoFactorCommand(currentPassword)` turns it off.

### Profile concurrency

`PUT /profile` replaces the whole representation, so two overlapping saves used to silently overwrite each other: whoever wrote last won, and the other person's change was gone with no error anywhere. The endpoint now supports an optimistic precondition.

`GET /profile` returns the user's `ConcurrencyStamp` as a strong `ETag`. ASP.NET Identity already treats that column as an EF Core concurrency token and rotates it on every write, so no extra column and no migration were needed. Echo the tag back as `If-Match` on the `PUT` and the write is rejected with `412 Precondition Failed` when the stored profile has moved on:

```http
GET /api/v1/identity/profile
→ 200 OK
ETag: "6f9619ff-8b86-d011-b42d-00cf4fc964ff"

PUT /api/v1/identity/profile
If-Match: "6f9619ff-8b86-d011-b42d-00cf4fc964ff"
→ 200 OK # nobody else wrote in between
→ 412 # someone did; nothing was written
```

Details worth knowing:

- **The header is optional.** A `PUT` without `If-Match` behaves exactly as before, so existing callers keep working. `If-Match: *` is accepted for the same reason: it matches any current representation.
- **Weak validators never match.** `If-Match` mandates the strong comparison function, so `W/"..."` is answered with `412`.
- **A malformed header is a `400`, not a `412`.** A `412` would send a well-behaved client into a refetch-and-retry loop it can never win, because the broken header is its own bug.
- **Nothing is written or deleted before the check.** The precondition runs ahead of the storage calls, so a rejected update never orphans an uploaded avatar and never clears the current one.
- **The token also rotates on writes the user does not think of as profile edits**, such as a password change, a new avatar, or a failed sign-in. A client should therefore refetch and retry once on `412` rather than surface it as a failed save, which is what `clients/dashboard` does.
- **CORS is already configured for it.** `ETag` is not a CORS-safelisted response header, so the framework's CORS policy exposes it explicitly and `if-match` ships in `CorsOptions.AllowedHeaders`. Without both, a browser on another origin cannot read the tag and silently stops sending the precondition. Keep them in place if you replace the policy with your own.

## Endpoints

All 51 endpoints are under `/api/v1/identity/`. The rate-limited `auth` policy covers `POST /token/issue`, `POST /token/refresh`, `GET /confirm-email`, `POST /users/{id}/resend-confirmation-email`, `POST /forgot-password`, `POST /reset-password`, and `POST /self-register`. Full table:
Expand All @@ -153,8 +179,8 @@ All 51 endpoints are under `/api/v1/identity/`. The rate-limited `auth` policy c
|---|---|---|
| POST | `/token/issue` | Login |
| POST | `/token/refresh` | Rotate refresh token |
| GET | `/profile` | Current user profile |
| PUT | `/profile` | Update own profile |
| GET | `/profile` | Current user profile (returns a strong `ETag`) |
| PUT | `/profile` | Update own profile (honours `If-Match`, answers `412` on a stale token) |
| PUT | `/profile/image` | Set / clear profile image |
| GET | `/permissions` | Current user permissions |
| GET | `/permissions/catalog` | All registered permissions (catalog) |
Expand Down