From e36769ab6c5a81f742a217edd8b7e4fdf3418c7a Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Sun, 13 Sep 2026 14:36:33 +0300 Subject: [PATCH 1/6] fix: say what is holding a partner, data source or work group Co-Authored-By: Claude Opus 5 (1M context) --- .../Resources/DataSources/Delete.cs | 16 +++++ SW.Bitween.Api/Resources/Partners/Delete.cs | 61 +++++++++++++++++-- SW.Bitween.Api/Resources/WorkGroups/Delete.cs | 15 +++++ 3 files changed, 88 insertions(+), 4 deletions(-) diff --git a/SW.Bitween.Api/Resources/DataSources/Delete.cs b/SW.Bitween.Api/Resources/DataSources/Delete.cs index f001cbb6..03cb3022 100644 --- a/SW.Bitween.Api/Resources/DataSources/Delete.cs +++ b/SW.Bitween.Api/Resources/DataSources/Delete.cs @@ -2,6 +2,7 @@ using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using SW.Bitween.Domain.DataSources; +using SW.Bitween.Domain; using SW.Bitween.Domain.Gateway; using SW.EfCoreExtensions; using SW.PrimitiveTypes; @@ -28,6 +29,21 @@ public async Task Handle(int key) string.Join(", ", gateways) + ". Point them at the internal bus, or delete them, first."); + // Subscription.DataSourceId restricts too, and was the one left to the database — the + // commoner case of the two, since a data source is normally read by an integration long + // before any gateway feeds off it. + var subscriptions = await dbContext.Set() + .Where(subscription => subscription.DataSourceId == key) + .Select(subscription => subscription.Name) + .Take(5) + .ToListAsync(); + + if (subscriptions.Count > 0) + throw new SWException( + "Cannot delete a data source that integrations still read: " + + string.Join(", ", subscriptions) + + ". Point them at another data source, or delete them, first."); + // Dedupe keys cascade with the data source, which is what makes deleting and recreating a // data source a genuine reset rather than one that silently suppresses the first messages. await dbContext.DeleteByKeyAsync(key); diff --git a/SW.Bitween.Api/Resources/Partners/Delete.cs b/SW.Bitween.Api/Resources/Partners/Delete.cs index 5bc8517d..42f8290a 100644 --- a/SW.Bitween.Api/Resources/Partners/Delete.cs +++ b/SW.Bitween.Api/Resources/Partners/Delete.cs @@ -1,10 +1,11 @@ -using SW.EfCoreExtensions; +using SW.EfCoreExtensions; using SW.Bitween.Domain; +using SW.Bitween.Domain.Gateway; using SW.PrimitiveTypes; -using System; using System.Collections.Generic; -using System.Text; +using System.Linq; using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; namespace SW.Bitween.Resources.Partners { @@ -17,8 +18,60 @@ public async Task Handle(int key) if (key == Partner.SystemId) throw new SWException("System partner can not be deleted."); + await EnsureNothingPointsAtIt(key); + await dbContext.DeleteByKeyAsync(key); return null; } + + /// + /// Says what still points at the partner, before the database says it less politely. + /// + /// + /// All three references are RESTRICT, so the delete was already refused — but as a + /// foreign key violation surfacing as a bare 500, which names a constraint instead of the + /// integration holding it and reads like a broken screen rather than a decision. + /// Exchanges are deliberately not checked: they carry a partner id with no foreign key + /// behind it, and history is not a reason to keep configuration alive. + /// + private async Task EnsureNothingPointsAtIt(int key) + { + var heldBy = new List(); + + var subscriptions = await dbContext.Set() + .Where(s => s.PartnerId == key) + .Select(s => s.Name) + .ToArrayAsync(); + if (subscriptions.Length > 0) + heldBy.Add($"the integration {Join(subscriptions)}"); + + var apiGateways = await dbContext.Set() + .Where(p => p.PartnerId == key) + .Select(p => p.ApiGateway.Name) + .Distinct() + .ToArrayAsync(); + if (apiGateways.Length > 0) + heldBy.Add($"an attachment on {Join(apiGateways)}"); + + var busGateways = await dbContext.Set() + .Where(r => r.PartnerId == key) + .Select(r => r.BusGateway.Name) + .Distinct() + .ToArrayAsync(); + if (busGateways.Length > 0) + heldBy.Add($"a route on {Join(busGateways)}"); + + if (heldBy.Count == 0) + return; + + throw new SWValidationException("PARTNER_IN_USE", + $"This partner is still used by {Join(heldBy.ToArray())}. " + + "Remove that first, or point it at another partner."); + } + + private static string Join(string[] names) => + names.Length == 1 + ? names[0] + : $"{string.Join(", ", names[..^1])} and {names[^1]}"; } -} \ No newline at end of file +} diff --git a/SW.Bitween.Api/Resources/WorkGroups/Delete.cs b/SW.Bitween.Api/Resources/WorkGroups/Delete.cs index 60f4c7e9..903e9b42 100644 --- a/SW.Bitween.Api/Resources/WorkGroups/Delete.cs +++ b/SW.Bitween.Api/Resources/WorkGroups/Delete.cs @@ -1,6 +1,8 @@ using System.Threading.Tasks; +using System.Linq; using Microsoft.EntityFrameworkCore; using SW.Bitween.Domain; +using SW.Bitween.Domain.DataSources; using SW.Bitween.Model; using SW.PrimitiveTypes; @@ -21,6 +23,19 @@ public async Task Handle(int key, DeleteWorkGroupModel _) if (await dbContext.Set().AnyAsync(i => i.WorkGroupId.Value == category.Id)) throw new SWValidationException("CANT_BE_DELETED", "Workgroup with Subscriptions cant be deleted"); + // Data source statements run on a work group too, and that foreign key restricts as well — + // left unchecked it refused the delete as a bare 500 naming the constraint. + var statements = await dbContext.Set() + .Where(s => s.WorkGroupId == key) + .Select(s => s.Name) + .Take(5) + .ToListAsync(); + if (statements.Count > 0) + throw new SWValidationException("CANT_BE_DELETED", + "Cannot delete a work group that data source statements run on: " + + string.Join(", ", statements) + + ". Point them at another work group first."); + //Todo chek rabbitMq dbContext.Remove(category); await dbContext.SaveChangesAsync(); From ae181d2a80a9453238e9d62304f36bffc1da755f Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Sun, 13 Sep 2026 14:42:55 +0300 Subject: [PATCH 2/6] fix: refuse a taken gateway address and category code by name Co-Authored-By: Claude Opus 5 (1M context) --- .../Resources/ApiGateways/Create.cs | 1 + .../Resources/ApiGateways/GatewayUrlName.cs | 26 +++++++++++++++++++ .../Resources/ApiGateways/Update.cs | 1 + .../SubscriptionCategories/Create.cs | 17 ++++++++++++ .../SubscriptionCategories/Update.cs | 3 +++ 5 files changed, 48 insertions(+) diff --git a/SW.Bitween.Api/Resources/ApiGateways/Create.cs b/SW.Bitween.Api/Resources/ApiGateways/Create.cs index 2e3e5e9b..b5129ffc 100644 --- a/SW.Bitween.Api/Resources/ApiGateways/Create.cs +++ b/SW.Bitween.Api/Resources/ApiGateways/Create.cs @@ -13,6 +13,7 @@ public async Task Handle(ApiGatewayCreate model) await requestContext.EnsurePermission(dbContext, Model.Permissions.ApiGateways.Create); GatewayUrlName.Validate(model.UrlName); + await GatewayUrlName.EnsureIsFree(dbContext, model.UrlName); var entity = new ApiGateway { diff --git a/SW.Bitween.Api/Resources/ApiGateways/GatewayUrlName.cs b/SW.Bitween.Api/Resources/ApiGateways/GatewayUrlName.cs index 3742d3e7..d9620f83 100644 --- a/SW.Bitween.Api/Resources/ApiGateways/GatewayUrlName.cs +++ b/SW.Bitween.Api/Resources/ApiGateways/GatewayUrlName.cs @@ -1,4 +1,8 @@ using System.Text.RegularExpressions; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using SW.Bitween.Domain.Gateway; using SW.PrimitiveTypes; namespace SW.Bitween.Resources.ApiGateways; @@ -24,4 +28,26 @@ public static void Validate(string urlName) $"'{urlName}' cannot be used in a URL. Use lowercase letters, digits, hyphens " + "and underscores only — no spaces, and not starting or ending with a separator."); } + + /// + /// Refuses a url name another gateway already answers on. Pass the gateway's own id when + /// updating, so saving a gateway without touching its url name isn't a collision with itself. + /// + /// + /// The column is uniquely indexed, so this was already refused — as a constraint violation + /// that reached the screen as "Request failed (500)", with nothing to say the name was taken + /// or which gateway has it. + /// + public static async Task EnsureIsFree(BitweenDbContext dbContext, string urlName, int? existingId = null) + { + var taken = await dbContext.Set().AsNoTracking() + .Where(gateway => gateway.UrlName == urlName && gateway.Id != existingId) + .Select(gateway => gateway.Name) + .FirstOrDefaultAsync(); + + if (taken != null) + throw new SWValidationException("GATEWAY_URL_NAME_TAKEN", + $"'{urlName}' is already the address of the gateway '{taken}'. " + + "Partners reach a gateway by this name, so two can't share one."); + } } diff --git a/SW.Bitween.Api/Resources/ApiGateways/Update.cs b/SW.Bitween.Api/Resources/ApiGateways/Update.cs index c9fce196..791f125a 100644 --- a/SW.Bitween.Api/Resources/ApiGateways/Update.cs +++ b/SW.Bitween.Api/Resources/ApiGateways/Update.cs @@ -23,6 +23,7 @@ public async Task Handle(int key, ApiGatewayUpdate model) throw new SWNotFoundException($"ApiGateway with Id {key} not found"); GatewayUrlName.Validate(model.UrlName); + await GatewayUrlName.EnsureIsFree(dbContext, model.UrlName, key); entity.Name = model.Name; entity.UrlName = model.UrlName; diff --git a/SW.Bitween.Api/Resources/SubscriptionCategories/Create.cs b/SW.Bitween.Api/Resources/SubscriptionCategories/Create.cs index 473983c9..dc45345c 100644 --- a/SW.Bitween.Api/Resources/SubscriptionCategories/Create.cs +++ b/SW.Bitween.Api/Resources/SubscriptionCategories/Create.cs @@ -1,5 +1,6 @@ using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; + using SW.Bitween.Domain; using SW.Bitween.Model; using SW.PrimitiveTypes; @@ -13,6 +14,8 @@ public async Task Handle(CreateSubscriptionCategoryModel request) { await requestContext.EnsurePermission(dbContext, Model.Permissions.Subscriptions.Create); + await EnsureCodeIsFree(dbContext, request.Code); + var category = new SubscriptionCategory(request.Code, request.Description); dbContext.Add(category); await dbContext.SaveChangesAsync(); @@ -21,4 +24,18 @@ public async Task Handle(CreateSubscriptionCategoryModel request) category.Id }; } + + /// + /// The code is uniquely indexed, so a repeat was already refused — as a constraint violation + /// surfacing as a bare 500, which says nothing about the code being taken. + /// + internal static async Task EnsureCodeIsFree(BitweenDbContext dbContext, string code, int? existingId = null) + { + var taken = await dbContext.Set().AsNoTracking() + .AnyAsync(c => c.Code == code && c.Id != existingId); + + if (taken) + throw new SWValidationException("CATEGORY_CODE_TAKEN", + $"A category with the code '{code}' already exists."); + } } \ No newline at end of file diff --git a/SW.Bitween.Api/Resources/SubscriptionCategories/Update.cs b/SW.Bitween.Api/Resources/SubscriptionCategories/Update.cs index b7058a5f..aa05b5c1 100644 --- a/SW.Bitween.Api/Resources/SubscriptionCategories/Update.cs +++ b/SW.Bitween.Api/Resources/SubscriptionCategories/Update.cs @@ -15,6 +15,9 @@ public async Task Handle(int key, CreateSubscriptionCategoryModel reques var category = await dbContext.Set().FindAsync(key); if (category is null) throw new SWValidationException("CATEGORY_NOT_FOUND", $"Category with id {key} was not found"); + + await Create.EnsureCodeIsFree(dbContext, request.Code, key); + category.Update(request.Code, request.Description); await dbContext.SaveChangesAsync(); return null; From 3f25898f207833b42dd0fbb5bf0ea0d0eb9084a3 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Sun, 13 Sep 2026 14:49:28 +0300 Subject: [PATCH 3/6] feat: name a gateway subscription after the partner and gateway Co-Authored-By: Claude Opus 5 (1M context) --- .../NewGatewaySubscriptionPage.tsx | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/NewGatewaySubscriptionPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/NewGatewaySubscriptionPage.tsx index 4de196d0..251a151c 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/NewGatewaySubscriptionPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/NewGatewaySubscriptionPage.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Link, useNavigate, useParams, useSearchParams } from "react-router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { X } from "lucide-react"; @@ -89,6 +89,28 @@ export function NewGatewaySubscriptionPage() { const [draft, update] = useDraft(EMPTY); + // Reuses the picker's cache: the attach page you came from has already listed them, + // so naming the partner here costs no request. + const partners = useQuery({ + queryKey: keys.partners.list, + queryFn: () => api.listPartners(), + enabled: partnerId !== null, + }); + + /** + * Fills the name in from the two things already decided — who calls, and which + * gateway they call — so the common case is a field to accept rather than one to + * invent. Seeded once: `touched` latches as soon as the field is edited, and the + * partner can't change while this page is open, so nothing overwrites a typed name. + */ + const touched = useRef(false); + const partnerName = partners.data?.find((p) => p.id === Number(partnerId))?.name; + useEffect(() => { + if (touched.current || !partnerName || !gateway.data) return; + update({ name: `${partnerName} via ${gateway.data.name}` }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [partnerName, gateway.data]); + /** Back to the attach page, carrying the partner along if one was already picked. */ const backToAttach = (extra: Record) => { const query = new URLSearchParams(extra); @@ -265,7 +287,10 @@ export function NewGatewaySubscriptionPage() { value={draft.name} autoFocus placeholder="e.g. Coral orders to SAP" - onChange={(e) => update({ name: e.target.value })} + onChange={(e) => { + touched.current = true; + update({ name: e.target.value }); + }} /> From 3402eb070ae58baeee50c7a8ef4653192e9778c1 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Sun, 13 Sep 2026 14:54:59 +0300 Subject: [PATCH 4/6] fix: seed a gateway subscription's name from the gateway, not a partner One subscription is normally shared by every partner on the gateway, so naming it after whichever partner was picked first named a shared pipeline after one of its callers. Co-Authored-By: Claude Opus 5 (1M context) --- .../NewGatewaySubscriptionPage.tsx | 28 ++++++++----------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/NewGatewaySubscriptionPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/NewGatewaySubscriptionPage.tsx index 251a151c..546f5724 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/NewGatewaySubscriptionPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/NewGatewaySubscriptionPage.tsx @@ -89,27 +89,23 @@ export function NewGatewaySubscriptionPage() { const [draft, update] = useDraft(EMPTY); - // Reuses the picker's cache: the attach page you came from has already listed them, - // so naming the partner here costs no request. - const partners = useQuery({ - queryKey: keys.partners.list, - queryFn: () => api.listPartners(), - enabled: partnerId !== null, - }); - /** - * Fills the name in from the two things already decided — who calls, and which - * gateway they call — so the common case is a field to accept rather than one to - * invent. Seeded once: `touched` latches as soon as the field is edited, and the - * partner can't change while this page is open, so nothing overwrites a typed name. + * Fills the name in from the gateway, so the field is one to accept rather than one + * to invent. Deliberately not from the partner in `?partnerId=`, even though it is + * right there: an attachment is (gateway, partner, subscription), and one subscription + * is normally shared by every partner on the gateway. Seeding it with whichever partner + * happened to be picked first would name a shared pipeline after one of its callers. + * + * Seeded once: `touched` latches as soon as the field is edited, so nothing overwrites + * a typed name. */ const touched = useRef(false); - const partnerName = partners.data?.find((p) => p.id === Number(partnerId))?.name; + const gatewayName = gateway.data?.name; useEffect(() => { - if (touched.current || !partnerName || !gateway.data) return; - update({ name: `${partnerName} via ${gateway.data.name}` }); + if (touched.current || !gatewayName) return; + update({ name: gatewayName }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [partnerName, gateway.data]); + }, [gatewayName]); /** Back to the attach page, carrying the partner along if one was already picked. */ const backToAttach = (extra: Record) => { From fdcaae5f1d8bff580fc2da51cd2afc03ad6aa154 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Sun, 13 Sep 2026 14:58:40 +0300 Subject: [PATCH 5/6] feat: offer the fix when a filter has no properties to match on Co-Authored-By: Claude Opus 5 (1M context) --- .../components/config/MatchExpressionEditor.tsx | 17 ++++++++++++++++- .../src/pages/bus-gateways/studio/Inspector.tsx | 1 + .../pages/subscriptions/SubscriptionPage.tsx | 1 + 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/SW.Bitween.Web/ClientApp/src/components/config/MatchExpressionEditor.tsx b/SW.Bitween.Web/ClientApp/src/components/config/MatchExpressionEditor.tsx index 54be33d6..82571e92 100644 --- a/SW.Bitween.Web/ClientApp/src/components/config/MatchExpressionEditor.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/config/MatchExpressionEditor.tsx @@ -1,4 +1,5 @@ import { Plus, Trash2 } from "lucide-react"; +import { Link } from "react-router"; import type { MatchCondition, MatchGroup, MatchNode } from "../../api"; import { matchSummary } from "../../lib/match"; import { Button } from "../ui/basics"; @@ -168,12 +169,18 @@ export function MatchExpressionEditor({ onChange, properties, disabled, + informationTypeId, }: { value: MatchGroup | null; onChange: (value: MatchGroup | null) => void; /** Promoted properties of the information type being filtered (friendly name + JSON path). */ properties: { key: string; path: string }[]; disabled: boolean; + /** + * The type those properties come from. Only used to point at it when it has none — + * without it the empty state names the fix without offering it. + */ + informationTypeId?: number | null; }) { if (value === null) { return ( @@ -194,7 +201,15 @@ export function MatchExpressionEditor({ )} {properties.length === 0 && (

