diff --git a/aspire.config.json b/aspire.config.json index ddf14d95..62aa4daf 100644 --- a/aspire.config.json +++ b/aspire.config.json @@ -1,11 +1,11 @@ { - "appHost" : { - "language" : "csharp", - "path" : "./src/GroundControl.AppHost/GroundControl.AppHost.csproj" + "appHost": { + "language": "csharp", + "path": "./src/GroundControl.AppHost/GroundControl.AppHost.csproj" }, - "features" : { - "defaultWatchEnabled" : true, - "showAllTemplates" : true, - "updateNotificationsEnabled" : true + "features": { + "defaultWatchEnabled": "true", + "showAllTemplates": "true", + "updateNotificationsEnabled": "true" } } \ No newline at end of file diff --git a/docs/guide/concepts.md b/docs/guide/concepts.md index c868a3cc..37339859 100644 --- a/docs/guide/concepts.md +++ b/docs/guide/concepts.md @@ -51,6 +51,8 @@ Use variables for values that appear in many entries, such as a connection strin Variables are resolved at publish time. If a configuration value references a variable that is undefined or cannot be resolved for the target scope, the publish fails with an error telling you exactly which variable is missing. +For a full reference of variable structure, ownership tiers, two-tier resolution, sensitivity, and group/system-wide visibility rules, see [Variables](variables.md). + ## Configuration Entries Configuration entries are the individual key-value pairs that make up your configuration. Each entry has: diff --git a/docs/guide/variables.md b/docs/guide/variables.md new file mode 100644 index 00000000..0099a8ab --- /dev/null +++ b/docs/guide/variables.md @@ -0,0 +1,213 @@ +# Variables + +Variables are named placeholders that get interpolated into configuration entry values at snapshot publish time. They let you keep one source of truth for any value that appears in many entries — connection-string prefixes, API endpoints, shared secrets — and change it in one place instead of editing every entry that uses it. + +This page covers what a variable looks like, how its visibility is determined, how a placeholder gets resolved, and the edge cases you need to know about. + +## Anatomy of a variable + +A variable has a name, an ownership tier, and a list of values. Each value is qualified by zero or more scope dimensions. + +| Field | Type | Purpose | +|---|---|---| +| `name` | string | The key used in `{{name}}` placeholders. Case-insensitive within its uniqueness key. | +| `description` | string? | Optional human-readable note. | +| `scope` | `Global` \| `Project` | Ownership tier. See [Ownership tiers](#ownership-tiers). | +| `groupId` | Guid? | For `Global` variables only. `null` means system-wide; otherwise the variable belongs to that group. Forbidden on `Project` variables. | +| `projectId` | Guid? | Required on `Project` variables; forbidden on `Global` variables. | +| `values` | `ScopedValue[]` | One or more scoped value variants. See [Scoped values](#scoped-values). | +| `isSensitive` | bool | Encrypts at rest, masks as `***` in API responses, and propagates sensitivity to any snapshot entry that interpolates the variable. | +| `version` | long | Optimistic-concurrency token. Required on update/delete via `If-Match`. | + +The full field list including audit timestamps is in [Domain Model — Variable](../design-docs/Domain-Model.md#variable). + +## Ownership tiers + +The `scope` field puts a variable in one of two tiers: + +### Global + +`scope = Global`. Used to define values shared across many projects. + +`groupId` controls visibility: + +- **`groupId = null`** — system-wide global. Every project, in every group (and ungrouped projects), can resolve this variable. +- **`groupId = X`** — group-owned global. Only projects whose `Project.GroupId` equals `X` can resolve it. + +`projectId` must be `null` on global variables. + +### Project + +`scope = Project`. Used to override a global variable's value for one specific project, or to define a value that only that project needs. + +`projectId` is required and must reference an existing project. `groupId` must be `null` — a project variable inherits its group through the project. + +A project variable with the same `name` as a global variable shadows the global for that project (see [Two-tier resolution](#two-tier-resolution)). + +## Scoped values + +Each entry in `values` represents the variable's value for a specific scope combination: + +```json +{ + "scopes": { "Environment": "prod", "Region": "eu" }, + "value": "https://api.eu.example.com" +} +``` + +- `scopes` is a dimension → value map. Dimensions must already exist in the Scopes registry; values must be in the dimension's allowed-values set. Validated on write by [`CreateVariableValidator`](../../src/GroundControl.Api/Features/Variables/CreateVariableValidator.cs). +- An empty `scopes` map (`{}`) marks the **unscoped default** — used when no scoped variant matches the requesting client. +- `value` is always a string. The interpolation rule below treats it as a literal: variable values **cannot themselves contain `{{...}}`** placeholders. Nested interpolation is rejected on write. + +A single variable typically holds one unscoped default plus one variant per environment/region/tier combination it needs to differ on. + +## How a placeholder resolves + +When a snapshot is published for a project, every config entry value is scanned for `{{name}}` placeholders. Each placeholder is resolved using a **two-tier**, **most-specific-scope-wins** algorithm. + +### Two-tier resolution + +For each placeholder `{{name}}`: + +1. Look up `name` in the project's project-scope variables. +2. If found, attempt scope resolution against the client's scopes (see below). If a value resolves, use it. +3. Otherwise, look up `name` in the project's visible globals. +4. If a global match resolves, use it. +5. If neither tier yields a value, the placeholder is **unresolved** and the publish fails with the offending name reported back. + +The implementation lives in [`VariableInterpolator`](../../src/GroundControl.Api/Features/Snapshots/VariableInterpolator.cs). + +### Scope resolution within a tier + +Within a single variable's `values` list, [`ScopeResolver`](../../src/GroundControl.Api/Shared/Resolvers/ScopeResolver.cs) picks one variant: + +1. Filter to candidates whose `scopes` map is a **full match** of the client's scopes — every dimension in the candidate must equal the client's value (case-insensitive on the dimension name, exact on the value). +2. Of the matches, the candidate with the **most dimensions** wins. +3. If no scoped candidate matches, fall back to the unscoped default (`scopes = {}`). +4. If there isn't even an unscoped default, the variable contributes no value and resolution falls through to the next tier (or fails). + +A tie at the same specificity logs a warning and returns the first match — design your scoped values so combinations don't collide. + +### Visibility from a project's perspective + +For a project `P` in group `G`, the variables visible at publish time are: + +| Source | Visible? | +|---|---| +| Project variables where `projectId = P.id` | Always | +| Global variables where `groupId = G` | Yes | +| Global variables where `groupId = null` (system-wide) | Yes | +| Global variables where `groupId = some other group` | **No** | +| Project variables on a different project | **No** | + +Implemented by [`VariableStore.GetGlobalVariablesForGroupAsync`](../../src/GroundControl.Persistence.MongoDb/Stores/VariableStore.cs) and [`SnapshotResolver.ResolveAndInterpolateAsync`](../../src/GroundControl.Api/Features/Snapshots/SnapshotResolver.cs). + +## Sensitivity + +Setting `isSensitive = true` does three things: + +1. **Encryption at rest** — values are encrypted by `SensitiveSourceValueProtector` before being written to MongoDB. +2. **Masking on read** — API responses replace each value with `***` unless the caller has the `sensitive_values:decrypt` permission and adds `?decrypt=true`. +3. **Sensitivity propagation** — any snapshot config entry that interpolates a sensitive variable is itself treated as sensitive. The flag flips on the resolved entry even if the entry was authored as non-sensitive. + +The mask sentinel `***` is reserved: you cannot save a sensitive variable whose plaintext value is literally `***` (the validator rejects it) — it would otherwise be indistinguishable from a masked read. + +## Choosing the right tier + +| You want… | Use | +|---|---| +| One value usable by every project in the system | `Global`, `groupId = null` | +| One value shared across every project in a single group | `Global`, `groupId = ` | +| A per-project tweak of a shared value (same name) | `Project` variable with the same `name` as the global | +| A value only one project ever uses | `Project` variable, no global counterpart | +| Different values per environment but the same name everywhere | One variable with multiple `ScopedValue` entries (`{Environment: prod}`, `{Environment: staging}`, plus an unscoped default) | +| Sharing a single value across **two specific groups** but not others | Not directly supported — either make it system-wide and accept the broader visibility, or duplicate it as a group-owned global in each group | + +## Uniqueness rules + +Enforced by partial unique indexes (case-insensitive) in [`VariableConfiguration`](../../src/GroundControl.Persistence.MongoDb/Conventions/VariableConfiguration.cs): + +- `(scope=Global, groupId, name)` is unique. Two globals can share a name only if they have different `groupId`s (including `null`). +- `(scope=Project, projectId, name)` is unique. + +`name` is treated case-insensitively for both uniqueness and placeholder lookup. + +## Sharp edges + +- **Same name at system-wide and group tier.** A `Global` variable with `groupId = null` and another `Global` variable with `groupId = X` are both stored — the unique index allows it because `groupId` differs. From a project in group `X`, both end up in the same lookup dictionary keyed by name, so whichever the dictionary build encounters last wins. The result is **order-dependent**. Don't rely on this for project-specific overrides — use a `Project`-scope variable instead. +- **No multi-group sharing.** There is no link table, no `groupId[]`, and no template-style attachment. A variable belongs to exactly one tier (system-wide or one group, or one project). +- **Variables can't reference variables.** `{{...}}` is rejected on write inside variable values; only config-entry values may contain placeholders. +- **Resolution is publish-time, not write-time.** A config entry can be saved with `{{Foo}}` even if `Foo` doesn't exist yet. The publish call is what fails when the placeholder can't be resolved. +- **Tied scope specificity.** If two scoped values in the same variable match a client with the same dimension count, you get a warning log and a non-deterministic pick. Make scope combinations unambiguous. + +## Worked examples + +### Shared API endpoint with environment overrides + +One system-wide variable, used by every project, varying by environment: + +```bash +curl -X POST http://localhost:8080/api/variables \ + -H "Content-Type: application/json" \ + -H "api-version: 1.0" \ + -d '{ + "name": "ApiBase", + "scope": "Global", + "isSensitive": false, + "values": [ + { "scopes": {}, "value": "https://api.example.com" }, + { "scopes": { "Environment": "staging" }, "value": "https://api.staging.example.com" }, + { "scopes": { "Environment": "prod" }, "value": "https://api.example.com" } + ] + }' +``` + +In a config entry: + +```json +{ "key": "Endpoints:Api", "valueType": "String", + "values": [{ "value": "{{ApiBase}}/v1" }] } +``` + +A client bound to `{Environment: staging}` resolves to `https://api.staging.example.com/v1`. + +### Group-owned secret with a per-project override + +A group-owned global database connection string: + +```json +{ + "name": "PrimaryDb", + "scope": "Global", + "groupId": "", + "isSensitive": true, + "values": [ + { "scopes": {}, "value": "Server=db.billing.internal;Database=core;" }, + { "scopes": { "Environment": "prod" }, "value": "Server=prod-db.billing.internal;Database=core;Encrypt=True;" } + ] +} +``` + +One project in that group needs to point at a dedicated read-replica. Define a project variable with the same name: + +```json +{ + "name": "PrimaryDb", + "scope": "Project", + "projectId": "", + "isSensitive": true, + "values": [ + { "scopes": { "Environment": "prod" }, "value": "Server=prod-db-reports.billing.internal;Database=core;Encrypt=True;ApplicationIntent=ReadOnly;" } + ] +} +``` + +The reports project in `prod` resolves `{{PrimaryDb}}` to the read-replica string. In any other environment the project variable has no matching scope, so resolution falls back to the global's unscoped default. Other projects in the same group are unaffected — they keep using the global value. + +## Related + +- [Core Concepts — Variables](concepts.md#variables) — short conceptual overview +- [API Reference — Variables](api/endpoints.md#variables) — endpoint shapes +- [CLI — `variable` commands](../cli/configuration.md#variable-----manage-variables) +- [Domain Model — Variable](../design-docs/Domain-Model.md#variable) — full field reference +- [Data Model — `variables`](../design-docs/Data-Model.md#variables) — persistence layout and indexes diff --git a/src/GroundControl.Api/Features/Clients/ClientsModule.cs b/src/GroundControl.Api/Features/Clients/ClientsModule.cs index 5f572d64..193211e0 100644 --- a/src/GroundControl.Api/Features/Clients/ClientsModule.cs +++ b/src/GroundControl.Api/Features/Clients/ClientsModule.cs @@ -14,6 +14,7 @@ public void OnServiceConfiguration(WebApplicationBuilder builder) builder.Services.AddTransient(); builder.Services.AddTransient, CreateClientValidator>(); + builder.Services.AddTransient, UpdateClientValidator>(); builder.Services.AddTransient(); } diff --git a/src/GroundControl.Api/Features/Clients/Contracts/UpdateClientRequest.cs b/src/GroundControl.Api/Features/Clients/Contracts/UpdateClientRequest.cs index aa4d875d..bfad15b0 100644 --- a/src/GroundControl.Api/Features/Clients/Contracts/UpdateClientRequest.cs +++ b/src/GroundControl.Api/Features/Clients/Contracts/UpdateClientRequest.cs @@ -20,8 +20,13 @@ internal sealed record UpdateClientRequest /// public required bool IsActive { get; init; } + /// + /// Gets the fixed scope assignments for the client. When provided, replaces the existing scope context. + /// + public Dictionary? Scopes { get; init; } + /// /// Gets the optional expiration timestamp. /// public DateTimeOffset? ExpiresAt { get; init; } -} \ No newline at end of file +} diff --git a/src/GroundControl.Api/Features/Clients/UpdateClientHandler.cs b/src/GroundControl.Api/Features/Clients/UpdateClientHandler.cs index 59972508..1485145b 100644 --- a/src/GroundControl.Api/Features/Clients/UpdateClientHandler.cs +++ b/src/GroundControl.Api/Features/Clients/UpdateClientHandler.cs @@ -27,10 +27,12 @@ public static void Endpoint(IEndpointRouteBuilder endpoints) HttpContext httpContext, [FromServices] UpdateClientHandler handler, CancellationToken cancellationToken = default) => await handler.HandleAsync(projectId, id, request, httpContext, cancellationToken)) + .WithContractValidation() .RequireAuthorization(Permissions.ClientsWrite) .WithSummary("Update a client") .WithDescription("Updates an existing client. Requires an If-Match header with the current ETag value.") .Produces() + .ProducesValidationProblem() .ProducesProblem(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status409Conflict) .ProducesProblem(StatusCodes.Status428PreconditionRequired) @@ -56,10 +58,21 @@ private async Task HandleAsync(Guid projectId, Guid id, UpdateClientReq var oldName = client.Name; var oldIsActive = client.IsActive; var oldExpiresAt = client.ExpiresAt; + var oldScopes = new Dictionary(client.Scopes); client.Name = request.Name; client.IsActive = request.IsActive; client.ExpiresAt = request.ExpiresAt; + + if (request.Scopes is not null) + { + client.Scopes.Clear(); + foreach (var (dimension, value) in request.Scopes) + { + client.Scopes[dimension] = value; + } + } + client.UpdatedAt = DateTimeOffset.UtcNow; client.UpdatedBy = Guid.Empty; @@ -73,6 +86,7 @@ private async Task HandleAsync(Guid projectId, Guid id, UpdateClientReq .. AuditRecorder.CompareFields("Name", oldName, client.Name), .. AuditRecorder.CompareFields("IsActive", oldIsActive.ToString(), client.IsActive.ToString()), .. AuditRecorder.CompareFields("ExpiresAt", oldExpiresAt?.ToString("O"), client.ExpiresAt?.ToString("O")), + .. CompareScopes(oldScopes, client.Scopes), ]; await _audit.RecordAsync("Client", client.Id, null, "Updated", changes, cancellationToken: cancellationToken).ConfigureAwait(false); @@ -80,4 +94,21 @@ .. AuditRecorder.CompareFields("ExpiresAt", oldExpiresAt?.ToString("O"), client. httpContext.Response.Headers.ETag = EntityTagHeaders.Format(client.Version); return TypedResults.Ok(ClientResponse.From(client)); } + + private static IEnumerable CompareScopes(IReadOnlyDictionary oldScopes, IReadOnlyDictionary newScopes) + { + var dimensions = new HashSet(oldScopes.Keys, StringComparer.Ordinal); + dimensions.UnionWith(newScopes.Keys); + + foreach (var dimension in dimensions) + { + oldScopes.TryGetValue(dimension, out var oldValue); + newScopes.TryGetValue(dimension, out var newValue); + + foreach (var change in AuditRecorder.CompareFields($"Scopes.{dimension}", oldValue, newValue)) + { + yield return change; + } + } + } } \ No newline at end of file diff --git a/src/GroundControl.Api/Features/Clients/UpdateClientValidator.cs b/src/GroundControl.Api/Features/Clients/UpdateClientValidator.cs new file mode 100644 index 00000000..7d9d09f3 --- /dev/null +++ b/src/GroundControl.Api/Features/Clients/UpdateClientValidator.cs @@ -0,0 +1,39 @@ +using GroundControl.Api.Features.Clients.Contracts; +using GroundControl.Persistence.Stores; + +namespace GroundControl.Api.Features.Clients; + +internal sealed class UpdateClientValidator : IAsyncValidator +{ + private readonly IScopeStore _scopeStore; + + public UpdateClientValidator(IScopeStore scopeStore) + { + _scopeStore = scopeStore ?? throw new ArgumentNullException(nameof(scopeStore)); + } + + public async Task ValidateAsync(UpdateClientRequest instance, ValidationContext context, CancellationToken cancellationToken = default) + { + var result = new ValidatorResult(); + + if (instance.Scopes is { Count: > 0 }) + { + foreach (var (dimension, value) in instance.Scopes) + { + var scope = await _scopeStore.GetByDimensionAsync(dimension, cancellationToken).ConfigureAwait(false); + if (scope is null) + { + result.AddError($"Scope dimension '{dimension}' was not found.", nameof(instance.Scopes)); + continue; + } + + if (!scope.AllowedValues.Contains(value)) + { + result.AddError($"Value '{value}' is not allowed for scope dimension '{dimension}'.", nameof(instance.Scopes)); + } + } + } + + return result.IsFailed ? result : ValidatorResult.Success; + } +} diff --git a/src/GroundControl.Api/OpenApi.json b/src/GroundControl.Api/OpenApi.json index e6da9f14..b650dbdf 100644 --- a/src/GroundControl.Api/OpenApi.json +++ b/src/GroundControl.Api/OpenApi.json @@ -531,6 +531,16 @@ } } }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/HttpValidationProblemDetails" + } + } + } + }, "404": { "description": "Not Found", "content": { @@ -6239,6 +6249,16 @@ "type": "boolean", "description": "Gets whether the client is active." }, + "scopes": { + "type": [ + "null", + "object" + ], + "additionalProperties": { + "type": "string" + }, + "description": "Gets the fixed scope assignments for the client. When provided, replaces the existing scope context." + }, "expiresAt": { "type": [ "null", diff --git a/src/GroundControl.Tower/design-tokens/tokens.json b/src/GroundControl.Tower/design-tokens/tokens.json index 272fe62e..ea9142ec 100644 --- a/src/GroundControl.Tower/design-tokens/tokens.json +++ b/src/GroundControl.Tower/design-tokens/tokens.json @@ -119,7 +119,10 @@ }, "buttonDanger": { "activeBrightness": { "$value": { "light": "0.96", "dark": "0.96" }, "$description": "Pressed-state brightness for destructive buttons." }, - "hoverBrightness": { "$value": { "light": "1.08", "dark": "1.08" }, "$description": "Hover brightness for destructive buttons." } + "hoverBrightness": { "$value": { "light": "1.08", "dark": "1.08" }, "$description": "Hover brightness for destructive buttons." }, + "background": { "$value": { "light": "#a53929", "dark": "#c43c3c" }, "$description": "Destructive button fill (hover/focus). Tuned per mode so dark surfaces don't read as washed-out pink." }, + "foreground": { "$value": { "light": "#ffffff", "dark": "#fbf8ff" }, "$description": "Text/icon color on destructive button fill." }, + "outline": { "$value": { "light": "#c9483a", "dark": "#e87575" }, "$description": "Border + text color for the resting (outlined) state of destructive buttons. Brighter than the fill so contrast survives on dark surfaces." } }, "buttonSubtle": { "hoverBackground": { "$value": { "light": "#f4f1fb", "dark": "rgba(146, 107, 255, 0.18)" }, "$description": "Hover background for outline, secondary, and ghost buttons." }, @@ -163,11 +166,11 @@ "radius": { "sm": { "$value": "3px", "$description": "Inline code chips, scope badges." }, - "md": { "$value": "4px", "$description": "Pills, small buttons, small badges." }, - "lg": { "$value": "6px", "$description": "Inputs, nav items, secondary buttons." }, + "md": { "$value": "4px", "$description": "Small badges." }, + "lg": { "$value": "6px", "$description": "Buttons (all sizes/variants), inputs, nav items." }, "xl": { "$value": "8px", "$description": "Cards, table containers." }, "2xl": { "$value": "10px", "$description": "Modal surfaces, floating panels." }, - "pill": { "$value": "9999px", "$description": "Primary/secondary buttons (fully rounded)." } + "pill": { "$value": "9999px", "$description": "Filter chips, segmented controls, count badges, status dots, avatars." } }, "shadow": { diff --git a/src/GroundControl.Tower/scripts/build-tokens.ts b/src/GroundControl.Tower/scripts/build-tokens.ts index a028ad1b..c1253d9a 100644 --- a/src/GroundControl.Tower/scripts/build-tokens.ts +++ b/src/GroundControl.Tower/scripts/build-tokens.ts @@ -127,8 +127,8 @@ function renderShadcnBridge(): string { ' --muted-foreground: var(--tower-fg-caption);', ' --accent: var(--tower-bg-selected);', ' --accent-foreground: var(--tower-fg-heading);', - ' --destructive: var(--tower-badge-critical-fg);', - ' --destructive-foreground: var(--tower-fg-chip-selected);', + ' --destructive: var(--tower-interaction-button-danger-background);', + ' --destructive-foreground: var(--tower-interaction-button-danger-foreground);', ' --border: var(--tower-stroke-subtle);', ' --input: var(--tower-stroke-field-initial);', ' --ring: var(--tower-stroke-field-focus);', diff --git a/src/GroundControl.Tower/src/api/endpoints/variables.ts b/src/GroundControl.Tower/src/api/endpoints/variables.ts index e37323c2..314b35f9 100644 --- a/src/GroundControl.Tower/src/api/endpoints/variables.ts +++ b/src/GroundControl.Tower/src/api/endpoints/variables.ts @@ -8,8 +8,10 @@ export function createVariable(body: ApiRequestBody<'CreateVariableHandler'>) { return apiFetch>('/api/variables', { method: 'POST', body }); } -export function getVariable(id: string) { - return apiFetch>(`/api/variables/${encodeURIComponent(id)}`); +export function getVariable(id: string, options: { decrypt?: boolean } = {}) { + return apiFetch>(`/api/variables/${encodeURIComponent(id)}`, { + query: options.decrypt ? { decrypt: true } : undefined, + }); } export function updateVariable(id: string, body: ApiRequestBody<'UpdateVariableHandler'>, version: string) { diff --git a/src/GroundControl.Tower/src/api/types.ts b/src/GroundControl.Tower/src/api/types.ts index bde8fbda..2589882d 100644 --- a/src/GroundControl.Tower/src/api/types.ts +++ b/src/GroundControl.Tower/src/api/types.ts @@ -1805,6 +1805,10 @@ export interface components { name: string; /** @description Gets whether the client is active. */ isActive: boolean; + /** @description Gets the fixed scope assignments for the client. When provided, replaces the existing scope context. */ + scopes?: null | { + [key: string]: string; + }; /** * Format: date-time * @description Gets the optional expiration timestamp. @@ -2318,6 +2322,15 @@ export interface operations { "application/json": components["schemas"]["ClientResponse"]; }; }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; + }; + }; /** @description Not Found */ 404: { headers: { diff --git a/src/GroundControl.Tower/src/components/tower/admin/AddGrantModal.tsx b/src/GroundControl.Tower/src/components/tower/admin/AddGrantModal.tsx index 82455722..42eefb1e 100644 --- a/src/GroundControl.Tower/src/components/tower/admin/AddGrantModal.tsx +++ b/src/GroundControl.Tower/src/components/tower/admin/AddGrantModal.tsx @@ -42,10 +42,10 @@ export function AddGrantModal({ userId }: AddGrantModalProps) { return ( - + - Add grant + Add Grant Grant this user a role within a group.
@@ -66,7 +66,7 @@ export function AddGrantModal({ userId }: AddGrantModalProps) {
- +
diff --git a/src/GroundControl.Tower/src/components/tower/admin/AssignRoleModal.tsx b/src/GroundControl.Tower/src/components/tower/admin/AssignRoleModal.tsx index d0ca16da..0aa86870 100644 --- a/src/GroundControl.Tower/src/components/tower/admin/AssignRoleModal.tsx +++ b/src/GroundControl.Tower/src/components/tower/admin/AssignRoleModal.tsx @@ -65,10 +65,10 @@ export function AssignRoleModal(props: AssignRoleModalProps) { return ( - + - {props.mode === 'edit' ? 'Change role' : 'Assign role'} + {props.mode === 'edit' ? 'Change Role' : 'Assign Role'} {props.mode === 'edit' ? 'Update this member\'s group role.' : 'Select a user and role for this group.'}
diff --git a/src/GroundControl.Tower/src/components/tower/admin/NewUserModal.tsx b/src/GroundControl.Tower/src/components/tower/admin/NewUserModal.tsx index e580544b..7408fcce 100644 --- a/src/GroundControl.Tower/src/components/tower/admin/NewUserModal.tsx +++ b/src/GroundControl.Tower/src/components/tower/admin/NewUserModal.tsx @@ -55,7 +55,7 @@ export function NewUserModal() { - New user + New User Create a user account. Grants can be assigned after the user is created. diff --git a/src/GroundControl.Tower/src/components/tower/clients/EditClientModal.tsx b/src/GroundControl.Tower/src/components/tower/clients/EditClientModal.tsx new file mode 100644 index 00000000..8fbd029b --- /dev/null +++ b/src/GroundControl.Tower/src/components/tower/clients/EditClientModal.tsx @@ -0,0 +1,176 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { useEffect, useId, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'; +import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { SegmentedControl } from '@/components/tower/data/SegmentedControl'; +import { useProjects } from '@/queries/useProjects'; +import { useScopes } from '@/queries/useScopes'; +import { useRevokeClient, useUpdateClient, type Client } from '@/queries/useClients'; + +const UNSET_SCOPE = '__unset__'; + +const editClientSchema = z.object({ + name: z.string().min(1, 'Client name is required').max(100, 'Use 100 characters or fewer'), + scopes: z.record(z.string(), z.string()), +}); + +type EditClientFormValues = z.infer; + +interface EditClientModalProps { + client: Client | null; + onOpenChange: (open: boolean) => void; + open: boolean; + projectId: string; +} + +export function EditClientModal({ client, onOpenChange, open, projectId }: EditClientModalProps) { + const projects = useProjects(); + const scopes = useScopes(); + const updateClient = useUpdateClient(); + const revokeClient = useRevokeClient(projectId); + const [confirmingDelete, setConfirmingDelete] = useState(false); + const [deleteConfirmText, setDeleteConfirmText] = useState(''); + const deleteConfirmInputId = useId(); + const isDeleteConfirmed = !!client && deleteConfirmText === client.name; + const form = useForm({ + defaultValues: { name: client?.name ?? '', scopes: client?.scopes ?? {} }, + resolver: zodResolver(editClientSchema), + }); + const selectedScopes = form.watch('scopes'); + const projectName = projects.data?.data.find((project) => project.id === projectId)?.name ?? projectId; + const scopeDefinitions = scopes.data?.data.filter((scope) => scope.allowedValues.length > 0) ?? []; + + useEffect(() => { + if (open && client) { + form.reset({ name: client.name, scopes: { ...client.scopes } }); + } + }, [client, form, open]); + + useEffect(() => { + if (!confirmingDelete) { + setDeleteConfirmText(''); + } + }, [confirmingDelete]); + + async function submit(values: EditClientFormValues) { + if (!client) { + return; + } + + const scopes = Object.fromEntries( + Object.entries(values.scopes).filter(([, value]) => value && value !== UNSET_SCOPE), + ); + + await updateClient.mutateAsync({ + body: { + expiresAt: client.expiresAt ?? null, + isActive: client.isActive, + name: values.name, + scopes, + }, + id: client.id, + projectId, + version: client.version.toString(), + }); + onOpenChange(false); + } + + async function confirmDelete() { + if (!client) { + return; + } + + await revokeClient.mutateAsync({ id: client.id, version: client.version.toString() }); + setConfirmingDelete(false); + onOpenChange(false); + } + + return ( + + + + Edit Client Credential + Update the name and scope context. The owning project cannot be changed. + +
+
+ + + {form.formState.errors.name ?

{form.formState.errors.name.message}

: null} +
+ +
+ Project +
{projectName}
+
+ +
+ {scopeDefinitions.length === 0 ?
No scope dimensions are configured.
: null} + {scopeDefinitions.map((scope) => { + const options = [ + { label: 'Unset', value: UNSET_SCOPE }, + ...scope.allowedValues.map((value) => ({ label: value, value })), + ]; + const current = selectedScopes[scope.dimension]; + const value = current && current !== UNSET_SCOPE ? current : UNSET_SCOPE; + + return ( +
+
{scope.dimension}
+
+ form.setValue(`scopes.${scope.dimension}`, next)} + options={options} + size="sm" + value={value} + /> +
+
+ ); + })} +
+ + + +
+ + +
+
+
+
+ + + + + Delete {client?.name ?? 'client'}? + This permanently removes the credential. Anyone using it will immediately lose access. This cannot be undone. + + {client ? ( +
+ + setDeleteConfirmText(event.target.value)} + placeholder={client.name} + value={deleteConfirmText} + /> +
+ ) : null} + + Cancel + { event.preventDefault(); void confirmDelete(); }}>{revokeClient.isPending ? 'Deleting…' : 'Delete'} + +
+
+
+ ); +} diff --git a/src/GroundControl.Tower/src/components/tower/clients/NewClientModal.tsx b/src/GroundControl.Tower/src/components/tower/clients/NewClientModal.tsx index 6199bdc8..f27189cc 100644 --- a/src/GroundControl.Tower/src/components/tower/clients/NewClientModal.tsx +++ b/src/GroundControl.Tower/src/components/tower/clients/NewClientModal.tsx @@ -1,32 +1,44 @@ import { zodResolver } from '@hookform/resolvers/zod'; +import { Plus } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; -import { useForm } from 'react-hook-form'; +import { Controller, useForm } from 'react-hook-form'; import { z } from 'zod'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { SegmentedControl } from '@/components/tower/data/SegmentedControl'; import { useCreateClient } from '@/queries/useClients'; +import { useProjects } from '@/queries/useProjects'; import { useScopes } from '@/queries/useScopes'; import { PATRevealModal } from './PATRevealModal'; const newClientSchema = z.object({ name: z.string().min(1, 'Client name is required').max(100, 'Use 100 characters or fewer'), + projectId: z.string().uuid('Select a project'), scopes: z.record(z.string(), z.string()), }); type NewClientFormValues = z.infer; -export function NewClientModal({ projectId }: { projectId: string }) { +interface NewClientModalProps { + projectId?: string; +} + +export function NewClientModal({ projectId }: NewClientModalProps) { + const projects = useProjects(); const scopes = useScopes(); const rawTokenRef = useRef(null); const [open, setOpen] = useState(false); const [revealOpen, setRevealOpen] = useState(false); - const createClient = useCreateClient(projectId, (rawToken) => { + const createClient = useCreateClient((rawToken) => { rawTokenRef.current = rawToken; setRevealOpen(true); }); - const form = useForm({ defaultValues: { name: '', scopes: {} }, resolver: zodResolver(newClientSchema) }); + const form = useForm({ + defaultValues: { name: '', projectId: projectId ?? '', scopes: {} }, + resolver: zodResolver(newClientSchema), + }); const selectedScopes = form.watch('scopes'); const scopeDefinitions = scopes.data?.data.filter((scope) => scope.allowedValues.length > 0) ?? []; @@ -38,9 +50,18 @@ export function NewClientModal({ projectId }: { projectId: string }) { } }, [form, scopeDefinitions]); + useEffect(() => { + if (projectId) { + form.setValue('projectId', projectId); + } + }, [form, projectId]); + async function submit(values: NewClientFormValues) { - await createClient.mutateAsync({ name: values.name, scopes: values.scopes }); - form.reset({ name: '', scopes: {} }); + await createClient.mutateAsync({ + body: { name: values.name, scopes: values.scopes }, + projectId: values.projectId, + }); + form.reset({ name: '', projectId: projectId ?? '', scopes: {} }); setOpen(false); } @@ -49,13 +70,20 @@ export function NewClientModal({ projectId }: { projectId: string }) { setRevealOpen(false); } + const projectOptions = projects.data?.data ?? []; + return ( <> - - + + + + - New client credential + New Client Credential Choose the fixed scope context this credential will use when fetching config.
@@ -64,10 +92,32 @@ export function NewClientModal({ projectId }: { projectId: string }) { {form.formState.errors.name ?

{form.formState.errors.name.message}

: null}
+ + {projectId ? null : ( +
+ + ( + + )} + /> + {form.formState.errors.projectId ?

{form.formState.errors.projectId.message}

: null} +
+ )} +
{scopeDefinitions.length === 0 ?
No scope dimensions are configured.
: null} {scopeDefinitions.map((scope) => ( -
+
{scope.dimension}
form.setValue(`scopes.${scope.dimension}`, value)} options={scope.allowedValues.map((value) => ({ label: value, value }))} size="sm" value={selectedScopes[scope.dimension] ?? scope.allowedValues[0]!} /> @@ -84,4 +134,4 @@ export function NewClientModal({ projectId }: { projectId: string }) { ); -} \ No newline at end of file +} diff --git a/src/GroundControl.Tower/src/components/tower/clients/PATRevealModal.tsx b/src/GroundControl.Tower/src/components/tower/clients/PATRevealModal.tsx index d3399ea4..5c4408c1 100644 --- a/src/GroundControl.Tower/src/components/tower/clients/PATRevealModal.tsx +++ b/src/GroundControl.Tower/src/components/tower/clients/PATRevealModal.tsx @@ -52,7 +52,7 @@ export function PATRevealModal({ onConfirm, open, rawToken }: PATRevealModalProp { if (!nextOpen && confirmed) { onConfirm(); } }}> { if (!confirmed) { event.preventDefault(); } }} onPointerDownOutside={(event) => { if (!confirmed) { event.preventDefault(); } }} showCloseButton={false}> - Client credential created + Client Credential Created This is the only time you will see this token. Copy it now.
diff --git a/src/GroundControl.Tower/src/components/tower/code/JsonDiff.tsx b/src/GroundControl.Tower/src/components/tower/code/JsonDiff.tsx index 708c0999..42b3f57d 100644 --- a/src/GroundControl.Tower/src/components/tower/code/JsonDiff.tsx +++ b/src/GroundControl.Tower/src/components/tower/code/JsonDiff.tsx @@ -20,10 +20,16 @@ interface HighlightedDiffLine extends DiffLine { html: string; } +interface SplitRow { + left: HighlightedDiffLine | null; + right: HighlightedDiffLine | null; +} + export function JsonDiff({ after, before, className, mode = 'inline' }: JsonDiffProps) { const theme = useTweaksStore((state) => state.theme); const lines = useMemo(() => buildDiffLines(before, after), [after, before]); const [highlightedLines, setHighlightedLines] = useState([]); + const splitRows = useMemo(() => buildSplitRows(highlightedLines), [highlightedLines]); useEffect(() => { let cancelled = false; @@ -41,9 +47,9 @@ export function JsonDiff({ after, before, className, mode = 'inline' }: JsonDiff if (mode === 'split') { return ( -
- line.kind !== 'add')} title="Before" /> - line.kind !== 'del')} title="After" /> +
+ row.left)} side="left" title="Before" /> + row.right)} side="right" title="After" />
); } @@ -57,11 +63,28 @@ export function JsonDiff({ after, before, className, mode = 'inline' }: JsonDiff ); } -function DiffColumn({ lines, title }: { lines: HighlightedDiffLine[]; title: string }) { +interface DiffColumnProps { + rows: (HighlightedDiffLine | null)[]; + side: 'left' | 'right'; + title: string; +} + +function DiffColumn({ rows, side, title }: DiffColumnProps) { + let lineIndex = 0; + return ( -
-
{title}
-
{lines.map((line, index) => )}
+
+
{title}
+
+ {rows.map((row, index) => { + if (row === null) { + return ; + } + + const currentIndex = lineIndex++; + return ; + })} +
); } @@ -72,7 +95,16 @@ function DiffRow({ index, line }: { index: number; line: HighlightedDiffLine }) {line.kind === 'add' ? '+' : line.kind === 'del' ? '-' : index + 1} - + +
+ ); +} + +function DiffPlaceholderRow() { + return ( + ); } @@ -81,6 +113,54 @@ function buildDiffLines(before: unknown, after: unknown): DiffLine[] { return diffLines(toJson(before), toJson(after)).flatMap((part) => part.value.replace(/\n$/, '').split('\n').map((content) => ({ content, kind: part.added ? 'add' : part.removed ? 'del' : 'same' }))); } +function buildSplitRows(lines: HighlightedDiffLine[]): SplitRow[] { + const rows: SplitRow[] = []; + let cursor = 0; + + while (cursor < lines.length) { + const line = lines[cursor]; + + if (line.kind === 'same') { + rows.push({ left: line, right: line }); + cursor += 1; + continue; + } + + if (line.kind === 'del') { + const dels: HighlightedDiffLine[] = []; + while (cursor < lines.length && lines[cursor].kind === 'del') { + dels.push(lines[cursor]); + cursor += 1; + } + + const adds: HighlightedDiffLine[] = []; + while (cursor < lines.length && lines[cursor].kind === 'add') { + adds.push(lines[cursor]); + cursor += 1; + } + + const pairCount = Math.max(dels.length, adds.length); + for (let pairIndex = 0; pairIndex < pairCount; pairIndex += 1) { + rows.push({ left: dels[pairIndex] ?? null, right: adds[pairIndex] ?? null }); + } + + continue; + } + + const adds: HighlightedDiffLine[] = []; + while (cursor < lines.length && lines[cursor].kind === 'add') { + adds.push(lines[cursor]); + cursor += 1; + } + + for (const add of adds) { + rows.push({ left: null, right: add }); + } + } + + return rows; +} + function toJson(value: unknown): string { return `${JSON.stringify(value, null, 2)}\n`; } @@ -89,4 +169,4 @@ function extractCode(html: string): string { const match = /(?[\s\S]*)<\/code>/.exec(html); return match?.groups?.code.replace(/\n$/, '') ?? ''; -} \ No newline at end of file +} diff --git a/src/GroundControl.Tower/src/components/tower/config/ConfigFlatView.tsx b/src/GroundControl.Tower/src/components/tower/config/ConfigFlatView.tsx index bfcfa9ff..b8ead224 100644 --- a/src/GroundControl.Tower/src/components/tower/config/ConfigFlatView.tsx +++ b/src/GroundControl.Tower/src/components/tower/config/ConfigFlatView.tsx @@ -2,7 +2,6 @@ import { createColumnHelper, flexRender, getCoreRowModel, getSortedRowModel, use import { Layers3 } from 'lucide-react'; import { useMemo, useState } from 'react'; import { Badge } from '@/components/tower/data/Badge'; -import { InlineCode } from '@/components/tower/data/InlineCode'; import { SensitiveValue } from '@/components/tower/code/SensitiveValue'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -10,35 +9,42 @@ import { Skeleton } from '@/components/ui/skeleton'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { type ConfigEntry } from '@/queries/useConfigEntries'; -import { useEffectiveEntries, type EffectiveEntry } from '@/queries/useEffectiveEntries'; +import { useOwnedEntries, type ConfigOwner, type EffectiveEntry, type EntrySource } from '@/queries/useEffectiveEntries'; import { DeleteEntryDialog } from './DeleteEntryDialog'; import { EntryModal } from './EntryModal'; const columnHelper = createColumnHelper(); interface ConfigFlatViewProps { - projectId: string; + owner: ConfigOwner; } -export function ConfigFlatView({ projectId }: ConfigFlatViewProps) { - const effective = useEffectiveEntries(projectId); +export function ConfigFlatView({ owner }: ConfigFlatViewProps) { + const effective = useOwnedEntries(owner); const [sorting, setSorting] = useState([]); const [search, setSearch] = useState(''); const [editingEntry, setEditingEntry] = useState(); const [deletingEntry, setDeletingEntry] = useState(); const [creating, setCreating] = useState(false); + const ownerType = owner.kind === 'project' ? 1 : 0; const data = useMemo( () => effective.entries.filter((item) => item.entry.key.toLowerCase().includes(search.toLowerCase())), [effective.entries, search], ); - const columns = useMemo(() => [ - columnHelper.accessor((row) => row.entry.key, { cell: (info) => {info.getValue()}, header: 'Key', id: 'key' }), - columnHelper.accessor((row) => row.entry.valueType, { cell: (info) => {info.getValue()}, header: 'Type', id: 'valueType' }), - columnHelper.display({ cell: (info) => , header: 'Default value', id: 'defaultValue' }), - columnHelper.display({ cell: (info) => {scopeCount(info.row.original.entry)} scopes, header: 'Scopes', id: 'scopes' }), - columnHelper.display({ cell: (info) => , header: 'Owner', id: 'owner' }), - columnHelper.accessor((row) => row.entry.updatedAt, { cell: (info) => relativeDate(info.getValue()), header: 'Updated', id: 'updatedAt' }), - columnHelper.display({ + const columns = useMemo(() => { + const baseColumns = [ + columnHelper.accessor((row) => row.entry.key, { cell: (info) => {info.getValue()}, header: 'Key', id: 'key' }), + columnHelper.accessor((row) => row.entry.valueType, { cell: (info) => {info.getValue()}, header: 'Type', id: 'valueType' }), + columnHelper.display({ cell: (info) => , header: 'Default Value', id: 'defaultValue' }), + columnHelper.display({ cell: (info) => {scopeCount(info.row.original.entry)} scopes, header: 'Scopes', id: 'scopes' }), + ]; + + if (owner.kind === 'project') { + baseColumns.push(columnHelper.display({ cell: (info) => , header: 'Owner', id: 'owner' })); + } + + baseColumns.push(columnHelper.accessor((row) => row.entry.updatedAt, { cell: (info) => relativeDate(info.getValue()), header: 'Updated', id: 'updatedAt' })); + baseColumns.push(columnHelper.display({ cell: (info) => { const item = info.row.original; if (item.source.kind === 'template') { @@ -54,8 +60,10 @@ export function ConfigFlatView({ projectId }: ConfigFlatViewProps) { }, header: '', id: 'actions', - }), - ], []); + })); + + return baseColumns; + }, [owner.kind]); const table = useReactTable({ columns, data, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), onSortingChange: setSorting, state: { sorting } }); if (effective.isLoading) { @@ -100,19 +108,23 @@ export function ConfigFlatView({ projectId }: ConfigFlatViewProps) {
- - !open && setEditingEntry(undefined)} open={Boolean(editingEntry)} projectId={projectId} /> - !open && setDeletingEntry(undefined)} open={Boolean(deletingEntry)} projectId={projectId} /> + + !open && setEditingEntry(undefined)} open={Boolean(editingEntry)} ownerId={owner.id} ownerType={ownerType} /> + !open && setDeletingEntry(undefined)} open={Boolean(deletingEntry)} ownerId={owner.id} ownerType={ownerType} />
); } -function OwnerBadge({ source }: { source: EffectiveEntry['source'] }) { +function OwnerBadge({ source }: { source: EntrySource }) { if (source.kind === 'project') { return Project; } + if (source.kind === 'template-self') { + return Template; + } + if (source.kind === 'template') { return ( diff --git a/src/GroundControl.Tower/src/components/tower/config/ConfigJsonView.tsx b/src/GroundControl.Tower/src/components/tower/config/ConfigJsonView.tsx index e54c1ca7..13f11489 100644 --- a/src/GroundControl.Tower/src/components/tower/config/ConfigJsonView.tsx +++ b/src/GroundControl.Tower/src/components/tower/config/ConfigJsonView.tsx @@ -6,20 +6,31 @@ import { JsonPreview } from '@/components/tower/code/JsonPreview'; import { SegmentedControl } from '@/components/tower/data/SegmentedControl'; import { useSensitive } from '@/lib/sensitive'; import { entriesToResolvedDocument } from '@/lib/snapshot-document'; +import { useConfigEntries } from '@/queries/useConfigEntries'; +import type { ConfigOwner } from '@/queries/useEffectiveEntries'; import { useScopes } from '@/queries/useScopes'; import { useSnapshotPreview } from '@/queries/useSnapshots'; interface ConfigJsonViewProps { - projectId: string; + owner: ConfigOwner; } -export function ConfigJsonView({ projectId }: ConfigJsonViewProps) { +export function ConfigJsonView({ owner }: ConfigJsonViewProps) { const scopes = useScopes(); const scopeDefinitions = useMemo(() => scopes.data?.data.filter((scope) => scope.allowedValues.length > 0) ?? [], [scopes.data?.data]); const [selectedScopes, setSelectedScopes] = useState>({}); - const preview = useSnapshotPreview(projectId); + const projectPreview = useSnapshotPreview(owner.kind === 'project' ? owner.id : '', { enabled: owner.kind === 'project' }); + const templateEntries = useConfigEntries(owner.kind === 'template' ? owner.id : '', 0); const { masked } = useSensitive(); - const resolvedDocument = useMemo(() => entriesToResolvedDocument(preview.data?.entries, selectedScopes, { maskSensitive: masked }), [masked, preview.data?.entries, selectedScopes]); + + const sourceEntries = owner.kind === 'project' ? projectPreview.data?.entries : templateEntries.data?.data; + const sourceLoading = owner.kind === 'project' ? projectPreview.isLoading : templateEntries.isLoading; + const entryCount = sourceEntries?.length ?? 0; + + const resolvedDocument = useMemo( + () => entriesToResolvedDocument(sourceEntries, selectedScopes, { maskSensitive: masked }), + [masked, selectedScopes, sourceEntries], + ); useEffect(() => { setSelectedScopes((current) => { @@ -40,7 +51,7 @@ export function ConfigJsonView({ projectId }: ConfigJsonViewProps) { const link = document.createElement('a'); link.href = url; - link.download = `resolved-config-${projectId}.json`; + link.download = `resolved-config-${owner.kind}-${owner.id}.json`; link.click(); window.setTimeout(() => URL.revokeObjectURL(url), 100); } @@ -62,14 +73,14 @@ export function ConfigJsonView({ projectId }: ConfigJsonViewProps) { ))}
-
- {preview.isLoading ? ( + {sourceLoading ? ( ) : (
@@ -79,7 +90,7 @@ export function ConfigJsonView({ projectId }: ConfigJsonViewProps) { )}
- {(preview.data?.entries ?? []).length} entries resolved · sensitive values {masked ? 'masked' : 'shown'} · change scope dimensions above to preview what different clients see. + {entryCount} entries resolved · sensitive values {masked ? 'masked' : 'shown'} · change scope dimensions above to preview what different clients see.
); @@ -91,4 +102,3 @@ function shallowEqual(left: Record, right: Record right[key] === value); } - diff --git a/src/GroundControl.Tower/src/components/tower/config/ConfigTreeView.tsx b/src/GroundControl.Tower/src/components/tower/config/ConfigTreeView.tsx index e62e4ce2..a2132731 100644 --- a/src/GroundControl.Tower/src/components/tower/config/ConfigTreeView.tsx +++ b/src/GroundControl.Tower/src/components/tower/config/ConfigTreeView.tsx @@ -8,24 +8,28 @@ import { Badge } from '@/components/tower/data/Badge'; import { InlineCode } from '@/components/tower/data/InlineCode'; import { SensitiveValue } from '@/components/tower/code/SensitiveValue'; import { type ConfigEntry } from '@/queries/useConfigEntries'; -import { useEffectiveEntries, type EffectiveEntry, type EntrySource } from '@/queries/useEffectiveEntries'; +import { useOwnedEntries, type ConfigOwner, type EffectiveEntry, type EntrySource } from '@/queries/useEffectiveEntries'; import { useProjects } from '@/queries/useProjects'; +import { useTemplates } from '@/queries/useTemplates'; import { buildKeyTree, type TreeNode } from '@/lib/key-tree'; import { cn } from '@/lib/utils'; import { DeleteEntryDialog } from './DeleteEntryDialog'; import { EntryModal } from './EntryModal'; import { EntryValue } from './EntryValue'; -import { ScopedEntryValue } from './ScopedEntryValue'; import { scopedValueKey, useEntryReveal } from './use-entry-reveal'; interface ConfigTreeViewProps { - projectId: string; + owner: ConfigOwner; } -export function ConfigTreeView({ projectId }: ConfigTreeViewProps) { - const effective = useEffectiveEntries(projectId); +export function ConfigTreeView({ owner }: ConfigTreeViewProps) { + const effective = useOwnedEntries(owner); const projects = useProjects(); - const projectName = projects.data?.data.find((candidate) => candidate.id === projectId)?.name ?? ''; + const templates = useTemplates(); + const ownerType = owner.kind === 'project' ? 1 : 0; + const ownerName = owner.kind === 'project' + ? projects.data?.data.find((candidate) => candidate.id === owner.id)?.name ?? '' + : templates.data?.data.find((candidate) => candidate.id === owner.id)?.name ?? ''; const [collapsed, setCollapsed] = useState>(() => new Set()); const [selectedEntryId, setSelectedEntryId] = useState(null); const [editingEntry, setEditingEntry] = useState(); @@ -55,7 +59,7 @@ export function ConfigTreeView({ projectId }: ConfigTreeViewProps) { useEffect(() => { setSelectedEntryId(null); - }, [projectId]); + }, [owner.id]); useEffect(() => { if (selectedEntryId !== null || tree.length === 0) { @@ -71,8 +75,8 @@ export function ConfigTreeView({ projectId }: ConfigTreeViewProps) { return ( -
-
+
+
setFilter(event.target.value)} placeholder="Filter…" value={filter} />
@@ -87,9 +91,9 @@ export function ConfigTreeView({ projectId }: ConfigTreeViewProps) {
-
- {effective.isLoading ? : ( -
+
+ {effective.isLoading ? : ( +
{tree.length === 0 ?
No entries found.
: tree.map((node) => ( - +
+ +
- - !open && setEditingEntry(undefined)} open={Boolean(editingEntry)} projectId={projectId} /> - !open && setDeletingEntry(undefined)} open={Boolean(deletingEntry)} projectId={projectId} /> + + !open && setEditingEntry(undefined)} open={Boolean(editingEntry)} ownerId={owner.id} ownerType={ownerType} /> + !open && setDeletingEntry(undefined)} open={Boolean(deletingEntry)} ownerId={owner.id} ownerType={ownerType} />
); @@ -210,24 +216,24 @@ function TreeRow({ collapsed, depth = 0, node, onDelete, onEdit, onSelect, selec interface EntryDetailPanelProps { item: EffectiveEntry | null; onEdit: (entry: ConfigEntry) => void; - projectName: string; + ownerName: string; } -function EntryDetailPanel({ item, onEdit, projectName }: EntryDetailPanelProps) { +function EntryDetailPanel({ item, onEdit, ownerName }: EntryDetailPanelProps) { if (!item) { return null; } - return ; + return ; } interface EntryDetailPanelBodyProps { item: EffectiveEntry; onEdit: (entry: ConfigEntry) => void; - projectName: string; + ownerName: string; } -function EntryDetailPanelBody({ item, onEdit, projectName }: EntryDetailPanelBodyProps) { +function EntryDetailPanelBody({ item, onEdit, ownerName }: EntryDetailPanelBodyProps) { const { entry, source } = item; const reveal = useEntryReveal(entry); const isInherited = source.kind === 'template'; @@ -250,7 +256,7 @@ function EntryDetailPanelBody({ item, onEdit, projectName }: EntryDetailPanelBod
{entry.valueType} - +
{entry.description ?

{entry.description}

: null} @@ -259,7 +265,9 @@ function EntryDetailPanelBody({ item, onEdit, projectName }: EntryDetailPanelBod
Default value
- +
+ +
@@ -268,7 +276,7 @@ function EntryDetailPanelBody({ item, onEdit, projectName }: EntryDetailPanelBod {scopedVals.length === 0 ? (
No scoped values defined.
) : scopedVals.map((value, index) => ( - + ))}
@@ -277,9 +285,13 @@ function EntryDetailPanelBody({ item, onEdit, projectName }: EntryDetailPanelBod ); } -function OwnerPill({ projectName, source }: { projectName: string; source: EntrySource }) { +function OwnerPill({ ownerName, source }: { ownerName: string; source: EntrySource }) { if (source.kind === 'project') { - return project{projectName ? ` · ${projectName}` : ''}; + return project{ownerName ? ` · ${ownerName}` : ''}; + } + + if (source.kind === 'template-self') { + return template{ownerName ? ` · ${ownerName}` : ''}; } if (source.kind === 'template') { diff --git a/src/GroundControl.Tower/src/components/tower/config/DeleteEntryDialog.tsx b/src/GroundControl.Tower/src/components/tower/config/DeleteEntryDialog.tsx index 3290048f..bf5ba167 100644 --- a/src/GroundControl.Tower/src/components/tower/config/DeleteEntryDialog.tsx +++ b/src/GroundControl.Tower/src/components/tower/config/DeleteEntryDialog.tsx @@ -28,7 +28,7 @@ export function DeleteEntryDialog({ entry, onOpenChange, open, ownerId, ownerTyp - Delete entry + Delete Entry Delete {entry?.key ?? 'entry'} from this project. This action cannot be undone. diff --git a/src/GroundControl.Tower/src/components/tower/config/EntryModal.test.tsx b/src/GroundControl.Tower/src/components/tower/config/EntryModal.test.tsx index 986315cc..f78abefa 100644 --- a/src/GroundControl.Tower/src/components/tower/config/EntryModal.test.tsx +++ b/src/GroundControl.Tower/src/components/tower/config/EntryModal.test.tsx @@ -1,26 +1,94 @@ -import { render, screen } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import type { ReactNode } from 'react'; import { describe, expect, it, vi } from 'vitest'; import { EntryModal } from './EntryModal'; +import type { ConfigEntry } from '@/queries/useConfigEntries'; vi.mock('@/queries/useScopes', () => ({ useScopes: () => ({ data: { data: [] } }), })); -vi.mock('@/queries/useConfigEntries', () => ({ - useCreateEntry: () => ({ isPending: false, mutateAsync: vi.fn() }), - useUpdateEntry: () => ({ isPending: false, mutateAsync: vi.fn() }), +vi.mock('@/queries/useConfigEntries', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useCreateEntry: () => ({ isPending: false, mutateAsync: vi.fn() }), + useUpdateEntry: () => ({ isPending: false, mutateAsync: vi.fn() }), + }; +}); + +const getConfigEntryMock = vi.fn(); +vi.mock('@/api/endpoints/config-entries', () => ({ + getConfigEntry: (...args: unknown[]) => getConfigEntryMock(...args), })); +function renderWithClient(ui: ReactNode) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + return render({ui}); +} + +const SENSITIVE_MASK = '***'; + +function buildSensitiveEntry(): ConfigEntry { + return { + createdAt: '2026-01-01T00:00:00Z', + createdBy: '00000000-0000-0000-0000-000000000000', + description: null, + id: '11111111-1111-1111-1111-111111111111', + isSensitive: true, + key: 'Secret:ApiKey', + ownerId: '22222222-2222-2222-2222-222222222222', + ownerType: 1, + updatedAt: '2026-01-01T00:00:00Z', + updatedBy: '00000000-0000-0000-0000-000000000000', + valueType: 'String', + values: [{ scopes: {}, value: SENSITIVE_MASK }], + version: '1', + } as ConfigEntry; +} + describe('EntryModal', () => { it('rejects invalid key characters', async () => { const user = userEvent.setup(); - render(); + renderWithClient(); await user.type(screen.getByLabelText('Key'), 'bad key!'); await user.click(screen.getByRole('button', { name: 'Create entry' })); expect(await screen.findByText('Use letters, numbers, colons, dots, underscores, and hyphens only')).toBeInTheDocument(); }); -}); \ No newline at end of file + + it('locks the form for masked sensitive entries until reveal succeeds', async () => { + // Arrange + const user = userEvent.setup(); + getConfigEntryMock.mockResolvedValue({ + id: '11111111-1111-1111-1111-111111111111', + values: [{ scopes: {}, value: 'real-secret' }], + }); + + // Act + renderWithClient( + , + ); + + // Assert — masked state + expect(screen.getByText('Reveal to Edit Values')).toBeInTheDocument(); + const defaultValueInput = screen.getByLabelText('Default value') as HTMLInputElement; + expect(defaultValueInput).toBeDisabled(); + expect(defaultValueInput.value).toBe(SENSITIVE_MASK); + expect(screen.getByRole('button', { name: 'Save entry' })).toBeDisabled(); + + // Act — reveal + await user.click(screen.getByRole('button', { name: 'Reveal Sensitive Values' })); + + // Assert — unlocked + await waitFor(() => expect(getConfigEntryMock).toHaveBeenCalledWith('11111111-1111-1111-1111-111111111111', { decrypt: true })); + await waitFor(() => expect(screen.queryByText('Reveal to Edit Values')).not.toBeInTheDocument()); + expect(defaultValueInput).not.toBeDisabled(); + expect(defaultValueInput.value).toBe('real-secret'); + expect(screen.getByRole('button', { name: 'Save entry' })).not.toBeDisabled(); + }); +}); diff --git a/src/GroundControl.Tower/src/components/tower/config/EntryModal.tsx b/src/GroundControl.Tower/src/components/tower/config/EntryModal.tsx index 316ff382..1892e9b2 100644 --- a/src/GroundControl.Tower/src/components/tower/config/EntryModal.tsx +++ b/src/GroundControl.Tower/src/components/tower/config/EntryModal.tsx @@ -1,15 +1,19 @@ import { zodResolver } from '@hookform/resolvers/zod'; -import { useMemo } from 'react'; -import { Controller, useFieldArray, useForm } from 'react-hook-form'; +import { useMutation } from '@tanstack/react-query'; +import { useEffect, useMemo } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { toast } from 'sonner'; import { z } from 'zod'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Textarea } from '@/components/ui/textarea'; +import { ScopedValuesField } from '@/components/tower/data/ScopedValuesField'; +import { getConfigEntry } from '@/api/endpoints/config-entries'; import { useCreateEntry, useUpdateEntry, type ConfigEntry, type ConfigEntryOwnerType } from '@/queries/useConfigEntries'; -import { useScopes } from '@/queries/useScopes'; +const SENSITIVE_MASK = '***'; const valueTypes = ['String', 'Int32', 'Int64', 'Double', 'Decimal', 'Boolean', 'DateTime', 'DateTimeOffset', 'DateOnly', 'TimeOnly'] as const; const integerTypes: ReadonlySet = new Set(['Int32', 'Int64']); const decimalTypes: ReadonlySet = new Set(['Double', 'Decimal']); @@ -37,21 +41,68 @@ interface EntryModalProps { export function EntryModal({ entry, mode, onOpenChange, open, ownerId, ownerType = 1, projectId }: EntryModalProps) { const resolvedOwnerId = ownerId ?? projectId ?? ''; - const scopes = useScopes(); const createEntry = useCreateEntry(resolvedOwnerId, ownerType); const updateEntry = useUpdateEntry(resolvedOwnerId, ownerType); const formValues = useMemo(() => toFormValues(entry), [entry]); const form = useForm({ defaultValues: formValues, resolver: zodResolver(entrySchema), - values: formValues, }); - const scopedValues = useFieldArray({ control: form.control, name: 'scopedValues' }); const isSensitive = form.watch('isSensitive'); const selectedType = form.watch('type'); + const defaultValue = form.watch('defaultValue'); + const scopedValues = form.watch('scopedValues'); + const isEdit = mode === 'edit'; const pending = createEntry.isPending || updateEntry.isPending; + // Recomputed each render — `scopedValues` from `watch` is a fresh array reference, so memoizing buys nothing. + const valuesAreMasked = isEdit + && isSensitive + && (defaultValue === SENSITIVE_MASK || scopedValues.some((row) => row.value === SENSITIVE_MASK)); + + const decryptValues = useMutation({ + mutationFn: () => { + if (!entry) { + throw new Error('NO_ENTRY'); + } + + return getConfigEntry(entry.id, { decrypt: true }); + }, + onError: () => toast.error("Couldn't reveal sensitive values."), + onSuccess: (data) => { + if (!data) { + return; + } + + if (data.values.some((value) => value.value === SENSITIVE_MASK)) { + toast.error("You don't have permission to reveal sensitive values."); + + return; + } + + const decrypted = toFormValues({ ...entry!, values: data.values }); + form.reset({ + ...form.getValues(), + defaultValue: decrypted.defaultValue, + scopedValues: decrypted.scopedValues, + }); + }, + }); + + useEffect(() => { + if (!open) { + return; + } + + form.reset(formValues); + decryptValues.reset(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, formValues]); async function submit(values: EntryFormValues) { + if (valuesAreMasked) { + return; + } + const body = toRequest(values); if (mode === 'create') { @@ -68,7 +119,7 @@ export function EntryModal({ entry, mode, onOpenChange, open, ownerId, ownerType - {mode === 'create' ? 'New entry' : 'Edit entry'} + {mode === 'create' ? 'New Configuration' : 'Edit Configuration'} Define the default value and any scope-specific overrides. @@ -82,50 +133,69 @@ export function EntryModal({ entry, mode, onOpenChange, open, ownerId, ownerType
- } /> + } />
-
+ {valuesAreMasked ? ( +
+
Reveal to Edit Values
+

+ Values are masked as ***. The API rejects saving the mask as a literal value, so reveal first. +

+ +
+ ) : null} +
- +
-