From 20006b08007334b30e65e17220fa9412fa06ba86 Mon Sep 17 00:00:00 2001 From: Mohammadreza Taikandi Date: Sat, 9 May 2026 12:26:15 +0100 Subject: [PATCH 01/27] feat(tower): refine button shape language and Projects page actions - Switch default button radius from rounded-full (pill) to rounded-lg (6px) for the Button component and four custom-styled call sites - Update radius token descriptions: lg is now the canonical button radius; pill is reserved for chips, badges, dots, avatars, and segmented controls - Convert the Projects page Filter button to an icon-only circle with a corner count badge - Convert the single project Edit button to an icon-only circle - Add a Plus icon to the New project button --- src/GroundControl.Tower/design-tokens/tokens.json | 6 +++--- .../src/components/tower/projects/NewProjectModal.tsx | 6 +++++- .../src/components/tower/projects/OtherProjectsSection.tsx | 2 +- .../src/components/tower/projects/ProjectGroupSection.tsx | 2 +- .../components/tower/projects/ProjectsFilterPopover.tsx | 7 +++---- src/GroundControl.Tower/src/components/ui/button.tsx | 6 +++--- .../src/routes/projects/$projectId/route.tsx | 3 +-- src/GroundControl.Tower/src/routes/projects/index.tsx | 2 +- 8 files changed, 18 insertions(+), 16 deletions(-) diff --git a/src/GroundControl.Tower/design-tokens/tokens.json b/src/GroundControl.Tower/design-tokens/tokens.json index 272fe62e..c1ea1e1a 100644 --- a/src/GroundControl.Tower/design-tokens/tokens.json +++ b/src/GroundControl.Tower/design-tokens/tokens.json @@ -163,11 +163,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/src/components/tower/projects/NewProjectModal.tsx b/src/GroundControl.Tower/src/components/tower/projects/NewProjectModal.tsx index 6aea0697..abb9acee 100644 --- a/src/GroundControl.Tower/src/components/tower/projects/NewProjectModal.tsx +++ b/src/GroundControl.Tower/src/components/tower/projects/NewProjectModal.tsx @@ -1,4 +1,5 @@ import { zodResolver } from '@hookform/resolvers/zod'; +import { Plus } from 'lucide-react'; import { Controller, useForm } from 'react-hook-form'; import { z } from 'zod'; import { Button } from '@/components/ui/button'; @@ -48,7 +49,10 @@ export function NewProjectModal() { return ( - + diff --git a/src/GroundControl.Tower/src/components/tower/projects/OtherProjectsSection.tsx b/src/GroundControl.Tower/src/components/tower/projects/OtherProjectsSection.tsx index 354b598a..8fe0004d 100644 --- a/src/GroundControl.Tower/src/components/tower/projects/OtherProjectsSection.tsx +++ b/src/GroundControl.Tower/src/components/tower/projects/OtherProjectsSection.tsx @@ -72,7 +72,7 @@ export function OtherProjectsSection({ initialNextCursor, initialProjects, searc {state.cursor && remaining > 0 ? (
diff --git a/src/GroundControl.Tower/src/components/ui/button.tsx b/src/GroundControl.Tower/src/components/ui/button.tsx index 9cafcb5e..a37452eb 100644 --- a/src/GroundControl.Tower/src/components/ui/button.tsx +++ b/src/GroundControl.Tower/src/components/ui/button.tsx @@ -4,10 +4,10 @@ import * as React from 'react'; import { cn } from '@/lib/utils'; const buttonSizeClasses = { - default: 'h-9 rounded-full px-4', + default: 'h-9 rounded-lg px-4', icon: 'size-9 rounded-lg p-0', - sm: 'h-8 rounded-full px-3', - lg: 'h-10 rounded-full px-5', + sm: 'h-8 rounded-lg px-3', + lg: 'h-10 rounded-lg px-5', } as const; const buttonVariants = cva( diff --git a/src/GroundControl.Tower/src/routes/projects/$projectId/route.tsx b/src/GroundControl.Tower/src/routes/projects/$projectId/route.tsx index 667707fd..12a6caa3 100644 --- a/src/GroundControl.Tower/src/routes/projects/$projectId/route.tsx +++ b/src/GroundControl.Tower/src/routes/projects/$projectId/route.tsx @@ -58,9 +58,8 @@ function ProjectLayout() { - + ); +} diff --git a/src/GroundControl.Tower/src/components/tower/data/SearchFilterPopover.tsx b/src/GroundControl.Tower/src/components/tower/data/SearchFilterPopover.tsx new file mode 100644 index 00000000..8487da08 --- /dev/null +++ b/src/GroundControl.Tower/src/components/tower/data/SearchFilterPopover.tsx @@ -0,0 +1,92 @@ +import { Search } from 'lucide-react'; +import { useEffect, useId, useRef, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { FilterButton } from '@/components/tower/data/FilterButton'; +import { cn } from '@/lib/utils'; + +interface SearchFilterPopoverProps { + appliedSearch: string | undefined; + ariaLabel: string; + onApply: (search: string | undefined) => void; + placeholder: string; +} + +export function SearchFilterPopover({ appliedSearch, ariaLabel, onApply, placeholder }: SearchFilterPopoverProps) { + const [open, setOpen] = useState(false); + const [draft, setDraft] = useState(appliedSearch ?? ''); + const labelId = useId(); + const inputRef = useRef(null); + const activeCount = appliedSearch ? 1 : 0; + + useEffect(() => { + if (open) { + setDraft(appliedSearch ?? ''); + } + }, [open, appliedSearch]); + + function applyAndClose() { + const trimmed = draft.trim(); + onApply(trimmed.length > 0 ? trimmed : undefined); + setOpen(false); + } + + function clearAll() { + setDraft(''); + onApply(undefined); + setOpen(false); + } + + return ( + + + + + + { + event.preventDefault(); + inputRef.current?.focus(); + inputRef.current?.select(); + }}> +
+
+ +
+
+
+ +
+ + +
+
+
+
+ ); +} diff --git a/src/GroundControl.Tower/src/components/tower/projects/ProjectsFilterPopover.tsx b/src/GroundControl.Tower/src/components/tower/projects/ProjectsFilterPopover.tsx index c3ced261..9f9fb527 100644 --- a/src/GroundControl.Tower/src/components/tower/projects/ProjectsFilterPopover.tsx +++ b/src/GroundControl.Tower/src/components/tower/projects/ProjectsFilterPopover.tsx @@ -1,9 +1,4 @@ -import { Filter, Search } from 'lucide-react'; -import { useEffect, useId, useRef, useState } from 'react'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; -import { cn } from '@/lib/utils'; +import { SearchFilterPopover } from '@/components/tower/data/SearchFilterPopover'; interface ProjectsFilterPopoverProps { appliedSearch: string | undefined; @@ -11,94 +6,12 @@ interface ProjectsFilterPopoverProps { } export function ProjectsFilterPopover({ appliedSearch, onApply }: ProjectsFilterPopoverProps) { - const [open, setOpen] = useState(false); - const [draft, setDraft] = useState(appliedSearch ?? ''); - const labelId = useId(); - const inputRef = useRef(null); - const activeCount = appliedSearch ? 1 : 0; - - useEffect(() => { - if (open) { - setDraft(appliedSearch ?? ''); - } - }, [open, appliedSearch]); - - function applyAndClose() { - const trimmed = draft.trim(); - onApply(trimmed.length > 0 ? trimmed : undefined); - setOpen(false); - } - - function clearAll() { - setDraft(''); - onApply(undefined); - setOpen(false); - } - return ( - - - - - - { - event.preventDefault(); - inputRef.current?.focus(); - inputRef.current?.select(); - }}> -
-
- -
-
-
- -
- - -
-
-
-
+ ); -} \ No newline at end of file +} From e847fcc060a18a5e6c6f4dcc3e709591026e3abe Mon Sep 17 00:00:00 2001 From: Mohammadreza Taikandi Date: Sat, 9 May 2026 13:54:57 +0100 Subject: [PATCH 07/27] feat(api): allow updating client scope context - Add optional Scopes property to UpdateClientRequest; when provided, it replaces the existing scope context - New UpdateClientValidator validates dimensions and values against the configured scopes (mirrors CreateClient validation) - UpdateClientHandler applies scope changes by mutating the existing dictionary in place and emits one audit FieldChange per dimension that was added, removed, or had its value changed - Add tests for the happy path and the unknown-dimension rejection - Regenerate OpenApi.json and Tower TypeScript types --- .../Features/Clients/ClientsModule.cs | 1 + .../Clients/Contracts/UpdateClientRequest.cs | 7 +- .../Features/Clients/UpdateClientHandler.cs | 31 ++++++++ .../Features/Clients/UpdateClientValidator.cs | 39 ++++++++++ src/GroundControl.Api/OpenApi.json | 20 +++++ src/GroundControl.Tower/src/api/types.ts | 13 ++++ .../Clients/ClientsHandlerTests.cs | 75 +++++++++++++++++++ 7 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 src/GroundControl.Api/Features/Clients/UpdateClientValidator.cs 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..41643547 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; + client.Scopes.Clear(); + + if (request.Scopes is not null) + { + 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/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/tests/GroundControl.Api.Tests/Clients/ClientsHandlerTests.cs b/tests/GroundControl.Api.Tests/Clients/ClientsHandlerTests.cs index 3397cc05..3b20a823 100644 --- a/tests/GroundControl.Api.Tests/Clients/ClientsHandlerTests.cs +++ b/tests/GroundControl.Api.Tests/Clients/ClientsHandlerTests.cs @@ -384,6 +384,81 @@ public async Task PutClient_WithStaleIfMatch_ReturnsConflict() problem.Detail.ShouldContain("Version conflict"); } + [Fact] + public async Task PutClient_WithUpdatedScopes_PersistsNewScopeContext() + { + // Arrange + await using var factory = CreateFactory(); + using var apiClient = factory.CreateClient(); + var project = await CreateProjectAsync(apiClient, "Test Project", TestCancellationToken); + await CreateScopeAsync(apiClient, "environment", ["dev", "staging", "prod"], TestCancellationToken); + + var createRequest = new CreateClientRequest + { + Name = "client", + Scopes = new Dictionary { ["environment"] = "dev" }, + }; + var createResponse = await apiClient.PostAsJsonAsync( + $"/api/projects/{project.Id}/clients", createRequest, WebJsonSerializerOptions, TestCancellationToken); + var created = await ReadCreateClientAsync(createResponse, TestCancellationToken); + var getResponse = await apiClient.GetAsync( + $"/api/projects/{project.Id}/clients/{created.Id}", TestCancellationToken); + var etag = getResponse.Headers.ETag?.ToString(); + + using var request = new HttpRequestMessage(HttpMethod.Put, $"/api/projects/{project.Id}/clients/{created.Id}"); + request.Content = JsonContent.Create( + new UpdateClientRequest + { + Name = "client", + IsActive = true, + Scopes = new Dictionary { ["environment"] = "prod" }, + }, + options: WebJsonSerializerOptions); + request.Headers.TryAddWithoutValidation("If-Match", etag); + + // Act + var response = await apiClient.SendAsync(request, TestCancellationToken); + var client = await ReadClientAsync(response, TestCancellationToken); + + // Assert + response.StatusCode.ShouldBe(HttpStatusCode.OK); + client.Scopes.ShouldContainKeyAndValue("environment", "prod"); + } + + [Fact] + public async Task PutClient_WithInvalidScopeDimension_ReturnsBadRequest() + { + // Arrange + await using var factory = CreateFactory(); + using var apiClient = factory.CreateClient(); + var project = await CreateProjectAsync(apiClient, "Test Project", TestCancellationToken); + var created = await CreateClientAsync(apiClient, project.Id, "client", TestCancellationToken); + var getResponse = await apiClient.GetAsync( + $"/api/projects/{project.Id}/clients/{created.Id}", TestCancellationToken); + var etag = getResponse.Headers.ETag?.ToString(); + + using var request = new HttpRequestMessage(HttpMethod.Put, $"/api/projects/{project.Id}/clients/{created.Id}"); + request.Content = JsonContent.Create( + new UpdateClientRequest + { + Name = "client", + IsActive = true, + Scopes = new Dictionary { ["nonexistent"] = "value" }, + }, + options: WebJsonSerializerOptions); + request.Headers.TryAddWithoutValidation("If-Match", etag); + + // Act + var response = await apiClient.SendAsync(request, TestCancellationToken); + var problem = await response.ReadValidationProblemAsync(TestCancellationToken); + + // Assert + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + problem.ShouldNotBeNull(); + problem.Errors.ShouldContainKey("Scopes"); + problem.Errors["Scopes"].ShouldContain(e => e.Contains("was not found")); + } + [Fact] public async Task DeleteClient_WithCorrectIfMatch_ReturnsNoContent() { From 4f865bda93b703eb9ceab7126bfb3c8eb96c7dd8 Mon Sep 17 00:00:00 2001 From: Mohammadreza Taikandi Date: Sat, 9 May 2026 13:55:09 +0100 Subject: [PATCH 08/27] feat(tower): redesign Clients page with global new/edit dialogs - Move projectId from useCreateClient hook param into the mutation body so the same hook serves both per-project and global Create flows - Add useUpdateClient mutation hook (with conflict handling) - NewClientModal accepts an optional projectId; on the global Clients page where it is omitted, the dialog renders a project picker and validates the selection - New EditClientModal: edits the client name and scope context; the owning project is shown read-only since clients cannot move between projects - Clients route header now shows the SearchFilterPopover next to a New client button, matching the Projects page layout - Replace the Manage column action with Edit and Open-project icon buttons; clicking the client name also opens the edit dialog - Open-project links to the project's Clients tab and uses a native title tooltip to describe the destination --- .../tower/clients/EditClientModal.tsx | 168 ++++++++++++++++++ .../tower/clients/NewClientModal.tsx | 70 ++++++-- .../src/queries/useClients.ts | 24 ++- .../src/routes/clients.tsx | 81 ++++++--- 4 files changed, 305 insertions(+), 38 deletions(-) create mode 100644 src/GroundControl.Tower/src/components/tower/clients/EditClientModal.tsx 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..17c9a34b --- /dev/null +++ b/src/GroundControl.Tower/src/components/tower/clients/EditClientModal.tsx @@ -0,0 +1,168 @@ +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 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]); + + useEffect(() => { + if (!open || !client) { + return; + } + + for (const scope of scopeDefinitions) { + if (!form.getValues(`scopes.${scope.dimension}`)) { + form.setValue(`scopes.${scope.dimension}`, scope.allowedValues[0]!); + } + } + }, [client, form, open, scopeDefinitions]); + + async function submit(values: EditClientFormValues) { + if (!client) { + return; + } + + await updateClient.mutateAsync({ + body: { + expiresAt: client.expiresAt ?? null, + isActive: client.isActive, + name: values.name, + scopes: values.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) => ( +
+
{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]!} /> +
+
+ ))} +
+ + + +
+ + +
+
+
+
+ + + + + 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..7cf33006 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,11 +70,18 @@ export function NewClientModal({ projectId }: { projectId: string }) { setRevealOpen(false); } + const projectOptions = projects.data?.data ?? []; + return ( <> - - + + + + 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/queries/useClients.ts b/src/GroundControl.Tower/src/queries/useClients.ts index 23545ff4..c239dcdc 100644 --- a/src/GroundControl.Tower/src/queries/useClients.ts +++ b/src/GroundControl.Tower/src/queries/useClients.ts @@ -1,11 +1,12 @@ import { useMutation, useQuery } from '@tanstack/react-query'; -import { createClient, deleteClient, getClients } from '@/api/endpoints/clients'; +import { createClient, deleteClient, getClients, updateClient } from '@/api/endpoints/clients'; import type { ApiRequestBody, ApiResponse } from '@/api/client'; import { useConflictMutation } from '@/lib/mutations'; import { queryClient } from '@/lib/query-client'; export type Client = NonNullable>['data'][number]; export type CreateClientRequest = ApiRequestBody<'CreateClientHandler'>; +export type UpdateClientRequest = ApiRequestBody<'UpdateClientHandler'>; export function clientsQueryKey(projectId: string) { return ['projects', projectId, 'clients'] as const; @@ -19,20 +20,33 @@ export function useClients(projectId: string) { }); } -export function useCreateClient(projectId: string, onCreated: (rawToken: string) => void) { +interface CreateClientVariables { + body: CreateClientRequest; + projectId: string; +} + +export function useCreateClient(onCreated: (rawToken: string) => void) { return useMutation({ - mutationFn: async (body: CreateClientRequest) => { + mutationFn: async ({ body, projectId }: CreateClientVariables) => { const response = await createClient(projectId, body); onCreated(response.clientSecret); }, - onSuccess: () => queryClient.invalidateQueries({ queryKey: clientsQueryKey(projectId) }), + onSuccess: (_, variables) => queryClient.invalidateQueries({ queryKey: clientsQueryKey(variables.projectId) }), }); } +export function useUpdateClient() { + return useConflictMutation( + ({ body, id, projectId, version }: { body: UpdateClientRequest; id: string; projectId: string; version: string }) => + updateClient(projectId, id, body, version), + { onSuccess: (_, variables) => queryClient.invalidateQueries({ queryKey: clientsQueryKey(variables.projectId) }) }, + ); +} + export function useRevokeClient(projectId: string) { return useConflictMutation( ({ id, version }: { id: string; version: string }) => deleteClient(projectId, id, version), { onSuccess: () => queryClient.invalidateQueries({ queryKey: clientsQueryKey(projectId) }) }, ); -} \ No newline at end of file +} diff --git a/src/GroundControl.Tower/src/routes/clients.tsx b/src/GroundControl.Tower/src/routes/clients.tsx index 8c0af982..beff4507 100644 --- a/src/GroundControl.Tower/src/routes/clients.tsx +++ b/src/GroundControl.Tower/src/routes/clients.tsx @@ -1,14 +1,17 @@ import { createColumnHelper, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table'; import { createFileRoute, Link } from '@tanstack/react-router'; +import { ExternalLink, Pencil } from 'lucide-react'; import { useMemo, useState } from 'react'; import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; import { Skeleton } from '@/components/ui/skeleton'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Badge } from '@/components/tower/data/Badge'; -import { PageHeader } from '@/components/tower/shell/PageHeader'; -import { PageContent } from '@/components/tower/shell/PageContent'; +import { SearchFilterPopover } from '@/components/tower/data/SearchFilterPopover'; import { ScopeTag } from '@/components/tower/data/ScopeTag'; +import { EditClientModal } from '@/components/tower/clients/EditClientModal'; +import { NewClientModal } from '@/components/tower/clients/NewClientModal'; +import { PageContent } from '@/components/tower/shell/PageContent'; +import { PageHeader } from '@/components/tower/shell/PageHeader'; import { useAllClients, type ClientWithProject } from '@/queries/useAllClients'; import { useProjects } from '@/queries/useProjects'; @@ -21,11 +24,12 @@ export const Route = createFileRoute('/clients')({ function ClientsRoute() { const projects = useProjects(); const allClients = useAllClients(); - const [search, setSearch] = useState(''); + const [search, setSearch] = useState(undefined); + const [editingClient, setEditingClient] = useState(null); const projectNames = useMemo(() => new Map((projects.data?.data ?? []).map((project) => [project.id, project.name])), [projects.data]); const filtered = useMemo(() => { - const needle = search.trim().toLowerCase(); + const needle = search?.trim().toLowerCase(); if (!needle) { return allClients.data; } @@ -38,7 +42,18 @@ function ClientsRoute() { }, [allClients.data, projectNames, search]); const columns = useMemo(() => [ - columnHelper.accessor('name', { cell: (info) => info.getValue(), header: 'Name' }), + columnHelper.accessor('name', { + cell: (info) => ( + + ), + header: 'Name', + }), columnHelper.display({ cell: (info) => { const projectId = info.row.original.projectId; @@ -56,13 +71,21 @@ function ClientsRoute() { columnHelper.accessor('isActive', { cell: (info) => {info.getValue() ? 'active' : 'revoked'}, header: 'Status' }), columnHelper.accessor('lastUsedAt', { cell: (info) => info.getValue() ? formatDate(info.getValue()!) : 'never', header: 'Last used' }), columnHelper.display({ - cell: (info) => ( -
- -
- ), + cell: (info) => { + const targetProjectName = projectNames.get(info.row.original.projectId) ?? info.row.original.projectId; + return ( +
+ + +
+ ); + }, header: '', id: 'actions', }), @@ -73,19 +96,24 @@ function ClientsRoute() { return ( <> - - - -
-
- setSearch(event.target.value)} - placeholder="Filter by client or project" - value={search} + + +
+ )} + description={`All credentials issued across projects. ${allClients.data.length} total · ${totalActive} active.`} + title="Clients" + /> + +
{allClients.isLoading ? : (
@@ -107,6 +135,13 @@ function ClientsRoute() { )}
+ + { if (!open) setEditingClient(null); }} + open={editingClient !== null} + projectId={editingClient?.projectId ?? ''} + /> ); } From 744e9347881c355b2862b348fed23accce0993b8 Mon Sep 17 00:00:00 2001 From: Mohammadreza Taikandi Date: Sat, 9 May 2026 14:14:22 +0100 Subject: [PATCH 09/27] feat(tower): redesign Scopes page with filter, stacked cards, and inline delete - Header actions: SearchFilterPopover next to the New scope button (Plus icon, matching the Projects page pattern); search filters by dimension or description - Card layout flattened to a stacked design: dimension name (mono) and Edit button on the top row, description below, allowed-value pills underneath - Allowed values render as bordered monospace tags both on the cards and inside the edit modal so the visual language stays consistent; modal pills are clickable buttons with an inline X to remove - Delete moved into the edit modal as a destructive action with a type-to-confirm AlertDialog (mirrors the EditClientModal pattern); removed the standalone DeleteScopeDialog now that the flow lives inside the edit dialog --- src/GroundControl.Tower/src/routes/scopes.tsx | 167 ++++++++++++------ 1 file changed, 117 insertions(+), 50 deletions(-) diff --git a/src/GroundControl.Tower/src/routes/scopes.tsx b/src/GroundControl.Tower/src/routes/scopes.tsx index 66ca682b..dfdd37bb 100644 --- a/src/GroundControl.Tower/src/routes/scopes.tsx +++ b/src/GroundControl.Tower/src/routes/scopes.tsx @@ -1,13 +1,13 @@ import { createFileRoute } from '@tanstack/react-router'; -import { useEffect, useState } from 'react'; +import { Plus, X } from 'lucide-react'; +import { useEffect, useId, useMemo, useState } from 'react'; 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 { Skeleton } from '@/components/ui/skeleton'; import { Textarea } from '@/components/ui/textarea'; -import { FilterChip } from '@/components/tower/data/FilterChip'; -import { InlineCode } from '@/components/tower/data/InlineCode'; +import { SearchFilterPopover } from '@/components/tower/data/SearchFilterPopover'; import { PageHeader } from '@/components/tower/shell/PageHeader'; import { PageContent } from '@/components/tower/shell/PageContent'; import { useCreateScope, useDeleteScope, useScopes, useUpdateScope, type Scope } from '@/queries/useScopes'; @@ -20,32 +20,62 @@ function ScopesRoute() { const scopes = useScopes(); const [creating, setCreating] = useState(false); const [editingScope, setEditingScope] = useState(); - const [deletingScope, setDeletingScope] = useState(); + const [search, setSearch] = useState(undefined); const items = scopes.data?.data ?? []; + const filtered = useMemo(() => { + const needle = search?.trim().toLowerCase(); + if (!needle) { + return items; + } + + return items.filter((scope) => scope.dimension.toLowerCase().includes(needle) || (scope.description ?? '').toLowerCase().includes(needle)); + }, [items, search]); + return ( <> - setCreating(true)} type="button">New scope} description="Decide which settings each app sees based on where it's running." title="Scopes" /> + + + +
+ )} + description="Decide which settings each app sees based on where it's running." + title="Scopes" + />
{scopes.isLoading ? : null} {!scopes.isLoading && items.length === 0 ?
No scope dimensions yet.
: null} - {items.length > 0 ? ( + {!scopes.isLoading && items.length > 0 && filtered.length === 0 ?
No scopes match the current filter.
: null} + {filtered.length > 0 ? (
- {items.map((scope) => ( -
-
- {scope.dimension} -

{scope.description || 'No description provided.'}

-
-
- {scope.allowedValues.map((value) => undefined} />)} -
-
- - + {filtered.map((scope) => ( +
+
+
+

{scope.dimension}

+

{scope.description || 'No description provided.'}

+
+
+ {scope.allowedValues.length > 0 ? ( +
+ {scope.allowedValues.map((value) => ( + {value} + ))} +
+ ) : null}
))}
@@ -55,7 +85,6 @@ function ScopesRoute() { !open && setEditingScope(undefined)} open={Boolean(editingScope)} scope={editingScope} /> - !open && setDeletingScope(undefined)} open={Boolean(deletingScope)} scope={deletingScope} /> ); } @@ -63,11 +92,16 @@ function ScopesRoute() { function ScopeModal({ mode, onOpenChange, open, scope }: { mode: 'create' | 'edit'; onOpenChange: (open: boolean) => void; open: boolean; scope?: Scope }) { const createScope = useCreateScope(); const updateScope = useUpdateScope(); + const deleteScope = useDeleteScope(); const [dimension, setDimension] = useState(''); const [description, setDescription] = useState(''); const [allowedValues, setAllowedValues] = useState([]); const [newValue, setNewValue] = useState(''); + const [confirmingDelete, setConfirmingDelete] = useState(false); + const [deleteConfirmText, setDeleteConfirmText] = useState(''); + const deleteConfirmInputId = useId(); const pending = createScope.isPending || updateScope.isPending; + const isDeleteConfirmed = !!scope && deleteConfirmText === scope.dimension; useEffect(() => { if (!open) { @@ -80,6 +114,12 @@ function ScopeModal({ mode, onOpenChange, open, scope }: { mode: 'create' | 'edi setNewValue(''); }, [open, scope]); + useEffect(() => { + if (!confirmingDelete) { + setDeleteConfirmText(''); + } + }, [confirmingDelete]); + function addValue() { const value = newValue.trim(); @@ -103,6 +143,16 @@ function ScopeModal({ mode, onOpenChange, open, scope }: { mode: 'create' | 'edi onOpenChange(false); } + async function confirmDelete() { + if (!scope) { + return; + } + + await deleteScope.mutateAsync({ id: scope.id, version: scope.version.toString() }); + setConfirmingDelete(false); + onOpenChange(false); + } + return ( @@ -126,43 +176,60 @@ function ScopeModal({ mode, onOpenChange, open, scope }: { mode: 'create' | 'edi
- {allowedValues.map((value) => setAllowedValues((current) => current.filter((item) => item !== value))} selected />)} + {allowedValues.map((value) => ( + + ))}
{allowedValues.length === 0 ?
Add at least one allowed value before saving this scope.
: null}
- - + + {mode === 'edit' && scope ? ( + + ) : null} +
+ + +
-
- ); -} - -function DeleteScopeDialog({ onOpenChange, open, scope }: { onOpenChange: (open: boolean) => void; open: boolean; scope?: Scope }) { - const deleteScope = useDeleteScope(); - - async function confirmDelete() { - if (!scope) { - return; - } - - await deleteScope.mutateAsync({ id: scope.id, version: scope.version.toString() }); - onOpenChange(false); - } - return ( - - - - Delete scope dimension - Deleting {scope?.dimension ?? 'scope'} can orphan scoped values that still reference this dimension. - - - Cancel - { event.preventDefault(); void confirmDelete(); }}>Delete - - - + + + + Delete {scope?.dimension ?? 'scope'}? + Deleting this dimension can orphan scoped values that still reference it. This cannot be undone. + + {scope ? ( +
+ + setDeleteConfirmText(event.target.value)} + placeholder={scope.dimension} + value={deleteConfirmText} + /> +
+ ) : null} + + Cancel + { event.preventDefault(); void confirmDelete(); }}>{deleteScope.isPending ? 'Deleting…' : 'Delete'} + +
+
+ ); } From d9946b87888b1179fc8fcf6db79349a8caa09817 Mon Sep 17 00:00:00 2001 From: Mohammadreza Taikandi Date: Sat, 9 May 2026 14:35:35 +0100 Subject: [PATCH 10/27] feat(tower): adopt projects-style divided list on Clients and Scopes pages - Replace per-item bordered cards with a single bordered container of divided rows, mirroring the Projects page layout - Each row is a role=button with click and Enter/Space handlers that open the edit dialog directly; per-row Edit buttons are gone - Clients row: name in [active] on top, scope tags below, Created/Updated/Expires (when set) in a small caption row; right column shows relative Last used time and the navigate-to-project icon button (with stopPropagation so clicking it doesn't trigger the row's edit handler) - Scopes row: dimension on top, allowed-value tags directly below, description last; tags now use the same bg-bg-selected / text-fg-on-selected colours as the client scope tags for visual consistency, and the editable remove-pills inside the edit modal follow the same scheme --- .../src/routes/clients.tsx | 152 ++++++++---------- src/GroundControl.Tower/src/routes/scopes.tsx | 43 ++--- 2 files changed, 91 insertions(+), 104 deletions(-) diff --git a/src/GroundControl.Tower/src/routes/clients.tsx b/src/GroundControl.Tower/src/routes/clients.tsx index beff4507..40c0a9d8 100644 --- a/src/GroundControl.Tower/src/routes/clients.tsx +++ b/src/GroundControl.Tower/src/routes/clients.tsx @@ -1,10 +1,8 @@ -import { createColumnHelper, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table'; import { createFileRoute, Link } from '@tanstack/react-router'; -import { ExternalLink, Pencil } from 'lucide-react'; +import { ExternalLink } from 'lucide-react'; import { useMemo, useState } from 'react'; import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Badge } from '@/components/tower/data/Badge'; import { SearchFilterPopover } from '@/components/tower/data/SearchFilterPopover'; import { ScopeTag } from '@/components/tower/data/ScopeTag'; @@ -12,11 +10,10 @@ import { EditClientModal } from '@/components/tower/clients/EditClientModal'; import { NewClientModal } from '@/components/tower/clients/NewClientModal'; import { PageContent } from '@/components/tower/shell/PageContent'; import { PageHeader } from '@/components/tower/shell/PageHeader'; +import { formatRelativeTime } from '@/lib/relative-time'; import { useAllClients, type ClientWithProject } from '@/queries/useAllClients'; import { useProjects } from '@/queries/useProjects'; -const columnHelper = createColumnHelper(); - export const Route = createFileRoute('/clients')({ component: ClientsRoute, }); @@ -41,57 +38,6 @@ function ClientsRoute() { }); }, [allClients.data, projectNames, search]); - const columns = useMemo(() => [ - columnHelper.accessor('name', { - cell: (info) => ( - - ), - header: 'Name', - }), - columnHelper.display({ - cell: (info) => { - const projectId = info.row.original.projectId; - const name = projectNames.get(projectId) ?? projectId; - return ( - - {name} - - ); - }, - header: 'Project', - id: 'project', - }), - columnHelper.display({ cell: (info) => , header: 'Scope context', id: 'scopes' }), - columnHelper.accessor('isActive', { cell: (info) => {info.getValue() ? 'active' : 'revoked'}, header: 'Status' }), - columnHelper.accessor('lastUsedAt', { cell: (info) => info.getValue() ? formatDate(info.getValue()!) : 'never', header: 'Last used' }), - columnHelper.display({ - cell: (info) => { - const targetProjectName = projectNames.get(info.row.original.projectId) ?? info.row.original.projectId; - return ( -
- - -
- ); - }, - header: '', - id: 'actions', - }), - ], [projectNames]); - - const table = useReactTable({ columns, data: filtered, getCoreRowModel: getCoreRowModel() }); const totalActive = allClients.data.filter((client) => client.isActive).length; return ( @@ -113,26 +59,72 @@ function ClientsRoute() { /> -
- {allClients.isLoading ? : ( +
+ {allClients.isLoading ? : null} + {!allClients.isLoading && allClients.data.length === 0 ?
No clients yet.
: null} + {!allClients.isLoading && allClients.data.length > 0 && filtered.length === 0 ?
No clients match the current filter.
: null} + {filtered.length > 0 ? (
-
- - - {table.getHeaderGroups().map((headerGroup) => ( - {headerGroup.headers.map((header) => {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())})} - ))} - - - {table.getRowModel().rows.map((row) => ( - {row.getVisibleCells().map((cell) => {flexRender(cell.column.columnDef.cell, cell.getContext())})} - ))} - {table.getRowModel().rows.length === 0 ? No clients found. : null} - -
-
+
    + {filtered.map((client) => { + const projectName = projectNames.get(client.projectId) ?? client.projectId; + const scopeEntries = Object.entries(client.scopes); + return ( +
  • +
    setEditingClient(client)} + onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); setEditingClient(client); } }} + role="button" + tabIndex={0} + > +
    +
    +

    {client.name}

    + in + event.stopPropagation()} + params={{ projectId: client.projectId }} + to="/projects/$projectId/clients" + > + {projectName} + + {client.isActive ? 'active' : 'revoked'} +
    + {scopeEntries.length > 0 ? ( +
    + {scopeEntries.map(([dimension, value]) => )} +
    + ) : null} +
    + Created at: {formatDateTime(client.createdAt)} + Updated at: {formatDateTime(client.updatedAt)} + {client.expiresAt ? Expires at: {formatDateTime(client.expiresAt)} : null} +
    +
    +
    +
    + Last used: {client.lastUsedAt ? formatRelativeTime(client.lastUsedAt) : 'never'} +
    + +
    +
    +
  • + ); + })} +
