From ac0494f62fadc419fab85d1f255ce36d07c1f10a Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Sat, 15 Aug 2026 06:55:47 +0200 Subject: [PATCH] feat(positions): tabbed position modal, terminal slots staged into create MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings PositionDetails in line with the admin Modal & Form Contract on the two points it broke. Tabs: the five stacked sections become General / Terminals / Authorized users / Staffing sessions, the shape every comparable modal already uses (Group, User, Client, App, LoginProvider). Sessions is absent in create — it cannot exist before the position does. Since a tab can hide a field, tab labels carry a warning popover listing that tab's validation issues, so a disabled Save always has a visible cause. Section headings that only repeated the tab label are gone. Whole-entity create: terminal slots are now staged in create mode and committed by the single Save, the way grants already were and the way the service account stages its initial credential. PositionCreateDto grows a Terminals list; the create endpoint validates it up front, then stages each slot's terminal-managed client via StageCreateTerminalClient plus its enrollment stream into the same Marten session that holds the position and grant streams. One SaveChangesAsync commits all of it, and a rejected slot returns before that — no half-created position, no orphaned OAuth client. Enrollment deliberately stays a later step: it is a ceremony on the device, not a setting. Co-Authored-By: Claude Fable 5 --- docs/admin/positions.md | 10 +- .../Positions/PositionCrudTests.cs | 78 ++++++ .../Positions/PositionTerminalsEndpoints.cs | 2 +- .../Features/Positions/PositionsEndpoints.cs | 43 ++++ .../DTOs/Positions/PositionPrincipalDtos.cs | 10 + src/frontend-vue/public/i18n/de.json | 10 +- src/frontend-vue/src/models/position.ts | 8 + .../views/admin/position/PositionDetails.vue | 241 ++++++++++++++---- 8 files changed, 350 insertions(+), 52 deletions(-) diff --git a/docs/admin/positions.md b/docs/admin/positions.md index 32abbb2b..b56cc15c 100644 --- a/docs/admin/positions.md +++ b/docs/admin/positions.md @@ -28,8 +28,11 @@ classes, wire formats, integration events) lives under 960 = 16 h). **Absolute maximum** — the hard ceiling no refresh can extend past (default 1440 = 24 h). Access tokens stay short-lived (10 min) independently of these. -- **Authorized users** can be staged right in the create dialog — the - position and its grants are created in one atomic save. +- **Authorized users** and **terminal slots** can be staged right in the + create dialog, on their own tabs — the position, its grants and its slots + are created in one atomic save. Nothing forces you to create the position + first and come back for the rest. (Enrolling a device stays a later step: + that is a ceremony on the device, not a setting.) Like every principal, the position receives roles/permissions through the normal groups & roles machinery — that is what ends up in its staffing @@ -52,7 +55,8 @@ staffing sessions and revokes the session tokens. ## 3. Create terminal slots -**Position detail → Shared terminals.** One slot per physical device. +**Position detail → Terminals** (or the same tab while creating the +position). One slot per physical device. Each slot atomically creates its own locked-down OAuth client (public, no secret, DPoP mandatory, reference tokens — the generic OAuth admin surface is read-only for it). diff --git a/src/dotnet/Modgud.Api.Tests/Positions/PositionCrudTests.cs b/src/dotnet/Modgud.Api.Tests/Positions/PositionCrudTests.cs index 2722945c..8ada0350 100644 --- a/src/dotnet/Modgud.Api.Tests/Positions/PositionCrudTests.cs +++ b/src/dotnet/Modgud.Api.Tests/Positions/PositionCrudTests.cs @@ -1,3 +1,7 @@ +using BuildingBlocks.Helper; +using Marten; +using Modgud.Domain.PositionTerminals; +using Modgud.Domain.OAuth.Applications; using System.Net; using System.Net.Http.Json; using Modgud.Api.Tests.Infrastructure; @@ -217,6 +221,80 @@ public async Task Positions_are_event_sourced_one_event_per_mutation() Assert.Contains(stream, e => e.Data is Modgud.Authorization.Events.PositionPrincipalDeletedEvent); } + /// + /// Modal contract rule 5 — a position is creatable as a whole: staged + /// terminal slots travel in the create body and commit with the position, + /// exactly like the service account's initial credential. Each slot brings + /// its managed OAuth client along in that same unit of work. + /// + [Fact] + public async Task Create_sets_up_staged_terminal_slots_in_the_same_save() + { + var ct = TestContext.Current.CancellationToken; + SetFeatureFlag(true); + + var resp = await Client.PostAsJsonAsync("/api/position", new + { + AccountName = "portier.staged", + TerminalPolicy = new { Enabled = true }, + Terminals = new[] + { + new { DisplayName = "Terminal links", Location = "Tor 3", WebAuthnRpId = "alerthub.example.com" }, + new { DisplayName = "Terminal rechts", Location = (string?)null, WebAuthnRpId = "alerthub.example.com" }, + }, + }, JsonOptions, ct); + var body = await resp.Content.ReadAsStringAsync(ct); + Assert.True(resp.IsSuccessStatusCode, $"create failed ({(int)resp.StatusCode}): {body}"); + + var created = (await resp.Content.ReadFromJsonAsync(JsonOptions, ct))!; + Assert.True(created.TerminalPolicy.Enabled); + + var slots = await Client.GetFromJsonAsync>( + $"/api/position/{created.Id}/terminals", JsonOptions, ct); + Assert.NotNull(slots); + Assert.Equal(2, slots!.Count); + Assert.All(slots, s => Assert.Equal(TerminalEnrollmentStatus.Pending, s.Status)); + Assert.All(slots, s => Assert.StartsWith("portier.staged.terminal.", s.ClientId)); + Assert.Equal("Tor 3", slots.Single(s => s.DisplayName == "Terminal links").Location); + + // Every slot's managed client committed with it — no half-created pair. + using var scope = Factory.Services.CreateScope(); + var session = scope.ServiceProvider.GetRequiredService(); + foreach (var slot in slots) + { + var client = (await session.Query() + .Where(c => c.ClientId == slot.ClientId).ToListAsync(ct)).Single(); + Assert.Equal(new ShortGuid(created.Id).Guid, client.LinkedPositionPrincipalId); + Assert.Equal(new ShortGuid(slot.Id).Guid, client.ManagedTerminalEnrollmentId); + } + } + + /// Plan §4.1 holds at create time too: slots need terminal use. + /// The rejection is all-or-nothing — no position, no orphaned client. + [Fact] + public async Task Create_rejects_staged_slots_while_terminal_use_stays_off() + { + var ct = TestContext.Current.CancellationToken; + SetFeatureFlag(true); + + var resp = await Client.PostAsJsonAsync("/api/position", new + { + AccountName = "portier.noterminals", + Terminals = new[] { new { DisplayName = "Terminal links", WebAuthnRpId = "alerthub.example.com" } }, + }, JsonOptions, ct); + + Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); + Assert.Contains("TerminalPolicyDisabled", await resp.Content.ReadAsStringAsync(ct)); + + var all = await Client.GetFromJsonAsync>("/api/position", JsonOptions, ct); + Assert.DoesNotContain(all!, p => p.AccountName == "portier.noterminals"); + + using var scope = Factory.Services.CreateScope(); + var session = scope.ServiceProvider.GetRequiredService(); + Assert.Empty(await session.Query() + .Where(c => c.ClientId.StartsWith("portier.noterminals.")).ToListAsync(ct)); + } + private async Task CreatePositionAsync(string accountName, CancellationToken ct) { var resp = await Client.PostAsJsonAsync("/api/position", new { AccountName = accountName }, JsonOptions, ct); diff --git a/src/dotnet/Modgud.Api/Features/Positions/PositionTerminalsEndpoints.cs b/src/dotnet/Modgud.Api/Features/Positions/PositionTerminalsEndpoints.cs index 17a50a10..041b6a16 100644 --- a/src/dotnet/Modgud.Api/Features/Positions/PositionTerminalsEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Positions/PositionTerminalsEndpoints.cs @@ -255,7 +255,7 @@ await bus.PublishAsync(new Modgud.Domain.PositionTerminals.Contracts.V1.Position return terminal is null || terminal.PositionPrincipalId != positionId ? null : terminal; } - private static async Task LoadDtoAsync(IDocumentSession session, Guid terminalId, CancellationToken ct) + internal static async Task LoadDtoAsync(IDocumentSession session, Guid terminalId, CancellationToken ct) => ToDto((await session.LoadAsync(terminalId, ct))!); private static TerminalDto ToDto(TerminalEnrollment t) => new() diff --git a/src/dotnet/Modgud.Api/Features/Positions/PositionsEndpoints.cs b/src/dotnet/Modgud.Api/Features/Positions/PositionsEndpoints.cs index f9961b5e..3b1bef3e 100644 --- a/src/dotnet/Modgud.Api/Features/Positions/PositionsEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Positions/PositionsEndpoints.cs @@ -2,6 +2,7 @@ using BuildingBlocks.EventDispatcher; using BuildingBlocks.Helper; using Modgud.Application.DTOs.Positions; +using Modgud.Application.Services; using Modgud.Authorization.AspNetCore; using Modgud.Authorization.Events; using Modgud.Authorization.Principals; @@ -61,6 +62,7 @@ public static WebApplication MapPositionsEndpoints(this WebApplication applicati PositionCreateDto dto, AppSettings settings, IDocumentSession session, + OAuthAdminService oauth, DataEventDispatcher dispatcher, HttpContext httpContext, CancellationToken ct) => @@ -77,6 +79,18 @@ public static WebApplication MapPositionsEndpoints(this WebApplication applicati var policy = ApplyPolicy(PositionTerminalPolicy.Disabled, dto.TerminalPolicy, out var policyError); if (policyError is not null) return policyError; + // Staged terminal slots — same up-front validation as the grants + // below. Plan §4.1 still holds: slots exist only while the + // position is opted into terminal use, so the staged policy has + // to enable it in this very save. + var stagedTerminals = dto.Terminals ?? []; + if (stagedTerminals.Count > 0 && !policy.Enabled) + return Results.BadRequest(new { Error = "Terminal.TerminalPolicyDisabled", + Message = "Enable terminal use on the position before adding terminal slots." }); + if (stagedTerminals.Any(t => string.IsNullOrWhiteSpace(t.DisplayName))) + return Results.BadRequest(new { Error = "Terminal.DisplayNameRequired", + Message = "A display name is required." }); + // Staged grants (rule 5: the entity is creatable completely) — // resolve and validate EVERY user before creating anything, so a // malformed or inactive user can never leave a half-granted @@ -123,10 +137,39 @@ public static WebApplication MapPositionsEndpoints(this WebApplication applicati grantId, new Modgud.Domain.PositionTerminals.PositionGrantIssued( grantId, fn.Id, grantUserId, actor, now)); } + + // ... and every staged slot with its terminal-managed client, in + // that same unit of work (mirrors the service-account initial + // credential). A rejected slot returns before SaveChanges, so + // the whole create — position, grants, slots — never happened. + var terminalIds = new List(); + foreach (var terminal in stagedTerminals) + { + var enrollmentId = Guid.NewGuid(); + var applicationId = Guid.NewGuid(); + var clientId = $"{fn.AccountName}.terminal.{new ShortGuid(Guid.NewGuid()).ToString()[..8]}"; + + var clientError = oauth.StageCreateTerminalClient( + applicationId, clientId, $"{fn.DisplayName} — {terminal.DisplayName.Trim()}", + fn.Id, enrollmentId, terminal.WebAuthnRpId); + if (clientError is not null) + return Results.BadRequest(new { Error = clientError.Value.Code, Message = clientError.Value.Description }); + + session.Events.StartStream(enrollmentId, new TerminalEnrollmentCreated( + enrollmentId, fn.Id, terminal.DisplayName.Trim(), + string.IsNullOrWhiteSpace(terminal.Location) ? null : terminal.Location.Trim(), + applicationId, clientId, terminal.WebAuthnRpId.Trim().ToLowerInvariant(), + actor, now)); + terminalIds.Add(enrollmentId); + } + await session.SaveChangesAsync(ct); var created = ToDto(fn); dispatcher.DispatchCreatedEvent("Position", created, session.TenantId); + foreach (var terminalId in terminalIds) + dispatcher.DispatchCreatedEvent("Terminal", + await PositionTerminalsEndpoints.LoadDtoAsync(session, terminalId, ct), session.TenantId); return Results.Ok(created); }) .WithName("V2_Position_Create") diff --git a/src/dotnet/Modgud.Application/DTOs/Positions/PositionPrincipalDtos.cs b/src/dotnet/Modgud.Application/DTOs/Positions/PositionPrincipalDtos.cs index 0b86bb54..6aa8b34a 100644 --- a/src/dotnet/Modgud.Application/DTOs/Positions/PositionPrincipalDtos.cs +++ b/src/dotnet/Modgud.Application/DTOs/Positions/PositionPrincipalDtos.cs @@ -49,6 +49,16 @@ public class PositionCreateDto /// grant stream commit in one unit of work. /// public List? GrantUserIds { get; set; } + + /// + /// Terminal slots to set up in the same save (modal-contract rule 5 — like + /// the service account's initial credential). Requires + /// to enable terminal use. All-or-nothing: each + /// slot's OAuth client is staged into the same session as the position and + /// grant streams, so one rejected slot leaves nothing behind. Enrollment + /// stays a later step — that is a device ceremony, not a setting. + /// + public List? Terminals { get; set; } } public class PositionUpdateDto diff --git a/src/frontend-vue/public/i18n/de.json b/src/frontend-vue/public/i18n/de.json index 48830cfa..eeb3ae53 100644 --- a/src/frontend-vue/public/i18n/de.json +++ b/src/frontend-vue/public/i18n/de.json @@ -467,6 +467,11 @@ "section.basics": "Basis", "section.status": "Status", "section.terminals": "Geteilte Terminals", + "tabs.general": "Allgemein", + "tabs.terminals": "Terminals", + "tabs.grants": "Berechtigte Benutzer", + "tabs.sessions": "Schichten", + "validation.incomplete": "Fehlende Angaben", "terminalsEnabled": "Terminal-Nutzung", "terminalsEnabledHint": "Standardmäßig aus. Terminal-Slots können nur angelegt und enrollt werden, solange dies aktiv ist; Personal besetzt die Position dann per Passkey-Tap.", "sessionLifetime": "Besetzungs-Session (Minuten)", @@ -493,8 +498,11 @@ "revokeConfirm": "Der Widerruf ist endgültig — eine spätere erneute Berechtigung erzeugt einen neuen Eintrag mit eigener Audit-Spur." }, "positionTerminals": { - "createFirst": "Terminal-Slots lassen sich einrichten, sobald die Position angelegt ist.", "enablePolicyFirst": "Terminal-Nutzung aktivieren und speichern, bevor Slots angelegt werden.", + "enablePolicyStaged": "Terminal-Nutzung einschalten, um Slots hinzuzufügen — sie werden gemeinsam mit der Position angelegt.", + "stagedNeedPolicy": "Terminal-Nutzung einschalten — die vorgemerkten Slots werden damit gespeichert.", + "emptyStaged": "Noch keine Slots vorgemerkt.", + "statusStaged": "Beim Speichern", "name": "Terminal-Name", "namePlaceholder": "Portier-Terminal links, …", "location": "Standort", diff --git a/src/frontend-vue/src/models/position.ts b/src/frontend-vue/src/models/position.ts index 1857f126..1e40fd54 100644 --- a/src/frontend-vue/src/models/position.ts +++ b/src/frontend-vue/src/models/position.ts @@ -27,6 +27,14 @@ export interface PositionCreateDto { TerminalPolicy?: PositionTerminalPolicyUpdateDto /** Users authorized in the same save (staged in create mode; all-or-nothing). */ GrantUserIds?: string[] + /** Terminal slots set up in the same save; requires TerminalPolicy.Enabled. */ + Terminals?: TerminalCreateDto[] +} + +export interface TerminalCreateDto { + DisplayName: string + Location?: string + WebAuthnRpId: string } export interface PositionUpdateDto { diff --git a/src/frontend-vue/src/views/admin/position/PositionDetails.vue b/src/frontend-vue/src/views/admin/position/PositionDetails.vue index 866057ae..e1b48b97 100644 --- a/src/frontend-vue/src/views/admin/position/PositionDetails.vue +++ b/src/frontend-vue/src/views/admin/position/PositionDetails.vue @@ -12,6 +12,10 @@ import { CoarButton, CoarTag, CoarPopconfirm, + CoarTabGroup, + CoarTab, + CoarIcon, + CoarPopover, useToast, } from '@cocoar/vue-ui' import { useI18n } from '@cocoar/vue-localization' @@ -33,6 +37,9 @@ const userStore = useUserStore() const isCreate = computed(() => props.id === 'create') const loading = ref(false) const error = ref(null) +// Modal-contract rule 5: create and edit share the layout — the sessions tab +// is simply absent while the position does not exist yet. +const activeTab = ref<'general' | 'terminals' | 'grants' | 'sessions'>('general') const form = ref({ AccountName: '', @@ -77,7 +84,8 @@ const modalTitle = computed(() => { const footerButton = computed(() => ({ visible: true, text: isCreate.value ? t('common.create', {}, 'Create') : t('common.save', {}, 'Save'), - disabled: !form.value.AccountName.trim() || !!accountNameError.value || !!lifetimeError.value || loading.value, + disabled: !form.value.AccountName.trim() || generalIssues.value.length > 0 + || terminalIssues.value.length > 0 || loading.value, onClick: save, })) @@ -151,14 +159,47 @@ async function transitionGrant(grant: PositionGrantDto, action: 'suspend' | 'res } } -// ── Terminal slots (MG-FT-03) — edit-mode operations like grants. Slot -// creation requires the PERSISTED terminal policy to be enabled (the server -// enforces it); a staged-but-unsaved enable is not enough. +// ── Terminal slots (MG-FT-03). Create mode STAGES slots the same way it +// stages grants (rule 5: the entity is creatable completely — mirrors the +// service account's initial credential, which is staged into the same +// atomic create). Edit mode operates on live slots immediately (rule 2), +// where the PERSISTED policy has to allow them. const terminals = ref([]) const terminalsLoading = ref(false) const newTerminal = ref({ DisplayName: '', Location: '', WebAuthnRpId: '' }) const terminalsHttp = computed(() => useHttpClient(`/api/position/${props.id}/terminals`)) -const canCreateTerminals = computed(() => !isCreate.value && original.value.TerminalEnabled) +const stagedTerminals = ref<{ DisplayName: string; Location: string; WebAuthnRpId: string }[]>([]) +// In create the staged policy decides (it is committed in the same save); in +// edit only the persisted one does, because the server validates against it. +const canAddTerminal = computed(() => + isCreate.value ? form.value.TerminalEnabled : original.value.TerminalEnabled) + +function stageTerminal() { + if (!newTerminal.value.DisplayName.trim() || !newTerminal.value.WebAuthnRpId.trim()) return + stagedTerminals.value.push({ + DisplayName: newTerminal.value.DisplayName.trim(), + Location: newTerminal.value.Location.trim(), + WebAuthnRpId: newTerminal.value.WebAuthnRpId.trim(), + }) + // Keep the RP ID — every terminal of one consuming app shares it. + newTerminal.value = { DisplayName: '', Location: '', WebAuthnRpId: newTerminal.value.WebAuthnRpId } +} + +function unstageTerminal(index: number) { + stagedTerminals.value.splice(index, 1) +} + +// Tabs organize, they don't disclose (rule 1) — so a validation error on an +// inactive tab is flagged on its label, otherwise a disabled Save would have +// no visible cause. Same shape as GroupDetails. +const generalIssues = computed(() => [accountNameError.value].filter(Boolean) as string[]) +const terminalIssues = computed(() => { + const issues = [lifetimeError.value].filter(Boolean) as string[] + if (stagedTerminals.value.length > 0 && !form.value.TerminalEnabled) + issues.push(t('admin.positionTerminals.stagedNeedPolicy', {}, + 'Turn terminal use on — the staged slots are saved with it.')) + return issues +}) async function loadTerminals() { if (isCreate.value) return @@ -305,6 +346,13 @@ async function save() { IsActive: form.value.IsActive, TerminalPolicy: policyDiff(), GrantUserIds: stagedGrantUserIds.value.length > 0 ? stagedGrantUserIds.value : undefined, + Terminals: stagedTerminals.value.length > 0 + ? stagedTerminals.value.map((slot) => ({ + DisplayName: slot.DisplayName, + Location: slot.Location || undefined, + WebAuthnRpId: slot.WebAuthnRpId, + })) + : undefined, } await store.createEntity(createDto) } else { @@ -336,7 +384,50 @@ async function save() {