- Filters match on promoted properties — this information type has none yet. + Filters match on promoted properties — this information type has none yet.{" "} + {informationTypeId != null && ( + + Add some + + )}

)} diff --git a/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/studio/Inspector.tsx b/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/studio/Inspector.tsx index 162fafec..2b8b6b74 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/studio/Inspector.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/studio/Inspector.tsx @@ -151,6 +151,7 @@ export function RouteBody({ onChange={(matchExpression) => onChange({ matchExpression })} properties={promotedProperties} disabled={disabled} + informationTypeId={informationTypeId} /> diff --git a/SW.Bitween.Web/ClientApp/src/pages/subscriptions/SubscriptionPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/subscriptions/SubscriptionPage.tsx index 76361118..0a7d73ef 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/subscriptions/SubscriptionPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/subscriptions/SubscriptionPage.tsx @@ -241,6 +241,7 @@ export function SubscriptionPage() { onChange={(matchExpression) => set("matchExpression", matchExpression)} properties={infoType.data?.promotedProperties ?? []} disabled={!canEdit} + informationTypeId={infoType.data?.id} /> ) : ( From 063a88a20ac2ae898a99d64e3db11b97e6af701f Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Sun, 13 Sep 2026 15:05:02 +0300 Subject: [PATCH 6/6] fix: cap the names a refused partner delete lists, and gate the link on the grant Co-Authored-By: Claude Opus 5 (1M context) --- SW.Bitween.Api/Resources/Partners/Delete.cs | 22 ++++++++++++++++--- .../config/MatchExpressionEditor.tsx | 7 +++++- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/SW.Bitween.Api/Resources/Partners/Delete.cs b/SW.Bitween.Api/Resources/Partners/Delete.cs index 42f8290a..21e48b6f 100644 --- a/SW.Bitween.Api/Resources/Partners/Delete.cs +++ b/SW.Bitween.Api/Resources/Partners/Delete.cs @@ -41,25 +41,28 @@ private async Task EnsureNothingPointsAtIt(int key) var subscriptions = await dbContext.Set() .Where(s => s.PartnerId == key) .Select(s => s.Name) + .Take(MaxNamed + 1) .ToArrayAsync(); if (subscriptions.Length > 0) - heldBy.Add($"the integration {Join(subscriptions)}"); + heldBy.Add($"the integration {Describe(subscriptions)}"); var apiGateways = await dbContext.Set() .Where(p => p.PartnerId == key) .Select(p => p.ApiGateway.Name) .Distinct() + .Take(MaxNamed + 1) .ToArrayAsync(); if (apiGateways.Length > 0) - heldBy.Add($"an attachment on {Join(apiGateways)}"); + heldBy.Add($"an attachment on {Describe(apiGateways)}"); var busGateways = await dbContext.Set() .Where(r => r.PartnerId == key) .Select(r => r.BusGateway.Name) .Distinct() + .Take(MaxNamed + 1) .ToArrayAsync(); if (busGateways.Length > 0) - heldBy.Add($"a route on {Join(busGateways)}"); + heldBy.Add($"a route on {Describe(busGateways)}"); if (heldBy.Count == 0) return; @@ -69,6 +72,19 @@ private async Task EnsureNothingPointsAtIt(int key) "Remove that first, or point it at another partner."); } + /// + /// How many holders the message names before it stops listing them. A partner carrying + /// forty integrations would otherwise read out all forty into a dialog — longer to + /// understand than the short version, and forty rows fetched to build it. + /// + private const int MaxNamed = 5; + + /// Takes one more than it will name, which is how it knows there are others. + private static string Describe(string[] names) => + names.Length > MaxNamed + ? $"{string.Join(", ", names[..MaxNamed])} and others" + : Join(names); + private static string Join(string[] names) => names.Length == 1 ? names[0] diff --git a/SW.Bitween.Web/ClientApp/src/components/config/MatchExpressionEditor.tsx b/SW.Bitween.Web/ClientApp/src/components/config/MatchExpressionEditor.tsx index 82571e92..38303088 100644 --- a/SW.Bitween.Web/ClientApp/src/components/config/MatchExpressionEditor.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/config/MatchExpressionEditor.tsx @@ -1,6 +1,7 @@ import { Plus, Trash2 } from "lucide-react"; import { Link } from "react-router"; import type { MatchCondition, MatchGroup, MatchNode } from "../../api"; +import { useSessionCan } from "../../auth/guards"; import { matchSummary } from "../../lib/match"; import { Button } from "../ui/basics"; import { Select } from "../ui/forms"; @@ -182,6 +183,10 @@ export function MatchExpressionEditor({ */ informationTypeId?: number | null; }) { + // Not `disabled`: that says whether this filter can be edited, which is a different + // grant from the one that would let you go and add the properties it needs. + const canEditTypes = useSessionCan("documents.edit"); + if (value === null) { return (
@@ -202,7 +207,7 @@ export function MatchExpressionEditor({ {properties.length === 0 && (

Filters match on promoted properties — this information type has none yet.{" "} - {informationTypeId != null && ( + {informationTypeId != null && canEditTypes && (