- )} + ) : null}
@@ -146,16 +138,6 @@ function ClientsRoute() { ); } -function ScopeChips({ scopes }: { scopes: Record }) { - const entries = Object.entries(scopes); - - if (entries.length === 0) { - return default; - } - - return
{entries.map(([dimension, value]) => )}
; -} - -function formatDate(value: string) { +function formatDateTime(value: string) { return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value)); } diff --git a/src/GroundControl.Tower/src/routes/scopes.tsx b/src/GroundControl.Tower/src/routes/scopes.tsx index dfdd37bb..9d1f5210 100644 --- a/src/GroundControl.Tower/src/routes/scopes.tsx +++ b/src/GroundControl.Tower/src/routes/scopes.tsx @@ -59,25 +59,30 @@ function ScopesRoute() { {!scopes.isLoading && items.length === 0 ?
No scope dimensions yet.
: null} {!scopes.isLoading && items.length > 0 && filtered.length === 0 ?
No scopes match the current filter.
: null} {filtered.length > 0 ? ( -
- {filtered.map((scope) => ( -
-
-
+
+
    + {filtered.map((scope) => ( +
  • +
    setEditingScope(scope)} + onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); setEditingScope(scope); } }} + role="button" + tabIndex={0} + >

    {scope.dimension}

    -

    {scope.description || 'No description provided.'}

    + {scope.allowedValues.length > 0 ? ( +
    + {scope.allowedValues.map((value) => ( + {value} + ))} +
    + ) : null} + {scope.description ?

    {scope.description}

    : null}
    - -
- {scope.allowedValues.length > 0 ? ( -
- {scope.allowedValues.map((value) => ( - {value} - ))} -
- ) : null} -
- ))} + + ))} +
) : null}
@@ -179,13 +184,13 @@ function ScopeModal({ mode, onOpenChange, open, scope }: { mode: 'create' | 'edi {allowedValues.map((value) => ( ))}
From 1dbbafa4999d13b1d4efdb82230ab9a91ccbcf8a Mon Sep 17 00:00:00 2001 From: Mohammadreza Taikandi Date: Sat, 9 May 2026 15:33:12 +0100 Subject: [PATCH 11/27] feat(tower): redesign Variables page with reveal/copy value cell Adopts the projects-style divided list pattern on the Variables page and introduces a shared RevealButton extracted from EntryValue. Each row now shows the variable name, sensitive chip, owner pill (green 'GLOBAL' for ungrouped/unscoped vars), description, and a config-style value panel that supports masking, on-demand decryption, and copy. The edit modal gains a reveal button on the value input and decrypts automatically when unchecking 'Sensitive value' so the user never sees the literal '***'. --- .../src/api/endpoints/variables.ts | 6 +- .../components/tower/config/EntryValue.tsx | 41 +- .../components/tower/data/RevealButton.tsx | 41 ++ .../src/routes/variables.tsx | 379 +++++++++++++----- 4 files changed, 335 insertions(+), 132 deletions(-) create mode 100644 src/GroundControl.Tower/src/components/tower/data/RevealButton.tsx 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/components/tower/config/EntryValue.tsx b/src/GroundControl.Tower/src/components/tower/config/EntryValue.tsx index 3483616a..7d360c8d 100644 --- a/src/GroundControl.Tower/src/components/tower/config/EntryValue.tsx +++ b/src/GroundControl.Tower/src/components/tower/config/EntryValue.tsx @@ -1,8 +1,8 @@ -import { Eye, EyeOff, Loader2, Lock } from 'lucide-react'; +import { Lock } from 'lucide-react'; import { CopyButton } from '@/components/tower/data/CopyButton'; +import { RevealButton } from '@/components/tower/data/RevealButton'; import { SensitiveValue } from '@/components/tower/code/SensitiveValue'; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; -import { cn } from '@/lib/utils'; +import { TooltipProvider } from '@/components/ui/tooltip'; import { scopedValueKey, type EntryReveal } from './use-entry-reveal'; interface EntryValueProps { @@ -58,38 +58,3 @@ export function EntryValue({ ariaLabel = 'Copy value', bare = false, emptyMessag ); } -interface RevealButtonProps { - onToggle: () => void; - pending: boolean; - revealed: boolean; -} - -function RevealButton({ onToggle, pending, revealed }: RevealButtonProps) { - const label = revealed ? 'Hide value' : 'Reveal value'; - - return ( - - - - - {label} - - ); -} diff --git a/src/GroundControl.Tower/src/components/tower/data/RevealButton.tsx b/src/GroundControl.Tower/src/components/tower/data/RevealButton.tsx new file mode 100644 index 00000000..97cecbda --- /dev/null +++ b/src/GroundControl.Tower/src/components/tower/data/RevealButton.tsx @@ -0,0 +1,41 @@ +import { Eye, EyeOff, Loader2 } from 'lucide-react'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; + +interface RevealButtonProps { + className?: string; + onToggle: () => void; + pending: boolean; + revealed: boolean; +} + +export function RevealButton({ className, onToggle, pending, revealed }: RevealButtonProps) { + const label = revealed ? 'Hide value' : 'Reveal value'; + + return ( + + + + + {label} + + ); +} diff --git a/src/GroundControl.Tower/src/routes/variables.tsx b/src/GroundControl.Tower/src/routes/variables.tsx index 0b7c62bc..b5df91d2 100644 --- a/src/GroundControl.Tower/src/routes/variables.tsx +++ b/src/GroundControl.Tower/src/routes/variables.tsx @@ -1,28 +1,31 @@ -import { createColumnHelper, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table'; +import { useMutation } from '@tanstack/react-query'; import { createFileRoute } from '@tanstack/react-router'; -import { useEffect, useMemo, useState } from 'react'; +import { Lock, Plus } from 'lucide-react'; +import { useEffect, useId, useMemo, useState } from 'react'; +import { toast } from 'sonner'; 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Skeleton } from '@/components/ui/skeleton'; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Textarea } from '@/components/ui/textarea'; -import { SensitiveValue } from '@/components/tower/code/SensitiveValue'; -import { Badge } from '@/components/tower/data/Badge'; -import { InlineCode } from '@/components/tower/data/InlineCode'; +import { TooltipProvider } from '@/components/ui/tooltip'; +import { EntryValue } from '@/components/tower/config/EntryValue'; +import type { EntryReveal } from '@/components/tower/config/use-entry-reveal'; +import { RevealButton } from '@/components/tower/data/RevealButton'; +import { SearchFilterPopover } from '@/components/tower/data/SearchFilterPopover'; +import { SegmentedControl } from '@/components/tower/data/SegmentedControl'; import { PageHeader } from '@/components/tower/shell/PageHeader'; import { PageContent } from '@/components/tower/shell/PageContent'; -import { SegmentedControl } from '@/components/tower/data/SegmentedControl'; -import { cn } from '@/lib/utils'; +import { getVariable } from '@/api/endpoints/variables'; import { useGroups } from '@/queries/useGroups'; import { useProjects } from '@/queries/useProjects'; import { useCreateVariable, useDeleteVariable, useUpdateVariable, useVariables, type Variable } from '@/queries/useVariables'; -type VariableTier = 'group' | 'project'; +const SENSITIVE_MASK = '***'; -const columnHelper = createColumnHelper(); +type VariableTier = 'group' | 'project'; export const Route = createFileRoute('/variables')({ component: VariablesRoute, @@ -34,75 +37,68 @@ function VariablesRoute() { const groups = useGroups(); const [creating, setCreating] = useState(false); const [editingVariable, setEditingVariable] = useState(); - const [deletingVariable, setDeletingVariable] = useState(); + const [search, setSearch] = useState(undefined); const projectNames = useMemo(() => new Map((projects.data?.data ?? []).map((project) => [project.id, project.name])), [projects.data?.data]); const groupNames = useMemo(() => new Map((groups.data?.data ?? []).map((group) => [group.id, group.name])), [groups.data?.data]); - const data = variables.data?.data ?? []; - const columns = useMemo(() => [ - columnHelper.accessor('name', { cell: (info) => {info.getValue()}, header: 'Name' }), - columnHelper.display({ cell: (info) => , header: 'Value', id: 'value' }), - columnHelper.accessor('isSensitive', { cell: (info) => {info.getValue() ? 'sensitive' : 'plain'}, header: 'Mode' }), - columnHelper.display({ cell: (info) => {ownerLabel(info.row.original, projectNames, groupNames)}, header: 'Owner', id: 'owner' }), - columnHelper.accessor('updatedAt', { cell: (info) => formatDate(info.getValue()), header: 'Updated' }), - columnHelper.display({ cell: (info) =>
, header: '', id: 'actions' }), - ], [groupNames, projectNames]); - const table = useReactTable({ columns, data, getCoreRowModel: getCoreRowModel() }); + const items = variables.data?.data ?? []; + + const filtered = useMemo(() => { + const needle = search?.trim().toLowerCase(); + if (!needle) { + return items; + } + + return items.filter((variable) => { + const ownerText = ownerLabel(variable, projectNames, groupNames).toLowerCase(); + return variable.name.toLowerCase().includes(needle) || (variable.description ?? '').toLowerCase().includes(needle) || ownerText.includes(needle); + }); + }, [groupNames, items, projectNames, search]); return ( <> - setCreating(true)} type="button">New variable} description="Reusable values for interpolation during snapshot publishing" title="Variables" /> + + + +
+ )} + description="Reusable values for interpolation during snapshot publishing." + title="Variables" + />
- {variables.isLoading ? : ( + {variables.isLoading ? : null} + {!variables.isLoading && items.length === 0 ?
No variables yet.
: null} + {!variables.isLoading && items.length > 0 && filtered.length === 0 ?
No variables match the current filter.
: null} + {filtered.length > 0 ? (
-
- - - {table.getHeaderGroups().map((headerGroup) => ( - {headerGroup.headers.map((header) => {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())})} - ))} - - {table.getRowModel().rows.map((row, index, all) => { - const description = row.original.description?.trim(); - const cells = row.getVisibleCells(); - const mainCells = cells.slice(0, -1); - const actionsCell = cells[cells.length - 1]; - const isLast = index === all.length - 1; - - return ( - tr:last-child]:border-b-0')} key={row.id}> - td]:pt-3', description ? 'border-b-0 [&>td]:pb-2' : '[&>td]:pb-4')}> - {mainCells.map((cell) => {flexRender(cell.column.columnDef.cell, cell.getContext())})} - {actionsCell ? ( - - {flexRender(actionsCell.column.columnDef.cell, actionsCell.getContext())} - - ) : null} - - {description ? ( - - {description} - - ) : null} - - ); - })} - {table.getRowModel().rows.length === 0 ? ( - - No variables found. - - ) : null} -
-
+
    + {filtered.map((variable) => ( + setEditingVariable(variable)} + ownerText={ownerLabel(variable, projectNames, groupNames)} + variable={variable} + /> + ))} +
- )} + ) : null}
!open && setEditingVariable(undefined)} open={Boolean(editingVariable)} variable={editingVariable} /> - !open && setDeletingVariable(undefined)} open={Boolean(deletingVariable)} variable={deletingVariable} /> ); } @@ -112,6 +108,7 @@ function VariableModal({ mode, onOpenChange, open, variable }: { mode: 'create' const groups = useGroups(); const createVariable = useCreateVariable(); const updateVariable = useUpdateVariable(); + const deleteVariable = useDeleteVariable(); const [name, setName] = useState(''); const [value, setValue] = useState(''); const [description, setDescription] = useState(''); @@ -119,8 +116,37 @@ function VariableModal({ mode, onOpenChange, open, variable }: { mode: 'create' const [tier, setTier] = useState('group'); const [groupId, setGroupId] = useState(null); const [projectId, setProjectId] = useState(null); + const [confirmingDelete, setConfirmingDelete] = useState(false); + const [deleteConfirmText, setDeleteConfirmText] = useState(''); + const [valueRevealed, setValueRevealed] = useState(false); + const deleteConfirmInputId = useId(); const pending = createVariable.isPending || updateVariable.isPending; const isEdit = mode === 'edit'; + const isDeleteConfirmed = !!variable && deleteConfirmText === variable.name; + const revealValue = useMutation({ + mutationFn: async () => { + if (!variable) { + throw new Error('NO_VARIABLE'); + } + + const data = await getVariable(variable.id, { decrypt: true }); + const next = data?.values.find((entry) => !entry.scopes || Object.keys(entry.scopes).length === 0)?.value ?? ''; + if (next === SENSITIVE_MASK || next === defaultValue(variable)) { + throw new Error('NO_PERMISSION'); + } + + return next; + }, + onError: (error) => { + const message = (error as Error).message; + toast.error(message === 'NO_PERMISSION' ? "You don't have permission to reveal sensitive values." : "Couldn't reveal sensitive value."); + }, + onSuccess: (next) => { + setValue(next); + setValueRevealed(true); + }, + }); + const canRevealValue = isEdit && !!variable && variable.isSensitive && value.length > 0; useEffect(() => { if (!open) { @@ -131,6 +157,8 @@ function VariableModal({ mode, onOpenChange, open, variable }: { mode: 'create' setValue(defaultValue(variable)); setDescription(variable?.description ?? ''); setIsSensitive(variable?.isSensitive ?? false); + setValueRevealed(false); + revealValue.reset(); if (variable) { if (variable.projectId) { @@ -147,8 +175,43 @@ function VariableModal({ mode, onOpenChange, open, variable }: { mode: 'create' setGroupId(null); setProjectId(null); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, variable]); + function toggleValueReveal() { + if (valueRevealed) { + setValueRevealed(false); + + return; + } + + if (revealValue.isSuccess) { + setValueRevealed(true); + + return; + } + + revealValue.mutate(); + } + + function handleSensitiveChange(checked: boolean) { + if (!checked && variable?.isSensitive && !revealValue.isSuccess && value.length > 0) { + revealValue.mutate(undefined, { + onSuccess: () => setIsSensitive(false), + }); + + return; + } + + setIsSensitive(checked); + } + + useEffect(() => { + if (!confirmingDelete) { + setDeleteConfirmText(''); + } + }, [confirmingDelete]); + const canSave = useMemo(() => { if (!name.trim()) { return false; @@ -182,6 +245,16 @@ function VariableModal({ mode, onOpenChange, open, variable }: { mode: 'create' onOpenChange(false); } + async function confirmDelete() { + if (!variable) { + return; + } + + await deleteVariable.mutateAsync({ id: variable.id, version: variable.version.toString() }); + setConfirmingDelete(false); + onOpenChange(false); + } + return ( @@ -248,10 +321,25 @@ function VariableModal({ mode, onOpenChange, open, variable }: { mode: 'create'
- setValue(event.target.value)} type={isSensitive ? 'password' : 'text'} value={value} /> + +
+ setValue(event.target.value)} + type={isSensitive && !valueRevealed ? 'password' : 'text'} + value={value} + /> + {canRevealValue ? ( +
+ +
+ ) : null} +
+
-