Skip to content

feat(apps): an App is one resource — fold settings in, atomic create/update#108

Merged
windischb merged 2 commits into
developfrom
feat/unified-app-resource
Jun 30, 2026
Merged

feat(apps): an App is one resource — fold settings in, atomic create/update#108
windischb merged 2 commits into
developfrom
feat/unified-app-resource

Conversation

@windischb

Copy link
Copy Markdown
Contributor

Why

An App was split across two surfaces — the App (RBAC permission catalog) and the ADR-0011 per-App settings override (branding / origin / login posture / native grants / DCR / CIMD) — each with its own modal and its own endpoint. Finding where a setting lived was confusing, and "create/update an app" took two calls.

This makes the App one resource (API-first, like Users): the per-App settings override is carried inline on POST/PUT/GET /api/app and written in the same tenant transaction as the App aggregate (atomic). The granular /api/app/{id}/settings endpoint is removed — there is one write path.

Backend

  • CreateAppDto/UpdateAppDto gain an optional nested Settings; the read includes it.
  • AppAdminService (the canonical path the realm-provisioning applier also uses) stages the settings override onto the same session and commits once. The only piece that can't be in the tenant transaction — the Origin subdomain, which drives the GLOBAL host→App routing map — is validated up-front (an invalid subdomain rejects the whole create/update before any commit) and its routing applied right after the atomic commit. The applier passes no settings, so its behaviour is unchanged.
  • Settings is a REPLACE of the complete override state: a provided section sets the override, a null section clears it (→ inherit the realm). ApplicationSettingsService gains StageNonOriginAsync (no-commit) + ValidateOriginAsync; PatchAsync stays sparse for the Origin follow-on.
  • Removed ApplicationSettingsEndpoints + its registration.

Frontend

  • Folded ApplicationSettingsModal's tabs into AppDetails as one Einstellungen tab (new AppSettingsSections component); removed the separate modal, its route and the "Einstellungen" context-menu entry. Settings load/save ride the App create/update.

Existing entities

No migration — the data model is unchanged. Existing apps read and round-trip correctly (with or without a settings doc); pre-existing empty-override docs from the old modal self-heal on next save under REPLACE. Only external callers of the removed /settings endpoint would need to move to the App resource.

Tests & verification

  • Migrated ApplicationSettingsAdminTests + the invite-code posture test to the unified endpoint; added atomic-create coverage (valid settings persist with the App; invalid settings reject the whole create — no orphan App).
  • 15 + 26 + 11 backend tests green (incl. the provisioning applier and effective-settings resolution unchanged); frontend type-check + build + full solution build clean.
  • Visual smoke (chrome-devtools): unified modal renders, system apps hide the settings tab, create → single POST /api/app (settings inline, no /settings call), GET /api/app/{id} returns settings inline, settings round-trip faithfully (only enabled sections show checked). The smoke test caught a real round-trip bug (sections showed as overridden after save) which is fixed in the second commit.

🤖 Generated with Claude Code

windischb and others added 2 commits June 30, 2026 16:02
…ic create/update

An App was split across two surfaces: the App (RBAC catalog) and the ADR-0011
per-App settings override (branding / origin / login posture / native grants /
DCR / CIMD), each with its own modal and its own endpoint. Discovering where a
setting lived was confusing, and "create/update an app" took two calls.

Make the App ONE resource (API-first, like Users): the per-App settings override
is carried inline on POST/PUT/GET /api/app and written in the SAME tenant
transaction as the App aggregate (atomic). The granular /api/app/{id}/settings
endpoint is removed — there is one write path.

Backend:
- CreateAppDto/UpdateAppDto gain an optional nested Settings; the read includes it.
- AppAdminService (the canonical path the applier also uses) stages the settings
  override onto the same session and commits once. The only piece that can't be in
  the tenant transaction — the Origin subdomain, which drives the GLOBAL host→App
  routing map — is validated up-front (an invalid subdomain rejects the whole
  create/update before any commit) and its routing applied right after the atomic
  commit. The applier passes no settings, so its behaviour is unchanged.
- ApplicationSettingsService gains StageNonOriginAsync (no-commit) + ValidateOriginAsync.
- Removed ApplicationSettingsEndpoints + its registration.

Frontend:
- Folded ApplicationSettingsModal's tabs into AppDetails as one "Einstellungen" tab
  (new AppSettingsSections component); removed the separate modal, its route and the
  "Einstellungen" context-menu entry. Settings load/save ride the App create/update.

Tests: migrated ApplicationSettingsAdminTests + the invite-code posture test to the
unified endpoint and added atomic-create coverage (valid settings persist with the
App; invalid settings reject the whole create — no orphan App). 15 + 26 + 11 backend
tests green (incl. the provisioning applier and effective-settings resolution
unchanged); frontend type-check + build + full solution build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ty-fill)

The settings form built `{}` for unchecked sections, the backend stored those
as empty-but-present overrides, and on read they came back non-null — so every
section showed as "overridden" after a save (caught by the visual smoke test:
creating an app with only Native Grants reopened with Branding also ticked).

Fix: the unified App create/update is a REPLACE of the complete override state.
build() sends `null` for unchecked sections; StageNonOriginAsync clears a null
section (and sets a present one). An unchecked section now round-trips back
unchecked. PatchAsync stays sparse for the Origin-only follow-on. (Origin remains
sparse here — toggling a subdomain off doesn't clear an existing route in this
view.) Pre-existing empty-override docs from the old modal self-heal on next save.

Verified in the browser: create app with only Native Grants → reopen shows only
Native Grants ticked, Origin/Branding/etc. clean; single POST/GET /api/app, no
/settings call. 16 settings tests green; frontend type-check clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@windischb windischb merged commit 93eaf08 into develop Jun 30, 2026
8 checks passed
@windischb windischb deleted the feat/unified-app-resource branch June 30, 2026 15:44
windischb added a commit that referenced this pull request Jun 30, 2026
* feat(admin): generic per-entity Clone/Copy

Slugs, client_ids and audiences are immutable by design, so the way to
"rename" an entity is to clone it under a new identity and re-wire the
references. Add a right-click "Clone" affordance across the six admin
entities: App, OAuth Client, Scope, API, Role and Group.

One pattern for all: a `useClone()` composable holds a module-level stash
(the fragment-routed modals only carry an `id` slot, so the prefill rides
out-of-band) plus a tiny per-entity descriptor — identity fields blanked,
secrets/server-issued ids dropped, everything else copied 1:1. A List
stages the prefill from the full source DTO and opens the Create modal;
the modal consumes it on mount and maps it through its existing fromDto.

Per-entity shaping:
- App: blank Slug, null catalog-entry ids (they belong to the source's
  streams — the clone mints fresh ids), drop the Origin override (the
  subdomain is globally unique). Branding/registration/grant overrides
  clone 1:1 via the settings tab.
- OAuth Client: blank ClientId, drop the hashed secret (create mints a
  fresh one) + DCR audit fields + SA linkage.
- Scope / API / Role / Group: blank the immutable identity (Name / aud);
  drop API secrets; Group's last script error is not carried over.

Frontend-only — reuses the existing create endpoints, no backend change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(admin): clone Role/Group via JSON deep-clone, not structuredClone

The Role/Group clone handlers pass the Pinia store entity (a Vue reactive
Proxy) straight into buildClonePrefill. structuredClone rejects a Proxy
with DataCloneError, so right-click → Klonen threw on those two lists
(App/Scope/API/Client went through a fresh loadOne object and happened to
work). Switch buildClonePrefill to a JSON round-trip: the DTOs are pure
JSON (no Dates/Sets/functions), so it is a faithful detached deep copy
and serialises straight through the reactive proxy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(admin): document entity Clone + correct stale App-settings refs

Add a concise "Cloning" section to each admin entity page (Applications,
OAuth Clients/Scopes/APIs, Roles, Groups): how to invoke (list →
right-click → Clone), which immutable identity is blanked, and which
secrets/server-issued fields are dropped — framing clone as the way to
"rename" an entity whose identity can't be changed.

While in applications.md + the admin-API reference, correct the App
**settings** references that PR #108 made stale: the separate
`settings/:id` modal and `GET/PATCH /api/app/{id}/settings` endpoint are
gone — per-App ADR-0011 settings now ride inline on the App resource
(`GET /api/app/{id}` + `POST`/`PUT /api/app`, one tenant transaction,
replace semantics). A reader following the old docs would have hit a 404.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant