From fcc4e790b6ba9f803451c1fb75d0255a81db4f88 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Thu, 16 Jul 2026 10:56:50 +0300 Subject: [PATCH 01/48] feat: serve admin UI from backend under /bitween --- .gitignore | 7 +++++++ Dockerfile | 14 ++++++++++++-- SW.Bitween.Web/SW.Bitween.Web.csproj | 23 +++++++++++++++++++++++ SW.Bitween.Web/Startup.cs | 4 ++++ SW.Bitween.Web/appsettings.json | 3 +-- 5 files changed, 47 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index ee53b660..3678c675 100644 --- a/.gitignore +++ b/.gitignore @@ -365,3 +365,10 @@ SW.Bitween.NativeAdapters/JsonFieldMapper/Bitween-api.code-workspace .vscode/ .postman/ postman/ + +# Admin UI build output (generated by ClientApp `yarn build` into wwwroot) +SW.Bitween.Web/wwwroot/index.html +SW.Bitween.Web/wwwroot/assets/ +SW.Bitween.Web/wwwroot/brand/ +SW.Bitween.Web/wwwroot/favicon.svg +SW.Bitween.Web/wwwroot/icons.svg diff --git a/Dockerfile b/Dockerfile index 63224453..602cb04a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,14 @@ WORKDIR /app EXPOSE 8080 EXPOSE 443 +# Build the admin UI (SPA) in its own stage; output lands in SW.Bitween.Web/wwwroot +FROM node:22-alpine AS ui-build +WORKDIR /src/SW.Bitween.Web/ClientApp +COPY ["SW.Bitween.Web/ClientApp/package.json", "SW.Bitween.Web/ClientApp/yarn.lock", "./"] +RUN yarn install --frozen-lockfile +COPY ["SW.Bitween.Web/ClientApp/", "./"] +RUN yarn build + FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build WORKDIR /src COPY ["SW.Bitween.Web/SW.Bitween.Web.csproj", "SW.Bitween.Web/"] @@ -21,9 +29,11 @@ WORKDIR "/src/SW.Bitween.Web" RUN dotnet build "SW.Bitween.Web.csproj" -c Release -o /app/build FROM build AS publish -RUN dotnet publish "SW.Bitween.Web.csproj" -c Release -o /app/publish +# UI is built in the ui-build stage; the SDK image has no node +RUN dotnet publish "SW.Bitween.Web.csproj" -c Release -o /app/publish -p:SkipClientBuild=true FROM base AS final WORKDIR /app COPY --from=publish /app/publish . -ENTRYPOINT ["dotnet", "SW.Bitween.Web.dll"] \ No newline at end of file +COPY --from=ui-build /src/SW.Bitween.Web/wwwroot ./wwwroot +ENTRYPOINT ["dotnet", "SW.Bitween.Web.dll"] diff --git a/SW.Bitween.Web/SW.Bitween.Web.csproj b/SW.Bitween.Web/SW.Bitween.Web.csproj index b47a5c52..b66f6d0f 100644 --- a/SW.Bitween.Web/SW.Bitween.Web.csproj +++ b/SW.Bitween.Web/SW.Bitween.Web.csproj @@ -4,8 +4,31 @@ net8.0 1.0.0.0 SW.Bitween.Web + ClientApp\ + + + + + + + + + + + + + + + %(ClientAppFiles.Identity) + PreserveNewest + true + + + + diff --git a/SW.Bitween.Web/Startup.cs b/SW.Bitween.Web/Startup.cs index ed78fc95..74f95a9e 100644 --- a/SW.Bitween.Web/Startup.cs +++ b/SW.Bitween.Web/Startup.cs @@ -355,6 +355,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) app.UseCors(); app.UsePathBase("/bitween"); + app.UseDefaultFiles(); app.UseStaticFiles(); app.UseRouting(); app.UseAuthentication(); @@ -370,6 +371,9 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) endpoints.MapControllers(); endpoints.MapHealthChecks("/health"); + // SPA fallback: unmatched, non-file routes get the UI's index.html + // so client-side routes (e.g. /bitween/team/members) survive refresh. + endpoints.MapFallbackToFile("index.html"); }); } } diff --git a/SW.Bitween.Web/appsettings.json b/SW.Bitween.Web/appsettings.json index a2822b84..3639e150 100644 --- a/SW.Bitween.Web/appsettings.json +++ b/SW.Bitween.Web/appsettings.json @@ -1,5 +1,4 @@ { - "ASPNETCORE_ENVIRONMENT":"Development", "AllowedHosts": "*", "Theme": { "LoginLogo": "/Graphics/s9.png", @@ -19,4 +18,4 @@ "SWLogger": { "LoggingLevel": 2 } -} \ No newline at end of file +} From 56f50fb2724dd525e31120393f2156f40ada1996 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Thu, 16 Jul 2026 11:33:20 +0300 Subject: [PATCH 02/48] feat: add admin UI redesign prototype (mock data) --- SW.Bitween.Web/ClientApp/.gitignore | 24 + SW.Bitween.Web/ClientApp/.oxlintrc.json | 8 + SW.Bitween.Web/ClientApp/README.md | 60 ++ SW.Bitween.Web/ClientApp/index.html | 13 + SW.Bitween.Web/ClientApp/package.json | 36 + .../public/brand/BitweenFull-light.svg | 18 + .../ClientApp/public/brand/BitweenFull.svg | 18 + .../ClientApp/public/brand/BitweenIcon.png | Bin 0 -> 5259 bytes .../ClientApp/public/brand/BitweenIcon.svg | 5 + SW.Bitween.Web/ClientApp/public/favicon.svg | 1 + SW.Bitween.Web/ClientApp/public/icons.svg | 24 + SW.Bitween.Web/ClientApp/src/api/client.ts | 266 +++++ SW.Bitween.Web/ClientApp/src/api/index.ts | 11 + .../ClientApp/src/api/mock/adapters.ts | 193 ++++ .../ClientApp/src/api/mock/configClient.ts | 689 +++++++++++++ .../ClientApp/src/api/mock/derive.ts | 61 ++ .../src/api/mock/integrationsClient.ts | 654 +++++++++++++ .../ClientApp/src/api/mock/mockClient.ts | 398 ++++++++ SW.Bitween.Web/ClientApp/src/api/mock/seed.ts | 155 +++ .../ClientApp/src/api/mock/seedConfig.ts | 920 ++++++++++++++++++ .../ClientApp/src/api/mock/settingsClient.ts | 30 + .../ClientApp/src/api/mock/store.ts | 126 +++ .../ClientApp/src/api/permissions.ts | 208 ++++ SW.Bitween.Web/ClientApp/src/api/types.ts | 542 +++++++++++ .../ClientApp/src/auth/SessionContext.tsx | 96 ++ SW.Bitween.Web/ClientApp/src/auth/guards.tsx | 73 ++ .../src/components/config/AdapterConfig.tsx | 305 ++++++ .../config/MatchExpressionEditor.tsx | 213 ++++ .../src/components/config/ScheduleEditor.tsx | 185 ++++ .../src/components/config/pickers.tsx | 168 ++++ .../src/components/config/shared.tsx | 231 +++++ .../src/components/config/wizard.tsx | 200 ++++ .../src/components/layout/AppShell.tsx | 192 ++++ .../src/components/layout/DemoSwitcher.tsx | 123 +++ .../src/components/layout/PageHeader.tsx | 62 ++ .../ArrayMappingFieldRow.tsx | 260 +++++ .../ArrayMappingModal/ArrayMappingModal.tsx | 322 ++++++ .../ArrayMappingModal/useArrayMappingModal.ts | 365 +++++++ .../components/mapper/ConnectionCanvas.tsx | 247 +++++ .../components/mapper/FixedStringInput.tsx | 119 +++ .../components/mapper/GlobalSetSelector.tsx | 53 + .../src/components/mapper/LeafValueInput.tsx | 147 +++ .../src/components/mapper/LivePreview.tsx | 102 ++ .../mapper/LookupDictionaryPanel.tsx | 152 +++ .../src/components/mapper/ManualEditor.tsx | 178 ++++ .../src/components/mapper/MappingEditor.tsx | 334 +++++++ .../mapper/MappingEditorToolbar.tsx | 218 +++++ .../components/mapper/ModeToggleButtons.tsx | 57 ++ .../mapper/OutputTree/ArrayInnerField.tsx | 68 ++ .../mapper/OutputTree/FixedItemPanel.tsx | 273 ++++++ .../mapper/OutputTree/NormalLeaf.tsx | 171 ++++ .../mapper/OutputTree/OutputBranch.tsx | 330 +++++++ .../mapper/OutputTree/OutputLeaf.tsx | 111 +++ .../mapper/OutputTree/OutputTree.tsx | 47 + .../mapper/OutputTree/PrimitiveArrayLeaf.tsx | 137 +++ .../mapper/OutputTree/useNormalLeaf.ts | 216 ++++ .../OutputTree/usePrimitiveArrayLeaf.ts | 71 ++ .../src/components/mapper/SourceTree.tsx | 209 ++++ .../ClientApp/src/components/mapper/data.ts | 35 + .../components/mapper/useKeyboardShortcuts.ts | 28 + .../mapper/useMappingEditorLoader.ts | 42 + .../src/components/mapper/useSave.ts | 160 +++ .../ClientApp/src/components/ui/Avatar.tsx | 45 + .../ClientApp/src/components/ui/CopyField.tsx | 29 + .../src/components/ui/KeyValueEditor.tsx | 249 +++++ .../ClientApp/src/components/ui/Panel.tsx | 101 ++ .../src/components/ui/ReturnBanner.tsx | 28 + .../ClientApp/src/components/ui/basics.tsx | 110 +++ .../ClientApp/src/components/ui/forms.tsx | 102 ++ .../ClientApp/src/components/ui/overlays.tsx | 164 ++++ SW.Bitween.Web/ClientApp/src/index.css | 71 ++ .../ClientApp/src/lib/colorScale.ts | 106 ++ SW.Bitween.Web/ClientApp/src/lib/dates.ts | 15 + .../ClientApp/src/lib/identifiers.ts | 18 + .../src/lib/mapping/MappingEditorContext.tsx | 35 + .../__tests__/scribanGeneratorOutput.test.ts | 256 +++++ .../__tests__/scribanRootArray.test.ts | 396 ++++++++ .../__tests__/scribanRoundTrip.test.ts | 215 ++++ .../src/lib/mapping/arrayMappingHelpers.ts | 140 +++ .../src/lib/mapping/fixedItemHelpers.ts | 183 ++++ .../src/lib/mapping/mappingEditorActions.ts | 102 ++ .../src/lib/mapping/mappingEditorReducer.ts | 369 +++++++ .../src/lib/mapping/mappingModeDefaults.ts | 10 + .../src/lib/mapping/mappingPreview.ts | 57 ++ .../src/lib/mapping/mappingTreeUtils.ts | 70 ++ .../ClientApp/src/lib/mapping/mappingUtils.ts | 18 + .../src/lib/mapping/scribanGenerator.ts | 747 ++++++++++++++ .../src/lib/mapping/scribanParser.ts | 323 ++++++ .../ClientApp/src/lib/mapping/types.ts | 96 ++ SW.Bitween.Web/ClientApp/src/lib/match.ts | 24 + SW.Bitween.Web/ClientApp/src/lib/returnTo.ts | 60 ++ SW.Bitween.Web/ClientApp/src/lib/schedules.ts | 28 + SW.Bitween.Web/ClientApp/src/main.tsx | 30 + SW.Bitween.Web/ClientApp/src/nav.ts | 91 ++ .../ClientApp/src/pages/PlaceholderPage.tsx | 41 + .../ClientApp/src/pages/ProfilePage.tsx | 203 ++++ .../pages/api-gateways/ApiGatewayNewPage.tsx | 90 ++ .../src/pages/api-gateways/ApiGatewayPage.tsx | 224 +++++ .../api-gateways/AttachPartnerWizard.tsx | 162 +++ .../pages/api-gateways/EditAttachmentPage.tsx | 127 +++ .../ClientApp/src/pages/auth/AcceptInvite.tsx | 131 +++ .../ClientApp/src/pages/auth/AuthLayout.tsx | 50 + .../src/pages/auth/ForgotPassword.tsx | 80 ++ .../ClientApp/src/pages/auth/Login.tsx | 139 +++ .../src/pages/auth/ResetPassword.tsx | 69 ++ .../src/pages/bus-gateways/AddRouteWizard.tsx | 191 ++++ .../pages/bus-gateways/BusGatewayNewPage.tsx | 117 +++ .../src/pages/bus-gateways/BusGatewayPage.tsx | 214 ++++ .../src/pages/bus-gateways/EditRoutePage.tsx | 173 ++++ .../global-values/GlobalValueSetPage.tsx | 185 ++++ .../global-values/GlobalValueSetsPage.tsx | 176 ++++ .../InformationTypeNewPage.tsx | 133 +++ .../information-types/InformationTypePage.tsx | 311 ++++++ .../InformationTypesPage.tsx | 137 +++ .../pages/integrations/IntegrationNewPage.tsx | 211 ++++ .../pages/integrations/IntegrationPage.tsx | 592 +++++++++++ .../pages/integrations/IntegrationsPage.tsx | 512 ++++++++++ .../src/pages/notifiers/NotifierPage.tsx | 356 +++++++ .../src/pages/notifiers/NotifiersPage.tsx | 192 ++++ .../src/pages/partners/PartnerNewPage.tsx | 67 ++ .../src/pages/partners/PartnerPage.tsx | 355 +++++++ .../src/pages/partners/PartnersPage.tsx | 110 +++ .../src/pages/retry-policies/GroupDialog.tsx | 349 +++++++ .../retry-policies/RetryPoliciesPage.tsx | 166 ++++ .../pages/retry-policies/RetryPolicyPage.tsx | 315 ++++++ .../scheduled-jobs/NewScheduledJobWizard.tsx | 231 +++++ .../src/pages/settings/SettingsPage.tsx | 239 +++++ .../ClientApp/src/pages/team/InviteDialog.tsx | 107 ++ .../ClientApp/src/pages/team/MemberDrawer.tsx | 293 ++++++ .../ClientApp/src/pages/team/MembersTab.tsx | 182 ++++ .../ClientApp/src/pages/team/RoleEditor.tsx | 360 +++++++ .../ClientApp/src/pages/team/RolesTab.tsx | 68 ++ .../ClientApp/src/pages/team/TeamPage.tsx | 72 ++ .../pages/work-groups/WorkGroupNewPage.tsx | 112 +++ .../src/pages/work-groups/WorkGroupPage.tsx | 189 ++++ .../src/pages/work-groups/WorkGroupsPage.tsx | 187 ++++ SW.Bitween.Web/ClientApp/src/router.tsx | 380 ++++++++ SW.Bitween.Web/ClientApp/tsconfig.app.json | 28 + SW.Bitween.Web/ClientApp/tsconfig.json | 8 + .../ClientApp/tsconfig.mapping.json | 32 + SW.Bitween.Web/ClientApp/tsconfig.node.json | 23 + SW.Bitween.Web/ClientApp/vite.config.ts | 17 + SW.Bitween.Web/ClientApp/vitest.config.ts | 12 + SW.Bitween.Web/ClientApp/yarn.lock | 917 +++++++++++++++++ 144 files changed, 24683 insertions(+) create mode 100644 SW.Bitween.Web/ClientApp/.gitignore create mode 100644 SW.Bitween.Web/ClientApp/.oxlintrc.json create mode 100644 SW.Bitween.Web/ClientApp/README.md create mode 100644 SW.Bitween.Web/ClientApp/index.html create mode 100644 SW.Bitween.Web/ClientApp/package.json create mode 100644 SW.Bitween.Web/ClientApp/public/brand/BitweenFull-light.svg create mode 100644 SW.Bitween.Web/ClientApp/public/brand/BitweenFull.svg create mode 100644 SW.Bitween.Web/ClientApp/public/brand/BitweenIcon.png create mode 100644 SW.Bitween.Web/ClientApp/public/brand/BitweenIcon.svg create mode 100644 SW.Bitween.Web/ClientApp/public/favicon.svg create mode 100644 SW.Bitween.Web/ClientApp/public/icons.svg create mode 100644 SW.Bitween.Web/ClientApp/src/api/client.ts create mode 100644 SW.Bitween.Web/ClientApp/src/api/index.ts create mode 100644 SW.Bitween.Web/ClientApp/src/api/mock/adapters.ts create mode 100644 SW.Bitween.Web/ClientApp/src/api/mock/configClient.ts create mode 100644 SW.Bitween.Web/ClientApp/src/api/mock/derive.ts create mode 100644 SW.Bitween.Web/ClientApp/src/api/mock/integrationsClient.ts create mode 100644 SW.Bitween.Web/ClientApp/src/api/mock/mockClient.ts create mode 100644 SW.Bitween.Web/ClientApp/src/api/mock/seed.ts create mode 100644 SW.Bitween.Web/ClientApp/src/api/mock/seedConfig.ts create mode 100644 SW.Bitween.Web/ClientApp/src/api/mock/settingsClient.ts create mode 100644 SW.Bitween.Web/ClientApp/src/api/mock/store.ts create mode 100644 SW.Bitween.Web/ClientApp/src/api/permissions.ts create mode 100644 SW.Bitween.Web/ClientApp/src/api/types.ts create mode 100644 SW.Bitween.Web/ClientApp/src/auth/SessionContext.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/auth/guards.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/components/config/AdapterConfig.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/components/config/MatchExpressionEditor.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/components/config/ScheduleEditor.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/components/config/pickers.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/components/config/shared.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/components/config/wizard.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/components/layout/AppShell.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/components/layout/DemoSwitcher.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/components/layout/PageHeader.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/components/mapper/ArrayMappingModal/ArrayMappingFieldRow.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/components/mapper/ArrayMappingModal/ArrayMappingModal.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/components/mapper/ArrayMappingModal/useArrayMappingModal.ts create mode 100644 SW.Bitween.Web/ClientApp/src/components/mapper/ConnectionCanvas.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/components/mapper/FixedStringInput.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/components/mapper/GlobalSetSelector.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/components/mapper/LeafValueInput.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/components/mapper/LivePreview.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/components/mapper/LookupDictionaryPanel.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/components/mapper/ManualEditor.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/components/mapper/MappingEditor.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/components/mapper/MappingEditorToolbar.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/components/mapper/ModeToggleButtons.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/components/mapper/OutputTree/ArrayInnerField.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/components/mapper/OutputTree/FixedItemPanel.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/components/mapper/OutputTree/NormalLeaf.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/components/mapper/OutputTree/OutputBranch.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/components/mapper/OutputTree/OutputLeaf.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/components/mapper/OutputTree/OutputTree.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/components/mapper/OutputTree/PrimitiveArrayLeaf.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/components/mapper/OutputTree/useNormalLeaf.ts create mode 100644 SW.Bitween.Web/ClientApp/src/components/mapper/OutputTree/usePrimitiveArrayLeaf.ts create mode 100644 SW.Bitween.Web/ClientApp/src/components/mapper/SourceTree.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/components/mapper/data.ts create mode 100644 SW.Bitween.Web/ClientApp/src/components/mapper/useKeyboardShortcuts.ts create mode 100644 SW.Bitween.Web/ClientApp/src/components/mapper/useMappingEditorLoader.ts create mode 100644 SW.Bitween.Web/ClientApp/src/components/mapper/useSave.ts create mode 100644 SW.Bitween.Web/ClientApp/src/components/ui/Avatar.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/components/ui/CopyField.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/components/ui/KeyValueEditor.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/components/ui/Panel.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/components/ui/ReturnBanner.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/components/ui/basics.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/components/ui/forms.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/components/ui/overlays.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/index.css create mode 100644 SW.Bitween.Web/ClientApp/src/lib/colorScale.ts create mode 100644 SW.Bitween.Web/ClientApp/src/lib/dates.ts create mode 100644 SW.Bitween.Web/ClientApp/src/lib/identifiers.ts create mode 100644 SW.Bitween.Web/ClientApp/src/lib/mapping/MappingEditorContext.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/lib/mapping/__tests__/scribanGeneratorOutput.test.ts create mode 100644 SW.Bitween.Web/ClientApp/src/lib/mapping/__tests__/scribanRootArray.test.ts create mode 100644 SW.Bitween.Web/ClientApp/src/lib/mapping/__tests__/scribanRoundTrip.test.ts create mode 100644 SW.Bitween.Web/ClientApp/src/lib/mapping/arrayMappingHelpers.ts create mode 100644 SW.Bitween.Web/ClientApp/src/lib/mapping/fixedItemHelpers.ts create mode 100644 SW.Bitween.Web/ClientApp/src/lib/mapping/mappingEditorActions.ts create mode 100644 SW.Bitween.Web/ClientApp/src/lib/mapping/mappingEditorReducer.ts create mode 100644 SW.Bitween.Web/ClientApp/src/lib/mapping/mappingModeDefaults.ts create mode 100644 SW.Bitween.Web/ClientApp/src/lib/mapping/mappingPreview.ts create mode 100644 SW.Bitween.Web/ClientApp/src/lib/mapping/mappingTreeUtils.ts create mode 100644 SW.Bitween.Web/ClientApp/src/lib/mapping/mappingUtils.ts create mode 100644 SW.Bitween.Web/ClientApp/src/lib/mapping/scribanGenerator.ts create mode 100644 SW.Bitween.Web/ClientApp/src/lib/mapping/scribanParser.ts create mode 100644 SW.Bitween.Web/ClientApp/src/lib/mapping/types.ts create mode 100644 SW.Bitween.Web/ClientApp/src/lib/match.ts create mode 100644 SW.Bitween.Web/ClientApp/src/lib/returnTo.ts create mode 100644 SW.Bitween.Web/ClientApp/src/lib/schedules.ts create mode 100644 SW.Bitween.Web/ClientApp/src/main.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/nav.ts create mode 100644 SW.Bitween.Web/ClientApp/src/pages/PlaceholderPage.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/pages/ProfilePage.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewayNewPage.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewayPage.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/pages/api-gateways/AttachPartnerWizard.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/pages/api-gateways/EditAttachmentPage.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/pages/auth/AcceptInvite.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/pages/auth/AuthLayout.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/pages/auth/ForgotPassword.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/pages/auth/Login.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/pages/auth/ResetPassword.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/pages/bus-gateways/AddRouteWizard.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/pages/bus-gateways/BusGatewayNewPage.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/pages/bus-gateways/BusGatewayPage.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/pages/bus-gateways/EditRoutePage.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/pages/global-values/GlobalValueSetPage.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/pages/global-values/GlobalValueSetsPage.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/pages/information-types/InformationTypeNewPage.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/pages/information-types/InformationTypePage.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/pages/information-types/InformationTypesPage.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/pages/integrations/IntegrationNewPage.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/pages/integrations/IntegrationPage.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/pages/integrations/IntegrationsPage.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/pages/notifiers/NotifierPage.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/pages/notifiers/NotifiersPage.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/pages/partners/PartnerNewPage.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/pages/partners/PartnerPage.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/pages/partners/PartnersPage.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/pages/retry-policies/GroupDialog.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/pages/retry-policies/RetryPoliciesPage.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/pages/retry-policies/RetryPolicyPage.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/pages/scheduled-jobs/NewScheduledJobWizard.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/pages/settings/SettingsPage.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/pages/team/InviteDialog.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/pages/team/MemberDrawer.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/pages/team/MembersTab.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/pages/team/RoleEditor.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/pages/team/RolesTab.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/pages/team/TeamPage.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/pages/work-groups/WorkGroupNewPage.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/pages/work-groups/WorkGroupPage.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/pages/work-groups/WorkGroupsPage.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/router.tsx create mode 100644 SW.Bitween.Web/ClientApp/tsconfig.app.json create mode 100644 SW.Bitween.Web/ClientApp/tsconfig.json create mode 100644 SW.Bitween.Web/ClientApp/tsconfig.mapping.json create mode 100644 SW.Bitween.Web/ClientApp/tsconfig.node.json create mode 100644 SW.Bitween.Web/ClientApp/vite.config.ts create mode 100644 SW.Bitween.Web/ClientApp/vitest.config.ts create mode 100644 SW.Bitween.Web/ClientApp/yarn.lock diff --git a/SW.Bitween.Web/ClientApp/.gitignore b/SW.Bitween.Web/ClientApp/.gitignore new file mode 100644 index 00000000..a547bf36 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/SW.Bitween.Web/ClientApp/.oxlintrc.json b/SW.Bitween.Web/ClientApp/.oxlintrc.json new file mode 100644 index 00000000..6fa991da --- /dev/null +++ b/SW.Bitween.Web/ClientApp/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/SW.Bitween.Web/ClientApp/README.md b/SW.Bitween.Web/ClientApp/README.md new file mode 100644 index 00000000..8a63c8eb --- /dev/null +++ b/SW.Bitween.Web/ClientApp/README.md @@ -0,0 +1,60 @@ +# Bitween UI — redesign prototype + +Clean-slate redesign of the Bitween admin UI, hosted by `SW.Bitween.Web`. **Runs entirely on +mock data** — it never calls the API, even when served by it. Sub-phase 1 covers auth, dynamic +RBAC, user profile, and team management; other areas exist as permission-gated placeholder +pages so role gating can be demonstrated across the whole navigation. + +## Running it + +**UI-only (fastest loop):** + +```bash +npm install +npm run dev # → http://localhost:5173/bitween/ +``` + +**Backend-served (how it deploys):** + +```bash +npm run build # outputs into ../wwwroot with base /bitween/ +dotnet run --project .. --launch-profile SW.Bitween.Web.Local +# → https://localhost:7155/bitween/ +``` + +`dotnet publish` runs the npm build automatically (skip with `-p:SkipClientBuild=true`); +the root `Dockerfile` builds the UI in its own node stage. The app lives under the host's +`/bitween` path base — runtime URLs must use `import.meta.env.BASE_URL` (see the project +instructions in `.github/instructions/`). + +Sign in with any prototype account listed on the login page (password `bitween`), or use the +one-click persona buttons. The floating **Demo** pill (bottom right) switches the signed-in +person at any time and resets the demo data. + +## Stack + +React 19 · TypeScript · Vite · Tailwind v4 · react-router v8 · TanStack Query · lucide-react. + +Brand carried over from the existing UI: the crimson ramp from `Bitween-UI/tailwind.config.js` +(verbatim, as `--color-crimson-*`) and the logo (`public/brand/`). Neutrals (`--color-ink-*`) +are derived from the logo's wordmark ink `#372f2e`. Tokens live in `src/index.css`. + +## Architecture — the parts that matter + +- **`src/api/` is the only data layer.** Components import `api` from `src/api/index.ts`, + which today points at `src/api/mock/mockClient.ts` (a localStorage-backed fake with + simulated latency). Implementing `ApiClient` (`src/api/client.ts`) over HTTP and changing + that one export swaps the whole app onto the real backend. +- **`src/api/permissions.ts` is the permission catalog** — every gated area and action. + Roles are named sets of these keys; a user's permissions are the union of their roles'. +- **`src/nav.ts` is the information architecture.** Sidebar, role-editor access preview and + post-login redirect all derive from it, each filtered by the session's permissions. +- **Gating hides, never dims**: `` for actions, `` for routes + (`src/auth/guards.tsx`). Unauthorized pages get an explanatory access-denied screen. +- **Everything is URL-addressable**: tabs are routes, the member drawer is a route, search + and filters are query params, dialogs are query params. +- Deployment assumptions (to revisit): served at root path, single project, client-side + routing compatible with being backend-served later. + +Anything the prototype needed that the real backend can't do yet is logged in +`Bitween-api/BACKEND_CAPABILITIES_NEEDED.md`. diff --git a/SW.Bitween.Web/ClientApp/index.html b/SW.Bitween.Web/ClientApp/index.html new file mode 100644 index 00000000..fb83c6a2 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/index.html @@ -0,0 +1,13 @@ + + + + + + + Bitween + + +
+ + + diff --git a/SW.Bitween.Web/ClientApp/package.json b/SW.Bitween.Web/ClientApp/package.json new file mode 100644 index 00000000..22ca51a5 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/package.json @@ -0,0 +1,36 @@ +{ + "name": "bitween-ui-next", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "preview": "vite preview", + "test": "vitest run" + }, + "dependencies": { + "@fontsource-variable/instrument-sans": "^5.2.8", + "@fontsource-variable/jetbrains-mono": "^5.2.8", + "@monaco-editor/react": "^4.7.0", + "@tailwindcss/vite": "^4.3.2", + "@tanstack/react-query": "^5.101.2", + "immer": "^11.1.8", + "lucide-react": "^1.24.0", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-router": "^8.2.0", + "tailwindcss": "^4.3.2" + }, + "devDependencies": { + "@types/node": "^24.13.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "oxlint": "^1.71.0", + "typescript": "~6.0.2", + "vite": "^8.1.1", + "vitest": "^4.1.7" + } +} diff --git a/SW.Bitween.Web/ClientApp/public/brand/BitweenFull-light.svg b/SW.Bitween.Web/ClientApp/public/brand/BitweenFull-light.svg new file mode 100644 index 00000000..7fc64cc9 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/public/brand/BitweenFull-light.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/SW.Bitween.Web/ClientApp/public/brand/BitweenFull.svg b/SW.Bitween.Web/ClientApp/public/brand/BitweenFull.svg new file mode 100644 index 00000000..1546a5c1 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/public/brand/BitweenFull.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/SW.Bitween.Web/ClientApp/public/brand/BitweenIcon.png b/SW.Bitween.Web/ClientApp/public/brand/BitweenIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..1f22ce3f02c5be4a4b23ccdc2d015b4abaedd847 GIT binary patch literal 5259 zcmeHL`9IX%+aDU58B3z6FqX3AW1`R^#E9FL3}%M0gd%G<8T)Q56@``=Da&LXW6c^x zs5>KhNv+{PO)1zCWDTIoCPoI@kN0b6xMV+{Ruq7D7lMAP|U< zsfnRA1i}M=DHz5N)(i%-^1%%5Z$czNAcyw^mWOI*l;Kk!i514&VC%EUycr;|F^^c zufu||s#X2iImnC*Sz#y1J#lQ*Uqf&vZ&VupuN%=QFmzFD+mVHr{9*$btFu-#lV_=o; z{Xj(0)c+srIE&|x=M1q&+#;qS4v0#X-2t@Ba2(BGSpHkk)O@>-;$dT=G)MLh8sE)svfd1z^*St<|b&Tv{XRG9xWCmM|UAA;jgc|UlFxO@29w` zONIJ>&A8d!xGS8O@(!rQ&S+(fudFGIMp^w{scc=oz12l-{@78wS(oN4(MJ$=Tb23;nM;d^-1 zlA1UMFI(D=({F5o64qe=O#(`|4QBS!t$EWe3SVL9i}1uPGD3D2tY{WSe+ zbP|-h4l_~gBMZCNpvWGSvsLyxHfqCG9Mlbqns$DMa*U_({x+e;qtt2=XuN@oNLJ(Q zk%A^sD3aF4!(#0rBYILeW+J%Q=)LL@?knB07`}ufnZA1Uq0KNr7HwQD)X{I9PUCH_ zQMwzfVU8gqU^d27vSR&L5uE<#OX!QWVmSS^Bx|R-m6Ed{1mP%gSI-HeEm)K`9b*I; z%Yf}K0)I@66HX1n0b2G!iY?I)1WW&aTlkQF1ltpTg!=WWa|1iVA~?uOt!0%M4wAJpcN&U}eS@7T z5diaAvAdH*FjA@6p4!R}X}ncs`PY5pz&x2zb(9V%8_$SQk{FdDBlHgZ^~&(;jZq%(OQ?>tX8)7u3coH{4N{lNK)x$lmd}6)}<}(Q!hO z9ewXBD)n=I4O4R7eOy|;Jhfoa8R%x|BIi5;MM|7WE!HTRl8#b-7m%-p{4C9~9pB1n z9lxQYTd|p_BWb9+%k#W{H3xSk24z{2&Z|Hf$bk-EQWcaKi;O#FO)xT|?SAr1cEUjx zA?lhap^jVneCDRhx*zw3&i2{V;#X_C{#m5lY|>rLh`ZBlU1uZgQ>67%uF~Yl=0x-% zv&qR)VNSRM9H{L+o0D^QyyTTOR{&2ayfia(SMW|0-3c@=UCo6!i=$6j$&K)c@NOI4GnPGV(-`$pMX=?h~ z%q;I`fMW%uTO~E1`AEYmBu8*t5$T0r9g+-5%H@os^jPxS#oy!>ylS+gtKC~aLoAO& zdVZ+5>*unE<)@&?Dg~|NkX*%xqQAxQNDSYsEX&QK@@u%ZEgp)*@(!w)*p=6l@E|&c zBW3DyI6N4ew_&Aq!3+lIx^4+9`-S@LM6fFlTt#}2`_4@BKX*C)=PHKb`5 z@nqXht6p8vuklsT^9@7^vqF(&{(lBVKA1bb5SF_jl#;^>&g)eA-4Ce!q>h`or#Gph zIDLyBw+_!kN0n>0CpDLUB)B+q&V)*)xu^f%#c(5QJWM;Kpdc|xUaqtH5@bp$z zpBEewH*9va2m(V&SWZAIBL)mXk<7~K7O2iU4I9v?(0v6O_xNeae_M2B%v;Yq+2I3w zKN{hyjf`f!oshxsP5pYmChPfg2|V)-9PY3gE8e)WX78rD3j_4iJ|>j{03ZvkZvL6i zN}-(EGR5*F-x;6I{B|R8U}lF0)V>-7dzg%XC4OTtUd-!&PYDmY&eexHzC=~Ml1Kvs}V zjX`h6_8fNH(H+XVh~@cqnAk*!r-zIcgTCP(sS#X0UkZS1cC#cI^dw$+CE%i<(~Hgg z+n_R<%3y3x2ZF7gl&3R6dU+sb*Jr_#exLp*$C~6aM|41t2_4oKvO!&>WRpK={FphC z1+o}eo92De99;0YdqT`2kR~6M;qWO>Ey3X)P-ymoQQ!_gx+rAyJkb+7m$VZ*+qRIIsL~wN3#rC+6R&Fw+QzfRrUM6r8Oyt>$F4D zWi~ZB0&#gyEP2Z6-y2MOe_i00GCq94*HaMp6JogW&~s*FCNo>lCd4eZ-sFFa6YBPJ zb%mWi*QY@QuEYaYrr6A|->G`XF1K|;g5BNwuYIC|TD|!pFaX*$glp0`m($v#WZ>Zi zL_Hg-2_E>kNo@0h1iLEJ50>isglwbC^=e}q!}LjAfz)VE9&mxaV@gwK5*;??n29I% zih^_UgFZw>_jLs_0R2WxzXo@ZJM5A5jdtaujDfo{eLiAxnpPb+nU;)rTK!ugBG+`9 zw$Yq9(0fZ&j01aEs5ZEXzkH7VSJzAGTh!sr<)i++xz(DK=vPYXx-1J*iYI8*D|hsK3N_o^=ghfQpK4J! zYHGv{^sY#H$Xiu*e%||VBbMv>-Z`}DW)|tjMvM-E;pX_|Fg2#9`+J7hHKPf^ZX2QF zIgf79BCCQM-!2AT#S$HMw8HT{W=T5bb9tfG=yC+3H2iW3@U!Kah@g)}U;_|Or_cW` zm&)bYE>JkB8CsI#*7JaN|A>l@4X1nR%%MPS&G4vz0N2a1T7r zmI(kUQGjtJ!d@_Mv}Y>bgmBPwhRn$u8pu1CkLdaSZi=#2&+)4e1eBZ6r&-!bo4nX| z|5J6rQ#v!I?+M5!RU1DO>sV6Ec(Yi2Oi1R>w-bF1>|5q1N#s=PVLm zFV>|dTpr*y3)kpuxwifK1MU7vzE0OVr7jDax^<&NOPq$odA9ptQ8H7!Wi#Wft0NCm z`_vAJ+NVbKL&6x!8E5Dq^SA%{}Sfz#8ukoMk{u@hy6Vo6Q3vU-!6NsUSTetG}@Xtl6ha7UhvI*aVo=IY6Jhdt>oKv zb+6?cI$h0XD%MtE&h^p6xn2)%DI-(oMpt|C_! zUCPvnxiTZYT92gUGwg3y8HifXx`c8%8O5@b&w{Y;s|ZiKoA)PBToZ6bABT6^od95= zFZESRIZP!UR&vo}m|*kw04$+#^W{H`V z-XdJp%(q=#mMgD5E<=Si)#@4_eGEB&o))dFLT8rIQOyj4``Y9zufh>o&YY=UayoP2 zD)Gn!vx1IVmLG0P=OkZ%Bkuv6cWdM9hLhkc&(?h>$=w!ri2>}ytGpc(+Uj<~=>Dh} zRX9>d>D^=%+T!NF`Ff^0eGz8i}DzA?K6z6_E zZQQF++XmPNbi4_tz{*esdr!&JfRhir8NDz1gZS{a^F_aol)N=r!7m=JIgpvSp`Tl8 zy%{tzTcSWmnYT8r`88C!qi_cS;;58~{tc*k9pJpA;4J`ztR6X+v2Q;EeEj&S#|~_K zvsynId4Y?2Vz#h!yi}Y1T;TH^b|N*Bx+0$?ympaQ(fcB@(_0AWkfrmB!ON{Y;|qF8 zX6?71XmpkMR*|xY;+fA5VI^e4=xmNf7-%fMh~gQ*;5V*dQC0ZncE zV{=a@Q&RL|KI3O3klwT;XSVG(WzBI7HPRHv-9aaKP=|cyU(c=<>b1!Sy|MJ@I`nBq zXcraE8p<&9LDNy&fUJDoYGLnA&kxBUQiOKI`uBAWiTqgaXw`)Ui~=O}*=WFCJgp08j#Z`mY`BB2kKc=p3u^*3Z1y=FFez0YmX-qtBz-5%fEc zi6v-8Ml^iTHOBNK0=(4$%G^YK5zGWkh34Cq@K8XRj&kLDs6bB-c*;kSD~omFJ7lf; zh8B>3tSXniz)Y0DrB?AKBTg4MLP$8DlTDBxr{P)!S?uGQVe3}A8EQGZeOjwzCc;h` zUP8;wlA7)H8Ix7t7WL5ZOWHfrbpYLmq9^bd}w?c1+JuR?}H*F3@ zyUS_Z+0;xezMD(BT*O9i_x|`amVxpSj34#U{#_whE(omp^KWWd1Rvi2d#z2e+R6N_Aj3ZTJK|=%#Api{5+;?2*0Igef|{<@lnk%W3_!`VDBKA^d>+Ax%MM#b0q2Y z9K#ShA>S1&L%4}~>Eao31b?7w2?b-2f2=*SQ5=3&cu%v__+{Msew@So9oGK1#H?$g zSjE%(Gw#xc_B-6D;^D#bk{R;x^fM0;DXnBV8q)|%xyU;sWo}Rdqa#Fc$fq~mQ}rY{ z0z7yjGUG}>#;;p9^Xu7(&;uchfFYJ;__IpbooFJX;!DzMofjZs!S4qNS;^e78`c;6 z4|Zxh8=W-0tVU*Fs?on)xp9DbIng`JnAxlHkRDz6aQGG;0%T zl3&5l(OM4Xtny!2A{5nB*$U+MM6uWN4~Me|LU&LOoxD8AK= z9%xBUY3pSvmiSBDXi&Wf#iy9~a}}O%@Mk2G;6*C%Vt8A>Fj)IO-OHI#ZbB|S-T{34 z(LqV*OIVCsIZ7A2&mSdfuh;1uf+V<@CjN*?ec*rPMY1jwDSa;~O4M$;WniW-rtZ~0 sQ--2CUbZE#E*W_J6_gMGe%9=%Ki|esyRoqX{w_jHjV>8h7`TxC2M=SZegFUf literal 0 HcmV?d00001 diff --git a/SW.Bitween.Web/ClientApp/public/brand/BitweenIcon.svg b/SW.Bitween.Web/ClientApp/public/brand/BitweenIcon.svg new file mode 100644 index 00000000..b2e1faf3 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/public/brand/BitweenIcon.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/SW.Bitween.Web/ClientApp/public/favicon.svg b/SW.Bitween.Web/ClientApp/public/favicon.svg new file mode 100644 index 00000000..6893eb13 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/SW.Bitween.Web/ClientApp/public/icons.svg b/SW.Bitween.Web/ClientApp/public/icons.svg new file mode 100644 index 00000000..e9522193 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SW.Bitween.Web/ClientApp/src/api/client.ts b/SW.Bitween.Web/ClientApp/src/api/client.ts new file mode 100644 index 00000000..7c37a1ad --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/api/client.ts @@ -0,0 +1,266 @@ +import type { + AdapterInfo, + AdapterKind, + ApiGateway, + ApiGatewayDetail, + ApiGatewayRow, + BusGateway, + BusGatewayDetail, + BusGatewayRow, + GlobalValuesSetDetail, + GlobalValuesSetRow, + InformationType, + InformationTypeDetail, + InformationTypeFormat, + InformationTypeRow, + Integration, + IntegrationDetail, + IntegrationInfo, + IntegrationRow, + IntegrationType, + Invite, + MatchGroup, + Notifier, + NotifierChannel, + NotifierDetail, + Partner, + PartnerDetail, + PartnerRow, + PermissionArea, + PermissionKey, + RetryGroup, + RetryPolicy, + RetryPolicyDetail, + RetryPolicyListRow, + RetryResultType, + RetryTestAttempt, + Role, + Schedule, + Session, + SettingRow, + User, + WorkGroup, + WorkGroupDetail, + WorkGroupRow, +} from "./types"; + +/** + * The single data-access contract the UI is written against. + * The mock implementation lives in ./mock; a real HTTP client can + * replace it later without touching any component. + */ +export interface ApiClient { + // — session — + getSession(): Promise; + login(email: string, password: string): Promise; + loginWithMicrosoft(): Promise; + logout(): Promise; + + // — self service — + updateProfile(changes: { displayName?: string; phone?: string }): Promise; + changePassword(currentPassword: string, newPassword: string): Promise; + requestPasswordReset(email: string): Promise<{ resetLink: string }>; + resetPassword(token: string, newPassword: string): Promise; + + // — members — + listUsers(): Promise; + getUser(id: string): Promise; + inviteUser(input: { email: string; roleIds: string[] }): Promise; + getInviteForUser(userId: string): Promise; + resendInvite(userId: string): Promise; + revokeInvite(userId: string): Promise; + getInvite(token: string): Promise; + acceptInvite(token: string, input: { displayName: string; password: string }): Promise; + updateUserRoles(id: string, roleIds: string[]): Promise; + setUserDisabled(id: string, disabled: boolean): Promise; + deleteUser(id: string): Promise; + adminResetPassword(id: string): Promise<{ resetLink: string }>; + + // — roles — + getPermissionCatalog(): Promise; + listRoles(): Promise; + getRole(id: string): Promise; + createRole(input: { name: string; description: string; permissions: PermissionKey[] }): Promise; + updateRole( + id: string, + input: { name: string; description: string; permissions: PermissionKey[] }, + ): Promise; + deleteRole(id: string): Promise; + + // — partners — + listPartners(): Promise; + getPartner(id: number): Promise; + createPartner(input: { name: string }): Promise; + updatePartner( + id: number, + changes: { name?: string; adapterProperties?: Record }, + ): Promise; + deletePartner(id: number): Promise; + /** Returns the full key exactly once; afterwards only a prefix is ever shown. */ + addPartnerCredential(id: number, name: string): Promise<{ key: string }>; + revokePartnerCredential(id: number, name: string): Promise; + + // — information types — + listInformationTypes(): Promise; + getInformationType(id: number): Promise; + createInformationType(input: { + name: string; + code: string; + format: InformationTypeFormat; + busEnabled?: boolean; + busMessageTypeName?: string; + }): Promise; + updateInformationType( + id: number, + changes: Omit, + ): Promise; + deleteInformationType(id: number): Promise; + + // — global values — + listValueSets(): Promise; + getValueSet(id: string): Promise; + createValueSet(input: { + id: string; + name: string; + values: Record; + }): Promise; + updateValueSet( + id: string, + changes: { name: string; values: Record }, + ): Promise; + deleteValueSet(id: string): Promise; + + // — integrations (light summaries; cache aggressively) — + listIntegrations(): Promise; + + // — integrations — + listIntegrationRows(): Promise; + getIntegration(id: number): Promise; + /** Only Receiving / GatewayApiCall / BusGateway — always via the entry-point wizards. */ + createIntegration(input: { + type: IntegrationType; + name: string; + informationTypeId: number; + receiverId?: string | null; + receiverProperties?: Record; + validatorId?: string | null; + validatorProperties?: Record; + mapperId?: string | null; + mapperProperties?: Record; + handlerId?: string | null; + handlerProperties?: Record; + schedules?: Schedule[]; + retryPolicyId?: number | null; + enabled?: boolean; + }): Promise; + updateIntegration( + id: number, + changes: Partial< + Pick< + Integration, + | "name" + | "enabled" + | "workGroupId" + | "retryPolicyId" + | "receiverId" + | "receiverProperties" + | "validatorId" + | "validatorProperties" + | "mapperId" + | "mapperProperties" + | "handlerId" + | "handlerProperties" + | "matchExpression" + | "schedules" + | "responseIntegrationId" + | "responseMessageTypeName" + > + >, + ): Promise; + deleteIntegration(id: number): Promise; + /** Toggles paused: paused integrations accept work but hold it. */ + pauseIntegration(id: number): Promise; + receiveNow(id: number): Promise; + listAdapters(kind: AdapterKind): Promise; + + // — work groups — + listWorkGroups(): Promise; + getWorkGroup(id: number): Promise; + createWorkGroup(input: { + name: string; + busMessageName: string; + prefetch: number; + priority: number; + }): Promise; + updateWorkGroup( + id: number, + changes: { name: string; busMessageName: string; prefetch: number; priority: number }, + ): Promise; + deleteWorkGroup(id: number): Promise; + + // — API gateways — + listApiGateways(): Promise; + getApiGateway(id: number): Promise; + createApiGateway(input: { name: string; urlName: string }): Promise; + updateApiGateway(id: number, changes: { name: string; urlName: string }): Promise; + deleteApiGateway(id: number): Promise; + attachGatewayPartner(id: number, input: { partnerId: number; integrationId: number }): Promise; + updateGatewayAttachment(id: number, input: { partnerId: number; integrationId: number }): Promise; + removeGatewayAttachment(id: number, partnerId: number): Promise; + + // — bus gateways — + listBusGateways(): Promise; + getBusGateway(id: number): Promise; + createBusGateway(input: { name: string; informationTypeId: number }): Promise; + updateBusGateway(id: number, changes: { name: string }): Promise; + deleteBusGateway(id: number): Promise; + addBusRoute( + id: number, + input: { integrationId: number; partnerId: number | null; matchExpression: MatchGroup | null }, + ): Promise; + updateBusRoute( + id: number, + routeId: number, + input: { integrationId: number; partnerId: number | null; matchExpression: MatchGroup | null }, + ): Promise; + removeBusRoute(id: number, routeId: number): Promise; + + // — retry policies — + listRetryPolicies(): Promise; + getRetryPolicy(id: number): Promise; + createRetryPolicy(input: { name: string }): Promise; + updateRetryPolicy(id: number, changes: { name: string; groups: RetryGroup[] }): Promise; + deleteRetryPolicy(id: number): Promise; + /** Dry-runs draft groups against a simulated failure over N attempts. */ + testRetryPolicy(input: { + groups: RetryGroup[]; + resultType: RetryResultType; + content: string; + attempts: number; + }): Promise; + + // — settings — + listSettings(): Promise; + /** `value: null` resets the setting back to its default. */ + updateSetting(key: string, value: string | null): Promise; + + // — notifiers — + listNotifierChannels(): Promise; + listNotifiers(): Promise; + getNotifier(id: number): Promise; + createNotifier(input: { name: string }): Promise; + updateNotifier(id: number, changes: Omit): Promise; + deleteNotifier(id: number): Promise; + /** Sends a test notification through a draft channel configuration. */ + testNotifier(input: { + channelId: string; + channelProperties: Record; + }): Promise<{ message: string }>; + + // — prototype-only affordances (not part of the future backend contract) — + demo: { + listPersonas(): Promise; + switchTo(userId: string): Promise; + reset(): Promise; + }; +} diff --git a/SW.Bitween.Web/ClientApp/src/api/index.ts b/SW.Bitween.Web/ClientApp/src/api/index.ts new file mode 100644 index 00000000..76ddcfd3 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/api/index.ts @@ -0,0 +1,11 @@ +import type { ApiClient } from "./client"; +import { mockClient } from "./mock/mockClient"; + +/** + * The swap point. Everything in the UI imports `api` from here. + * When the backend is ready, replace `mockClient` with an HTTP + * implementation of `ApiClient` — no component changes needed. + */ +export const api: ApiClient = mockClient; + +export * from "./types"; diff --git a/SW.Bitween.Web/ClientApp/src/api/mock/adapters.ts b/SW.Bitween.Web/ClientApp/src/api/mock/adapters.ts new file mode 100644 index 00000000..4da7d6ac --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/api/mock/adapters.ts @@ -0,0 +1,193 @@ +import type { AdapterInfo } from "../types"; + +/** + * The adapter catalog: native adapters (in-process, unversioned) and + * deployed packages (versioned). Mirrors the real backend's discovery + + * startup-value metadata; ids match the real adapter naming. + */ +export const ADAPTER_CATALOG: AdapterInfo[] = [ + // ——— receivers ——— + { + id: "NativeHttpReceiver", + kind: "receiver", + label: "HTTP endpoint", + native: true, + versions: [], + props: [ + { key: "url", optional: false, secret: false, description: "Endpoint polled for new documents. Tokens allowed." }, + { key: "method", optional: true, default: "GET", secret: false }, + { key: "headers", optional: true, secret: false, description: "One per line — Name: value." }, + { key: "authHeader", optional: true, secret: true, description: "Authorization header sent with each poll." }, + ], + }, + { + id: "NativeRebexFtpReceiver", + kind: "receiver", + label: "FTP folder", + native: true, + versions: [], + props: [ + { key: "host", optional: false, secret: false }, + { key: "port", optional: true, default: "21", secret: false }, + { key: "username", optional: false, secret: false }, + { key: "password", optional: false, secret: true }, + { key: "path", optional: true, default: "/", secret: false, description: "Folder watched for new files." }, + { key: "deleteAfterDownload", optional: true, default: "true", secret: false }, + ], + }, + { + id: "NativeS3Receiver", + kind: "receiver", + label: "Amazon S3 bucket", + native: true, + versions: [], + props: [ + { key: "bucket", optional: false, secret: false }, + { key: "region", optional: false, secret: false }, + { key: "accessKey", optional: false, secret: false }, + { key: "secretKey", optional: false, secret: true }, + { key: "prefix", optional: true, secret: false, description: "Only objects under this key prefix are picked up." }, + ], + }, + { + id: "NativeAzureBlobReceiver", + kind: "receiver", + label: "Azure Blob container", + native: true, + versions: [], + props: [ + { key: "connectionString", optional: false, secret: true }, + { key: "container", optional: false, secret: false }, + { key: "prefix", optional: true, secret: false }, + ], + }, + { + id: "SW.Infolink.Adapters.Receivers.Pop3", + kind: "receiver", + label: "POP3 mailbox", + native: false, + versions: ["1.2.0", "1.3.1"], + props: [ + { key: "host", optional: false, secret: false }, + { key: "port", optional: true, default: "995", secret: false }, + { key: "username", optional: false, secret: false }, + { key: "password", optional: false, secret: true }, + { key: "deleteAfterRead", optional: true, default: "true", secret: false }, + ], + }, + + // ——— handlers ——— + { + id: "NativeHttpHandler", + kind: "handler", + label: "HTTP endpoint", + native: true, + versions: [], + props: [ + { key: "url", optional: false, secret: false, description: "Where the document is sent. Tokens allowed." }, + { key: "method", optional: true, default: "POST", secret: false }, + { key: "headers", optional: true, secret: false, description: "One per line — Name: value. Tokens allowed." }, + { key: "authToken", optional: true, secret: true, description: "Bearer token attached to each request." }, + ], + }, + { + id: "NativeRebexFtpUploadHandler", + kind: "handler", + label: "FTP upload", + native: true, + versions: [], + props: [ + { key: "host", optional: false, secret: false }, + { key: "port", optional: true, default: "21", secret: false }, + { key: "username", optional: false, secret: false }, + { key: "password", optional: false, secret: true }, + { key: "path", optional: true, default: "/", secret: false }, + { key: "fileNameTemplate", optional: true, secret: false, description: "e.g. invoice-{id}.xml" }, + ], + }, + { + id: "NativeS3UploadHandler", + kind: "handler", + label: "Amazon S3 upload", + native: true, + versions: [], + props: [ + { key: "bucket", optional: false, secret: false }, + { key: "region", optional: false, secret: false }, + { key: "accessKey", optional: false, secret: false }, + { key: "secretKey", optional: false, secret: true }, + { key: "keyTemplate", optional: true, secret: false, description: "Object key pattern, e.g. orders/{date}/{id}.json" }, + ], + }, + { + id: "NativeAzureBlobUploadHandler", + kind: "handler", + label: "Azure Blob upload", + native: true, + versions: [], + props: [ + { key: "connectionString", optional: false, secret: true }, + { key: "container", optional: false, secret: false }, + { key: "blobNameTemplate", optional: true, secret: false }, + ], + }, + { + id: "SW.Infolink.Adapters.Handlers.Smtp", + kind: "handler", + label: "Email (SMTP)", + native: false, + versions: ["2.0.0", "2.1.4"], + props: [ + { key: "host", optional: false, secret: false }, + { key: "port", optional: true, default: "587", secret: false }, + { key: "from", optional: false, secret: false }, + { key: "to", optional: false, secret: false, description: "Comma-separated recipients. Tokens allowed." }, + { key: "password", optional: false, secret: true }, + { key: "subjectTemplate", optional: true, secret: false }, + ], + }, + + // ——— mappers ——— + { + id: "NativeJSONMapper", + kind: "mapper", + label: "Visual JSON mapping", + native: true, + versions: [], + // Configured through the mapping editor (deferred in this redesign). + props: [], + }, + { + id: "SW.Infolink.Adapters.Mappers.Liquid", + kind: "mapper", + label: "Liquid template", + native: false, + versions: ["1.4.0"], + props: [ + { key: "template", optional: false, secret: false, description: "Liquid template producing the output document." }, + ], + }, + { + id: "SW.Infolink.Adapters.Mappers.JsonToDelimited", + kind: "mapper", + label: "JSON → delimited file", + native: false, + versions: ["1.1.0"], + props: [ + { key: "columns", optional: false, secret: false, description: "Comma-separated JSON paths, one per output column." }, + { key: "delimiter", optional: true, default: ",", secret: false }, + ], + }, + + // ——— validators ——— + { + id: "SW.Infolink.Adapters.Validators.JsonSchema", + kind: "validator", + label: "JSON schema", + native: false, + versions: ["1.0.2"], + props: [ + { key: "schema", optional: false, secret: false, description: "Documents failing this schema are rejected before mapping." }, + ], + }, +]; diff --git a/SW.Bitween.Web/ClientApp/src/api/mock/configClient.ts b/SW.Bitween.Web/ClientApp/src/api/mock/configClient.ts new file mode 100644 index 00000000..84f3bfbf --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/api/mock/configClient.ts @@ -0,0 +1,689 @@ +import type { ApiClient } from "../client"; +import { + ApiRequestError, + type ExchangeRef, + type GlobalValuesSetRow, + type InformationType, + type Notifier, + type NotifierChannel, + type Partner, + type PartnerRow, + type RetryDelay, + type RetryGroup, + type RetryMatcher, + type RetryResultType, + type RetryTestAttempt, +} from "../types"; +import { matchSummary } from "../../lib/match"; +import { globalRefsOf, integrationInfoOf, partnerIdsOf, setupRefOf } from "./derive"; +import { type MockDb, delay, loadDb, saveDb } from "./store"; + +const fail = (code: string, message: string): never => { + throw new ApiRequestError(code, message); +}; + +const CODE_PATTERN = /^[A-Z][A-Z0-9_]{1,49}$/; +const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{1,49}$/; + +const currentUserName = (db: MockDb) => + db.users.find((u) => u.id === db.sessionUserId)?.displayName ?? "Unknown"; + +const exchangeDto = (db: MockDb, e: MockDb["exchanges"][number]): ExchangeRef => ({ + id: e.id, + partnerName: e.partnerId ? db.partners.find((p) => p.id === e.partnerId)?.name : undefined, + informationTypeCode: + db.informationTypes.find((t) => t.id === e.informationTypeId)?.code ?? "UNKNOWN", + status: e.status, + on: e.on, + documents: e.documents ? structuredClone(e.documents) : undefined, +}); + +const partnerUsedByCount = (db: MockDb, id: number) => + db.integrations.filter((s) => s.partnerId === id).length + + db.apiGateways.flatMap((g) => g.attachments).filter((a) => a.partnerId === id).length + + db.busGateways.flatMap((g) => g.routes).filter((r) => r.partnerId === id).length; + +const requirePartner = (db: MockDb, id: number): Partner => + db.partners.find((p) => p.id === id) ?? fail("NOT_FOUND", "This partner no longer exists."); + +const requireInfoType = (db: MockDb, id: number): InformationType => + db.informationTypes.find((t) => t.id === id) ?? + fail("NOT_FOUND", "This information type no longer exists."); + +const valueSetRow = (db: MockDb, set: MockDb["valueSets"][number]): GlobalValuesSetRow => ({ + ...structuredClone(set), + usedByCount: db.integrations.filter((s) => globalRefsOf(s).some((g) => g.setId === set.id)).length, +}); + +/** + * Delivery channels = notifier handler adapters. The real backend already + * discovers these; this list mirrors the adapters that ship today. + */ +const NOTIFIER_CHANNELS: NotifierChannel[] = [ + { + id: "sendgrid", + label: "Email (SendGrid)", + props: [ + { key: "apiKey", label: "SendGrid API key", placeholder: "SG.…" }, + { key: "from", label: "From address", placeholder: "bitween@company.example" }, + { key: "to", label: "Recipients (comma-separated)", placeholder: "ops@company.example" }, + ], + }, + { + id: "msteams", + label: "Microsoft Teams", + props: [ + { + key: "webhookUrl", + label: "Incoming webhook URL", + placeholder: "https://outlook.office.com/webhook/…", + }, + ], + }, +]; + +// ——— retry test evaluator (mirrors the backend's group semantics) ——— + +const matcherHits = (matcher: RetryMatcher, content: string): boolean => { + switch (matcher.type) { + case "contains": + return matcher.caseSensitive + ? content.includes(matcher.value) + : content.toLowerCase().includes(matcher.value.toLowerCase()); + case "regex": + try { + return new RegExp(matcher.pattern, matcher.flags).test(content); + } catch { + return false; + } + case "exceptionType": + return content.toLowerCase().includes(matcher.value.toLowerCase()); + case "jsonPath": { + let node: unknown; + try { + node = JSON.parse(content); + } catch { + return false; + } + for (const part of matcher.path.replace(/^\$\.?/, "").split(".").filter(Boolean)) { + if (node !== null && typeof node === "object" && part in (node as object)) { + node = (node as Record)[part]; + } else { + node = undefined; + break; + } + } + const text = node === undefined || node === null ? undefined : String(node); + switch (matcher.op) { + case "Exists": + return text !== undefined; + case "NotExists": + return text === undefined; + case "Eq": + return text !== undefined && text === (matcher.value ?? ""); + case "Neq": + return text !== undefined && text !== (matcher.value ?? ""); + case "Contains": + return text !== undefined && text.includes(matcher.value ?? ""); + } + } + } +}; + +const delayForAttempt = (delay: RetryDelay, attempt: number): number => { + if (delay.type === "fixed") return delay.delaySeconds; + if (delay.type === "linear") return delay.initialSeconds + delay.incrementSeconds * (attempt - 1); + return Math.min(delay.initialSeconds * Math.pow(delay.multiplier, attempt - 1), delay.maxSeconds); +}; + +const evaluateRetry = ( + groups: RetryGroup[], + resultType: RetryResultType, + content: string, + attempts: number, +): RetryTestAttempt[] => { + const ordered = [...groups].filter((g) => g.enabled).sort((a, b) => a.priority - b.priority); + const results: RetryTestAttempt[] = []; + let stopped = false; + for (let attempt = 1; attempt <= attempts && !stopped; attempt++) { + const group = ordered.find( + (g) => + g.appliesTo.includes(resultType) && + (g.matchers.length === 0 || g.matchers.some((m) => matcherHits(m, content))), + ); + if (!group) { + results.push({ attempt, shouldRetry: false, reason: "No group matches — default is to stop." }); + stopped = true; + } else if (group.action === "Block") { + results.push({ attempt, shouldRetry: false, matchedGroup: group.name, reason: "Group blocks retries." }); + stopped = true; + } else if (!group.budget) { + results.push({ attempt, shouldRetry: false, matchedGroup: group.name, reason: "Group allows retries but has no budget." }); + stopped = true; + } else if (attempt >= group.budget.maxAttemptsPerError) { + results.push({ + attempt, + shouldRetry: false, + matchedGroup: group.name, + reason: `Budget spent — ${group.budget.maxAttemptsPerError} attempts per error.`, + }); + stopped = true; + } else { + results.push({ + attempt, + shouldRetry: true, + matchedGroup: group.name, + delaySeconds: Math.round(delayForAttempt(group.budget.delay, attempt)), + reason: "Within budget.", + }); + } + } + return results; +}; + +export const configClient = { + // ——— partners ——— + + async listPartners(): Promise { + await delay(); + const db = loadDb(); + return db.partners.map((p) => ({ + ...structuredClone(p), + credentialCount: db.credentials.filter((c) => c.partnerId === p.id).length, + usedByCount: partnerUsedByCount(db, p.id), + })); + }, + + async getPartner(id: number) { + await delay(); + const db = loadDb(); + const partner = requirePartner(db, id); + return { + ...structuredClone(partner), + apiCredentials: db.credentials + .filter((c) => c.partnerId === id) + .map((c) => ({ name: c.name, keyPrefix: c.key.slice(0, 5), createdOn: c.createdOn })), + integrationSetups: db.integrations + .filter((s) => partnerIdsOf(db, s).includes(id)) + .map(setupRefOf), + apiGateways: db.apiGateways + .filter((g) => g.attachments.some((a) => a.partnerId === id)) + .map((g) => ({ gatewayId: g.id, gatewayName: g.name, urlName: g.urlName })), + busGatewayRoutes: db.busGateways.flatMap((g) => + g.routes + .filter((r) => r.partnerId === id) + .map((r) => ({ gatewayId: g.id, gatewayName: g.name, matchExpression: matchSummary(r.matchExpression) })), + ), + recentExchanges: db.exchanges + .filter((e) => e.partnerId === id) + .sort((a, b) => b.on.localeCompare(a.on)) + .slice(0, 8) + .map((e) => exchangeDto(db, e)), + }; + }, + + async createPartner({ name }: { name: string }) { + await delay(); + const db = loadDb(); + const trimmed = name.trim(); + if (trimmed.length < 2) fail("INVALID_NAME", "Give the partner a name."); + if (db.partners.some((p) => p.name.toLowerCase() === trimmed.toLowerCase())) + fail("NAME_TAKEN", "A partner with this name already exists."); + const partner: Partner = { + id: Math.max(...db.partners.map((p) => p.id)) + 1, + name: trimmed, + adapterProperties: {}, + isSystem: false, + createdOn: new Date().toISOString(), + }; + db.partners.push(partner); + saveDb(db); + return structuredClone(partner); + }, + + async updatePartner( + id: number, + changes: { name?: string; adapterProperties?: Record }, + ) { + await delay(); + const db = loadDb(); + const partner = requirePartner(db, id); + if (changes.name !== undefined) { + if (partner.isSystem) fail("SYSTEM_PARTNER", "The SYSTEM partner can't be renamed."); + const trimmed = changes.name.trim(); + if (trimmed.length < 2) fail("INVALID_NAME", "Give the partner a name."); + if (db.partners.some((p) => p.id !== id && p.name.toLowerCase() === trimmed.toLowerCase())) + fail("NAME_TAKEN", "A partner with this name already exists."); + partner.name = trimmed; + } + if (changes.adapterProperties !== undefined) { + if (Object.keys(changes.adapterProperties).some((k) => !k.trim())) + fail("INVALID_PROPERTY", "Property names can't be empty."); + partner.adapterProperties = { ...changes.adapterProperties }; + } + saveDb(db); + return structuredClone(partner); + }, + + async deletePartner(id: number) { + await delay(); + const db = loadDb(); + const partner = requirePartner(db, id); + if (partner.isSystem) fail("SYSTEM_PARTNER", "The SYSTEM partner can't be deleted."); + const used = partnerUsedByCount(db, id); + if (used > 0) + fail( + "IN_USE", + `${partner.name} is referenced by ${used} integration${used === 1 ? "" : "s"} or gateway attachment${used === 1 ? "" : "s"} — detach those first.`, + ); + db.partners = db.partners.filter((p) => p.id !== id); + db.credentials = db.credentials.filter((c) => c.partnerId !== id); + saveDb(db); + }, + + async addPartnerCredential(id: number, name: string) { + await delay(); + const db = loadDb(); + requirePartner(db, id); + const trimmed = name.trim(); + if (!trimmed) fail("INVALID_NAME", "Give the key a name (e.g. Production)."); + if (db.credentials.some((c) => c.partnerId === id && c.name.toLowerCase() === trimmed.toLowerCase())) + fail("NAME_TAKEN", "This partner already has a key with that name."); + const key = crypto.randomUUID().replaceAll("-", ""); + db.credentials.push({ partnerId: id, name: trimmed, key, createdOn: new Date().toISOString() }); + saveDb(db); + return { key }; + }, + + async revokePartnerCredential(id: number, name: string) { + await delay(); + const db = loadDb(); + requirePartner(db, id); + if (!db.credentials.some((c) => c.partnerId === id && c.name === name)) + fail("NOT_FOUND", "This key no longer exists."); + db.credentials = db.credentials.filter((c) => !(c.partnerId === id && c.name === name)); + saveDb(db); + }, + + // ——— information types ——— + + async listInformationTypes() { + await delay(); + const db = loadDb(); + return db.informationTypes.map((t) => ({ + ...structuredClone(t), + usedByCount: + db.integrations.filter((s) => s.informationTypeId === t.id).length + + db.busGateways.filter((g) => g.informationTypeId === t.id).length, + })); + }, + + async getInformationType(id: number) { + await delay(); + const db = loadDb(); + const type = requireInfoType(db, id); + return { + ...structuredClone(type), + integrationSetups: db.integrations.filter((s) => s.informationTypeId === id).map(setupRefOf), + busGateways: db.busGateways + .filter((g) => g.informationTypeId === id) + .map((g) => ({ gatewayId: g.id, gatewayName: g.name })), + trail: structuredClone(db.trails[id] ?? []), + recentExchanges: db.exchanges + .filter((e) => e.informationTypeId === id) + .sort((a, b) => b.on.localeCompare(a.on)) + .slice(0, 8) + .map((e) => exchangeDto(db, e)), + }; + }, + + async createInformationType(input: { + name: string; + code: string; + format: "Json" | "Xml"; + busEnabled?: boolean; + busMessageTypeName?: string; + }) { + await delay(); + const db = loadDb(); + const name = input.name.trim(); + const code = input.code.trim(); + if (name.length < 2) fail("INVALID_NAME", "Give the information type a name."); + if (!CODE_PATTERN.test(code)) + fail("INVALID_CODE", "Codes are UPPER_CASE letters, digits and underscores (2–50 chars)."); + if (db.informationTypes.some((t) => t.name.toLowerCase() === name.toLowerCase())) + fail("NAME_TAKEN", "An information type with this name already exists."); + if (db.informationTypes.some((t) => t.code === code)) + fail("CODE_TAKEN", "This code is already in use."); + let busMessageTypeName: string | undefined; + if (input.busEnabled) { + busMessageTypeName = input.busMessageTypeName?.trim(); + if (!busMessageTypeName) fail("INVALID_BUS_NAME", "Bus-enabled types need a bus message type name."); + if (db.informationTypes.some((t) => t.busMessageTypeName?.toLowerCase() === busMessageTypeName!.toLowerCase())) + fail("DUPLICATED_BUS_TYPE_NAME", "Another information type already uses this bus message type name."); + } + const type: InformationType = { + id: Math.max(...db.informationTypes.map((t) => t.id)) + 1, + code, + name, + format: input.format, + busEnabled: input.busEnabled ?? false, + busMessageTypeName, + duplicateIntervalMinutes: 0, + disregardsUnfilteredMessages: false, + promotedProperties: [], + createdOn: new Date().toISOString(), + }; + db.informationTypes.push(type); + db.trails[type.id] = [ + { on: type.createdOn, action: "Created", by: currentUserName(db), byUserId: db.sessionUserId ?? undefined }, + ]; + saveDb(db); + return structuredClone(type); + }, + + async updateInformationType(id: number, changes: Omit) { + await delay(); + const db = loadDb(); + const type = requireInfoType(db, id); + const name = changes.name.trim(); + const code = changes.code.trim(); + if (name.length < 2) fail("INVALID_NAME", "Give the information type a name."); + if (!CODE_PATTERN.test(code)) + fail("INVALID_CODE", "Codes are UPPER_CASE letters, digits and underscores (2–50 chars)."); + if (db.informationTypes.some((t) => t.id !== id && t.name.toLowerCase() === name.toLowerCase())) + fail("NAME_TAKEN", "An information type with this name already exists."); + if (db.informationTypes.some((t) => t.id !== id && t.code === code)) + fail("CODE_TAKEN", "This code is already in use."); + if (changes.busEnabled) { + const busName = changes.busMessageTypeName?.trim(); + if (!busName) fail("INVALID_BUS_NAME", "Bus-enabled types need a bus message type name."); + if ( + db.informationTypes.some( + (t) => t.id !== id && t.busMessageTypeName?.toLowerCase() === busName!.toLowerCase(), + ) + ) + fail("DUPLICATED_BUS_TYPE_NAME", "Another information type already uses this bus message type name."); + } + const keys = changes.promotedProperties.map((p) => p.key.trim()); + if (changes.promotedProperties.some((p) => !p.key.trim() || !p.path.trim())) + fail("INVALID_PROMOTED_PROPERTY", "Promoted properties need both a name and a path."); + if (new Set(keys.map((k) => k.toLowerCase())).size !== keys.length) + fail("DUPLICATE_PROMOTED_PROPERTY_KEY", "Promoted property names must be unique."); + + Object.assign(type, { + ...changes, + name, + code, + busMessageTypeName: changes.busEnabled ? changes.busMessageTypeName?.trim() : undefined, + promotedProperties: changes.promotedProperties.map((p) => ({ + key: p.key.trim(), + path: p.path.trim(), + })), + }); + (db.trails[id] ??= []).push({ + on: new Date().toISOString(), + action: "Updated", + by: currentUserName(db), + byUserId: db.sessionUserId ?? undefined, + }); + saveDb(db); + return structuredClone(type); + }, + + async deleteInformationType(id: number) { + await delay(); + const db = loadDb(); + const type = requireInfoType(db, id); + const used = + db.integrations.filter((s) => s.informationTypeId === id).length + + db.busGateways.filter((g) => g.informationTypeId === id).length; + if (used > 0) + fail("IN_USE", `${type.code} is used by ${used} integration${used === 1 ? "" : "s"} or bus gateway${used === 1 ? "" : "s"} — detach those first.`); + db.informationTypes = db.informationTypes.filter((t) => t.id !== id); + delete db.trails[id]; + saveDb(db); + }, + + // ——— global values ——— + + async listValueSets() { + await delay(); + const db = loadDb(); + return db.valueSets.map((set) => valueSetRow(db, set)); + }, + + async getValueSet(id: string) { + await delay(); + const db = loadDb(); + const set = db.valueSets.find((s) => s.id === id); + if (!set) fail("NOT_FOUND", "This value set no longer exists."); + return { + ...structuredClone(set!), + usedBy: db.integrations + .map((s) => ({ s, refs: globalRefsOf(s).find((g) => g.setId === id) })) + .filter((x) => x.refs) + .map(({ s, refs }) => ({ integrationSetup: setupRefOf(s), keys: refs!.keys })), + }; + }, + + async createValueSet(input: { id: string; name: string; values: Record }) { + await delay(); + const db = loadDb(); + const slug = input.id.trim(); + const name = input.name.trim(); + if (name.length < 2) fail("INVALID_NAME", "Give the value set a name."); + if (!SLUG_PATTERN.test(slug)) + fail("INVALID_ID", "IDs are lowercase letters, digits and dashes (2–50 chars)."); + if (db.valueSets.some((s) => s.id === slug)) fail("ID_EXISTS", "This ID is already in use."); + if (Object.keys(input.values).some((k) => !k.trim())) + fail("INVALID_VALUE", "Value names can't be empty."); + const set = { id: slug, name, values: { ...input.values }, createdOn: new Date().toISOString() }; + db.valueSets.push(set); + saveDb(db); + return valueSetRow(db, set); + }, + + async updateValueSet(id: string, changes: { name: string; values: Record }) { + await delay(); + const db = loadDb(); + const set = db.valueSets.find((s) => s.id === id); + if (!set) fail("NOT_FOUND", "This value set no longer exists."); + if (changes.name.trim().length < 2) fail("INVALID_NAME", "Give the value set a name."); + if (Object.keys(changes.values).some((k) => !k.trim())) + fail("INVALID_VALUE", "Value names can't be empty."); + set!.name = changes.name.trim(); + set!.values = { ...changes.values }; + saveDb(db); + return valueSetRow(db, set!); + }, + + async deleteValueSet(id: string) { + await delay(); + const db = loadDb(); + const set = db.valueSets.find((s) => s.id === id); + if (!set) fail("NOT_FOUND", "This value set no longer exists."); + const used = db.integrations.filter((s) => globalRefsOf(s).some((g) => g.setId === id)).length; + if (used > 0) + fail("IN_USE", `${set!.name} is referenced by ${used} integration${used === 1 ? "" : "s"} — remove those references first.`); + db.valueSets = db.valueSets.filter((s) => s.id !== id); + saveDb(db); + }, + + // ——— integrations (cached summaries) ——— + + async listIntegrations() { + await delay(); + const db = loadDb(); + return db.integrations.map((s) => integrationInfoOf(db, s)); + }, + + // ——— retry policies ——— + + async listRetryPolicies() { + await delay(); + const db = loadDb(); + return db.retryPolicies.map((p) => ({ + ...structuredClone(p), + usedByCount: db.integrations.filter((s) => s.retryPolicyId === p.id).length, + })); + }, + + async getRetryPolicy(id: number) { + await delay(); + const db = loadDb(); + const policy = db.retryPolicies.find((p) => p.id === id); + if (!policy) fail("NOT_FOUND", "This retry policy no longer exists."); + return { + ...structuredClone(policy!), + integrations: db.integrations.filter((s) => s.retryPolicyId === id).map(setupRefOf), + }; + }, + + async createRetryPolicy({ name }: { name: string }) { + await delay(); + const db = loadDb(); + const trimmed = name.trim(); + if (trimmed.length < 2) fail("INVALID_NAME", "Give the policy a name."); + if (db.retryPolicies.some((p) => p.name.toLowerCase() === trimmed.toLowerCase())) + fail("NAME_TAKEN", "A retry policy with this name already exists."); + const policy = { + id: Math.max(0, ...db.retryPolicies.map((p) => p.id)) + 1, + name: trimmed, + groups: [], + createdOn: new Date().toISOString(), + }; + db.retryPolicies.push(policy); + saveDb(db); + return structuredClone(policy); + }, + + async updateRetryPolicy(id: number, changes: { name: string; groups: RetryGroup[] }) { + await delay(); + const db = loadDb(); + const policy = db.retryPolicies.find((p) => p.id === id); + if (!policy) fail("NOT_FOUND", "This retry policy no longer exists."); + const trimmed = changes.name.trim(); + if (trimmed.length < 2) fail("INVALID_NAME", "Give the policy a name."); + if (db.retryPolicies.some((p) => p.id !== id && p.name.toLowerCase() === trimmed.toLowerCase())) + fail("NAME_TAKEN", "A retry policy with this name already exists."); + if (changes.groups.some((g) => !g.name.trim())) + fail("INVALID_GROUP", "Every group needs a name."); + policy!.name = trimmed; + policy!.groups = structuredClone(changes.groups); + saveDb(db); + return structuredClone(policy!); + }, + + async deleteRetryPolicy(id: number) { + await delay(); + const db = loadDb(); + const policy = db.retryPolicies.find((p) => p.id === id); + if (!policy) fail("NOT_FOUND", "This retry policy no longer exists."); + const used = db.integrations.filter((s) => s.retryPolicyId === id).length; + if (used > 0) + fail("IN_USE", `${policy!.name} is assigned to ${used} integration${used === 1 ? "" : "s"} — unassign it first.`); + db.retryPolicies = db.retryPolicies.filter((p) => p.id !== id); + saveDb(db); + }, + + async testRetryPolicy({ groups, resultType, content, attempts }) { + await delay(); + if (!content.trim()) fail("NO_CONTENT", "Paste an error message or result body to simulate."); + return evaluateRetry(groups, resultType, content, Math.min(Math.max(attempts, 1), 20)); + }, + + // ——— notifiers ——— + + async listNotifierChannels() { + await delay(); + return structuredClone(NOTIFIER_CHANNELS); + }, + + async listNotifiers() { + await delay(); + return structuredClone(loadDb().notifiers); + }, + + async getNotifier(id: number) { + await delay(); + const db = loadDb(); + const notifier = db.notifiers.find((n) => n.id === id); + if (!notifier) fail("NOT_FOUND", "This notifier no longer exists."); + return { + ...structuredClone(notifier!), + recentNotifications: db.notifications + .filter((n) => n.notifierId === id) + .sort((a, b) => b.on.localeCompare(a.on)) + .slice(0, 8) + .map(({ notifierId: _n, ...entry }) => structuredClone(entry)), + }; + }, + + async createNotifier({ name }: { name: string }) { + await delay(); + const db = loadDb(); + const trimmed = name.trim(); + if (trimmed.length < 2) fail("INVALID_NAME", "Give the notifier a name."); + if (db.notifiers.some((n) => n.name.toLowerCase() === trimmed.toLowerCase())) + fail("NAME_TAKEN", "A notifier with this name already exists."); + const notifier: Notifier = { + id: Math.max(0, ...db.notifiers.map((n) => n.id)) + 1, + name: trimmed, + enabled: true, + onFailed: true, + onBadResult: false, + onSuccess: false, + channelId: NOTIFIER_CHANNELS[0].id, + channelProperties: {}, + integrationIds: [], + createdOn: new Date().toISOString(), + }; + db.notifiers.push(notifier); + saveDb(db); + return structuredClone(notifier); + }, + + async updateNotifier(id: number, changes: Omit) { + await delay(); + const db = loadDb(); + const notifier = db.notifiers.find((n) => n.id === id); + if (!notifier) fail("NOT_FOUND", "This notifier no longer exists."); + const trimmed = changes.name.trim(); + if (trimmed.length < 2) fail("INVALID_NAME", "Give the notifier a name."); + if (db.notifiers.some((n) => n.id !== id && n.name.toLowerCase() === trimmed.toLowerCase())) + fail("NAME_TAKEN", "A notifier with this name already exists."); + if (!NOTIFIER_CHANNELS.some((c) => c.id === changes.channelId)) + fail("INVALID_CHANNEL", "Pick a delivery channel."); + Object.assign(notifier!, { + ...changes, + name: trimmed, + channelProperties: { ...changes.channelProperties }, + integrationIds: changes.integrationIds.filter((sid) => + db.integrations.some((s) => s.id === sid), + ), + }); + saveDb(db); + return structuredClone(notifier!); + }, + + async deleteNotifier(id: number) { + await delay(); + const db = loadDb(); + if (!db.notifiers.some((n) => n.id === id)) + fail("NOT_FOUND", "This notifier no longer exists."); + db.notifiers = db.notifiers.filter((n) => n.id !== id); + db.notifications = db.notifications.filter((n) => n.notifierId !== id); + saveDb(db); + }, + + async testNotifier({ channelId, channelProperties }) { + await delay(); + const channel = NOTIFIER_CHANNELS.find((c) => c.id === channelId); + if (!channel) fail("INVALID_CHANNEL", "Pick a delivery channel."); + const missing = channel!.props.filter((p) => !channelProperties[p.key]?.trim()); + if (missing.length > 0) + fail("MISSING_PROPERTY", `${channel!.label} still needs: ${missing.map((p) => p.label).join(", ")}.`); + return { message: `Test notification sent via ${channel!.label}.` }; + }, +} satisfies Partial; diff --git a/SW.Bitween.Web/ClientApp/src/api/mock/derive.ts b/SW.Bitween.Web/ClientApp/src/api/mock/derive.ts new file mode 100644 index 00000000..7bc85378 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/api/mock/derive.ts @@ -0,0 +1,61 @@ +import type { IntegrationInfo, Integration, IntegrationSetupRef } from "../types"; +import type { MockDb } from "./store"; + +const PARTNER_TOKEN = /\{\{\s*partner\.([A-Za-z0-9_.-]+)\s*\}\}/g; +const GLOBALS_TOKEN = /\{\{\s*globals\.([A-Za-z0-9_-]+)\.([A-Za-z0-9_.-]+)\s*\}\}/g; + +const allPropValues = (s: Integration): string[] => [ + ...Object.values(s.receiverProperties), + ...Object.values(s.validatorProperties), + ...Object.values(s.mapperProperties), + ...Object.values(s.handlerProperties), +]; + +/** Keys of partner properties referenced anywhere in the adapter config. */ +export const partnerPropKeysOf = (s: Integration): string[] => [ + ...new Set(allPropValues(s).flatMap((v) => [...v.matchAll(PARTNER_TOKEN)].map((m) => m[1]))), +]; + +/** Global value references anywhere in the adapter config, grouped per set. */ +export const globalRefsOf = (s: Integration): { setId: string; keys: string[] }[] => { + const bySet = new Map>(); + for (const v of allPropValues(s)) { + for (const m of v.matchAll(GLOBALS_TOKEN)) { + (bySet.get(m[1]) ?? bySet.set(m[1], new Set()).get(m[1])!).add(m[2]); + } + } + return [...bySet.entries()].map(([setId, keys]) => ({ setId, keys: [...keys] })); +}; + +/** + * Partners an integration runs for: its own (legacy types) plus partners + * linked through API-gateway attachments and bus-gateway routes. + */ +export const partnerIdsOf = (db: MockDb, s: Integration): number[] => { + const ids = new Set(); + if (s.partnerId !== null) ids.add(s.partnerId); + for (const gw of db.apiGateways) + for (const a of gw.attachments) if (a.integrationId === s.id) ids.add(a.partnerId); + for (const gw of db.busGateways) + for (const r of gw.routes) + if (r.integrationId === s.id && r.partnerId !== null) ids.add(r.partnerId); + return [...ids]; +}; + +export const integrationInfoOf = (db: MockDb, s: Integration): IntegrationInfo => ({ + id: s.id, + name: s.name, + type: s.type, + partnerIds: partnerIdsOf(db, s), + informationTypeId: s.informationTypeId, + workGroupId: s.workGroupId, + retryPolicyId: s.retryPolicyId, + partnerPropKeys: partnerPropKeysOf(s), + globals: globalRefsOf(s), +}); + +export const setupRefOf = (s: Integration): IntegrationSetupRef => ({ + id: s.id, + name: s.name, + type: s.type, +}); diff --git a/SW.Bitween.Web/ClientApp/src/api/mock/integrationsClient.ts b/SW.Bitween.Web/ClientApp/src/api/mock/integrationsClient.ts new file mode 100644 index 00000000..2a28336f --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/api/mock/integrationsClient.ts @@ -0,0 +1,654 @@ +import type { ApiClient } from "../client"; +import { + ApiRequestError, + type AdapterKind, + type ExchangeRef, + type Integration, + type IntegrationDetail, + type IntegrationRow, + type IntegrationType, + type Schedule, + type WorkGroup, +} from "../types"; +import { schedulesSummary } from "../../lib/schedules"; +import { ADAPTER_CATALOG } from "./adapters"; +import { partnerIdsOf, setupRefOf } from "./derive"; +import { type MockDb, delay, loadDb, saveDb } from "./store"; + +const fail = (code: string, message: string): never => { + throw new ApiRequestError(code, message); +}; + +const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{1,49}$/; +const CREATABLE_TYPES: IntegrationType[] = ["Receiving", "GatewayApiCall", "BusGateway"]; + +const currentUserName = (db: MockDb) => + db.users.find((u) => u.id === db.sessionUserId)?.displayName ?? "Unknown"; + +const requireIntegration = (db: MockDb, id: number): Integration => + db.integrations.find((s) => s.id === id) ?? + fail("NOT_FOUND", "This integration no longer exists."); + +const requireApiGateway = (db: MockDb, id: number) => + db.apiGateways.find((g) => g.id === id) ?? fail("NOT_FOUND", "This API gateway no longer exists."); + +const requireBusGateway = (db: MockDb, id: number) => + db.busGateways.find((g) => g.id === id) ?? fail("NOT_FOUND", "This bus gateway no longer exists."); + +const requireWorkGroup = (db: MockDb, id: number): WorkGroup => + db.workGroups.find((w) => w.id === id) ?? fail("NOT_FOUND", "This work group no longer exists."); + +/** Consumer count is a live RabbitMQ read in the real backend; here it's proxied by enabled assignees. */ +const workGroupRow = (db: MockDb, w: WorkGroup) => ({ + ...structuredClone(w), + usedByCount: db.integrations.filter((s) => s.workGroupId === w.id).length, + consumerCount: db.integrations.filter((s) => s.workGroupId === w.id && s.enabled).length, +}); + +/** Every non-optional startup value of the chosen adapter must be filled. */ +const validateAdapterConfig = ( + kind: AdapterKind, + adapterId: string | null, + props: Record, +) => { + if (adapterId === null) return; + const adapter = ADAPTER_CATALOG.find((a) => a.kind === kind && a.id === adapterId); + if (!adapter) fail("UNKNOWN_ADAPTER", `Unknown ${kind} adapter "${adapterId}".`); + const missing = adapter!.props.filter((p) => !p.optional && !props[p.key]?.trim()); + if (missing.length > 0) + fail( + "MISSING_ADAPTER_PROPERTY", + `${adapter!.label} still needs: ${missing.map((p) => p.key).join(", ")}.`, + ); +}; + +const validateSchedules = (schedules: Schedule[]) => { + for (const s of schedules) { + if (s.recurrence === "Monthly" && (s.days < 1 || s.days > 27)) + fail("INVALID_SCHEDULE", "Monthly schedules run on day 1–27."); + if (s.recurrence === "Weekly" && (s.days < 0 || s.days > 6)) + fail("INVALID_SCHEDULE", "Weekly schedules need a weekday."); + } +}; + +const integrationRow = (db: MockDb, s: Integration): IntegrationRow => ({ + id: s.id, + name: s.name, + type: s.type, + informationTypeId: s.informationTypeId, + informationTypeCode: + db.informationTypes.find((t) => t.id === s.informationTypeId)?.code ?? "UNKNOWN", + partners: partnerIdsOf(db, s) + .map((pid) => ({ id: pid, name: db.partners.find((p) => p.id === pid)?.name })) + .filter((p): p is { id: number; name: string } => !!p.name), + enabled: s.enabled, + paused: s.pausedOn !== null, + isRunning: s.isRunning, + consecutiveFailures: s.consecutiveFailures, + lastException: s.lastException, + scheduleSummary: s.type === "Receiving" || s.type === "Aggregation" ? schedulesSummary(s.schedules) : undefined, + lastReceiveOn: s.lastReceiveOn, + createdOn: s.createdOn, +}); + +const exchangeDto = (db: MockDb, e: MockDb["exchanges"][number]): ExchangeRef => ({ + id: e.id, + partnerName: e.partnerId ? db.partners.find((p) => p.id === e.partnerId)?.name : undefined, + informationTypeCode: + db.informationTypes.find((t) => t.id === e.informationTypeId)?.code ?? "UNKNOWN", + status: e.status, + on: e.on, + documents: e.documents ? structuredClone(e.documents) : undefined, +}); + +const integrationDetail = (db: MockDb, s: Integration): IntegrationDetail => { + const infoType = db.informationTypes.find((t) => t.id === s.informationTypeId); + return { + ...structuredClone(s), + informationTypeCode: infoType?.code ?? "UNKNOWN", + informationTypeName: infoType?.name ?? "Unknown", + apiGatewayAttachments: db.apiGateways.flatMap((g) => + g.attachments + .filter((a) => a.integrationId === s.id) + .map((a) => ({ + gatewayId: g.id, + gatewayName: g.name, + urlName: g.urlName, + partnerId: a.partnerId, + partnerName: db.partners.find((p) => p.id === a.partnerId)?.name ?? "Unknown", + })), + ), + busGatewayRoutes: db.busGateways.flatMap((g) => + g.routes + .filter((r) => r.integrationId === s.id) + .map((r) => ({ + gatewayId: g.id, + gatewayName: g.name, + partnerId: r.partnerId, + partnerName: r.partnerId ? (db.partners.find((p) => p.id === r.partnerId)?.name ?? null) : null, + })), + ), + watchingNotifiers: db.notifiers + .filter((n) => n.integrationIds.includes(s.id)) + .map((n) => ({ id: n.id, name: n.name })), + recentExchanges: db.exchanges + .filter((e) => e.integrationId === s.id) + .sort((a, b) => b.on.localeCompare(a.on)) + .slice(0, 8) + .map((e) => exchangeDto(db, e)), + trail: structuredClone(db.integrationTrails[s.id] ?? []), + }; +}; + +const appendTrail = (db: MockDb, id: number, action: "Created" | "Updated") => { + (db.integrationTrails[id] ??= []).push({ + on: new Date().toISOString(), + action, + by: currentUserName(db), + byUserId: db.sessionUserId ?? undefined, + }); +}; + +export const integrationsClient = { + // ——— integrations ——— + + async listIntegrationRows() { + await delay(); + const db = loadDb(); + return db.integrations.map((s) => integrationRow(db, s)); + }, + + async getIntegration(id: number) { + await delay(); + const db = loadDb(); + return integrationDetail(db, requireIntegration(db, id)); + }, + + async createIntegration(input) { + await delay(); + const db = loadDb(); + const name = input.name.trim(); + if (name.length < 2) fail("INVALID_NAME", "Give the integration a name."); + if (db.integrations.some((s) => s.name.toLowerCase() === name.toLowerCase())) + fail("NAME_TAKEN", "An integration with this name already exists."); + if (!CREATABLE_TYPES.includes(input.type)) + fail("INVALID_TYPE", "New integrations are created from the gateway and scheduled-jobs pages."); + if (!db.informationTypes.some((t) => t.id === input.informationTypeId)) + fail("NOT_FOUND", "This information type no longer exists."); + if (input.type === "Receiving") { + if (!input.receiverId) fail("MISSING_RECEIVER", "Receivers need a source adapter."); + if (!input.schedules || input.schedules.length === 0) + fail("MISSING_SCHEDULE", "Receivers need at least one schedule."); + } + validateAdapterConfig("receiver", input.receiverId ?? null, input.receiverProperties ?? {}); + validateAdapterConfig("validator", input.validatorId ?? null, input.validatorProperties ?? {}); + validateAdapterConfig("mapper", input.mapperId ?? null, input.mapperProperties ?? {}); + validateAdapterConfig("handler", input.handlerId ?? null, input.handlerProperties ?? {}); + validateSchedules(input.schedules ?? []); + + const integration: Integration = { + id: Math.max(500, ...db.integrations.map((s) => s.id)) + 1, + name, + type: input.type, + informationTypeId: input.informationTypeId, + partnerId: null, + enabled: input.enabled ?? false, + pausedOn: null, + workGroupId: null, + retryPolicyId: input.retryPolicyId ?? null, + receiverId: input.receiverId ?? null, + receiverProperties: { ...(input.receiverProperties ?? {}) }, + validatorId: input.validatorId ?? null, + validatorProperties: { ...(input.validatorProperties ?? {}) }, + mapperId: input.mapperId ?? null, + mapperProperties: { ...(input.mapperProperties ?? {}) }, + handlerId: input.handlerId ?? null, + handlerProperties: { ...(input.handlerProperties ?? {}) }, + matchExpression: null, + schedules: structuredClone(input.schedules ?? []), + responseIntegrationId: null, + responseMessageTypeName: null, + aggregationForId: null, + isRunning: false, + lastReceiveOn: null, + consecutiveFailures: 0, + lastException: null, + createdOn: new Date().toISOString(), + }; + db.integrations.push(integration); + appendTrail(db, integration.id, "Created"); + saveDb(db); + return structuredClone(integration); + }, + + async updateIntegration(id, changes) { + await delay(); + const db = loadDb(); + const s = requireIntegration(db, id); + const name = (changes.name ?? s.name).trim(); + if (name.length < 2) fail("INVALID_NAME", "Give the integration a name."); + if (db.integrations.some((x) => x.id !== id && x.name.toLowerCase() === name.toLowerCase())) + fail("NAME_TAKEN", "An integration with this name already exists."); + + const next = { ...s, ...structuredClone(changes), name }; + if (s.type === "Receiving") { + if (!next.receiverId) fail("MISSING_RECEIVER", "Receivers need a source adapter."); + if (next.schedules.length === 0) fail("MISSING_SCHEDULE", "Receivers need at least one schedule."); + } + validateAdapterConfig("receiver", next.receiverId, next.receiverProperties); + validateAdapterConfig("validator", next.validatorId, next.validatorProperties); + validateAdapterConfig("mapper", next.mapperId, next.mapperProperties); + validateAdapterConfig("handler", next.handlerId, next.handlerProperties); + validateSchedules(next.schedules); + if (next.responseIntegrationId === id) + fail("INVALID_RESPONSE_TARGET", "An integration can't feed its response into itself."); + if (next.responseIntegrationId !== null && !db.integrations.some((x) => x.id === next.responseIntegrationId)) + fail("NOT_FOUND", "The response integration no longer exists."); + if (next.retryPolicyId !== null && !db.retryPolicies.some((p) => p.id === next.retryPolicyId)) + fail("NOT_FOUND", "The chosen retry policy no longer exists."); + if (next.workGroupId !== null && !db.workGroups.some((w) => w.id === next.workGroupId)) + fail("NOT_FOUND", "The chosen work group no longer exists."); + + Object.assign(s, next); + appendTrail(db, id, "Updated"); + saveDb(db); + return structuredClone(s); + }, + + async deleteIntegration(id) { + await delay(); + const db = loadDb(); + const s = requireIntegration(db, id); + const attachedTo = db.apiGateways.filter((g) => g.attachments.some((a) => a.integrationId === id)); + const routedFrom = db.busGateways.filter((g) => g.routes.some((r) => r.integrationId === id)); + if (attachedTo.length > 0 || routedFrom.length > 0) { + const names = [...attachedTo, ...routedFrom].map((g) => g.name).join(", "); + fail("IN_USE", `${s.name} is wired into ${names} — detach it from the gateway first.`); + } + const chainedFrom = db.integrations.find((x) => x.responseIntegrationId === id); + if (chainedFrom) + fail("IN_USE", `${chainedFrom.name} feeds its responses into ${s.name} — unlink that first.`); + db.integrations = db.integrations.filter((x) => x.id !== id); + for (const n of db.notifiers) n.integrationIds = n.integrationIds.filter((x) => x !== id); + delete db.integrationTrails[id]; + saveDb(db); + }, + + async pauseIntegration(id) { + await delay(); + const db = loadDb(); + const s = requireIntegration(db, id); + s.pausedOn = s.pausedOn === null ? new Date().toISOString() : null; + saveDb(db); + return structuredClone(s); + }, + + async receiveNow(id) { + await delay(); + const db = loadDb(); + const s = requireIntegration(db, id); + if (s.type !== "Receiving") fail("NOT_A_RECEIVER", "Only receivers can receive on demand."); + if (!s.enabled) fail("DISABLED", "Enable this integration before running it."); + s.lastReceiveOn = new Date().toISOString(); + db.exchanges.unshift({ + id: `0x8DD${Math.floor(Math.random() * 0xffffff).toString(16).toUpperCase().padStart(6, "0")}`, + partnerId: null, + informationTypeId: s.informationTypeId, + integrationId: s.id, + partnerName: undefined, + informationTypeCode: "", + status: "processing", + on: new Date().toISOString(), + }); + saveDb(db); + return structuredClone(s); + }, + + async listAdapters(kind: AdapterKind) { + await delay(); + return structuredClone(ADAPTER_CATALOG.filter((a) => a.kind === kind)); + }, + + // ——— work groups ——— + + async listWorkGroups() { + await delay(); + const db = loadDb(); + return db.workGroups.map((w) => workGroupRow(db, w)); + }, + + async getWorkGroup(id: number) { + await delay(); + const db = loadDb(); + const w = requireWorkGroup(db, id); + return { + ...structuredClone(w), + integrations: db.integrations.filter((s) => s.workGroupId === id).map(setupRefOf), + }; + }, + + async createWorkGroup({ + name, + busMessageName, + prefetch, + priority, + }: { + name: string; + busMessageName: string; + prefetch: number; + priority: number; + }) { + await delay(); + const db = loadDb(); + const trimmedName = name.trim(); + const trimmedBusName = busMessageName.trim(); + if (trimmedName.length < 2) fail("INVALID_NAME", "Give the work group a name."); + if (!trimmedBusName) fail("INVALID_BUS_MESSAGE_NAME", "Give the work group a bus message name."); + // no uniqueness constraint on name/busMessageName — mirrors the real backend + const group: WorkGroup = { + id: Math.max(0, ...db.workGroups.map((w) => w.id)) + 1, + name: trimmedName, + busMessageName: trimmedBusName, + options: { rabbitMqOptions: { consumerSettings: { prefetch, priority } } }, + createdOn: new Date().toISOString(), + }; + db.workGroups.push(group); + saveDb(db); + return structuredClone(group); + }, + + async updateWorkGroup( + id: number, + { name, busMessageName, prefetch, priority }: { name: string; busMessageName: string; prefetch: number; priority: number }, + ) { + await delay(); + const db = loadDb(); + const w = requireWorkGroup(db, id); + const trimmedName = name.trim(); + const trimmedBusName = busMessageName.trim(); + if (trimmedName.length < 2) fail("INVALID_NAME", "Give the work group a name."); + if (!trimmedBusName) fail("INVALID_BUS_MESSAGE_NAME", "Give the work group a bus message name."); + w.name = trimmedName; + w.busMessageName = trimmedBusName; + w.options = { rabbitMqOptions: { consumerSettings: { prefetch, priority } } }; + saveDb(db); + return structuredClone(w); + }, + + async deleteWorkGroup(id: number) { + await delay(); + const db = loadDb(); + const w = requireWorkGroup(db, id); + const used = db.integrations.filter((s) => s.workGroupId === id).length; + if (used > 0) + fail( + "CANT_BE_DELETED", + `${w.name} is assigned to ${used} integration${used === 1 ? "" : "s"} — unassign it first.`, + ); + db.workGroups = db.workGroups.filter((x) => x.id !== id); + saveDb(db); + }, + + // ——— API gateways ——— + + async listApiGateways() { + await delay(); + const db = loadDb(); + return db.apiGateways.map((g) => ({ + id: g.id, + name: g.name, + urlName: g.urlName, + createdOn: g.createdOn, + partnerCount: g.attachments.length, + attachments: g.attachments.map((a) => ({ + partnerId: a.partnerId, + partnerName: db.partners.find((p) => p.id === a.partnerId)?.name ?? "Unknown", + integrationId: a.integrationId, + integrationName: db.integrations.find((s) => s.id === a.integrationId)?.name ?? "Unknown", + })), + })); + }, + + async getApiGateway(id) { + await delay(); + const db = loadDb(); + const g = requireApiGateway(db, id); + return { + id: g.id, + name: g.name, + urlName: g.urlName, + createdOn: g.createdOn, + attachments: g.attachments.map((a) => ({ + partnerId: a.partnerId, + partnerName: db.partners.find((p) => p.id === a.partnerId)?.name ?? "Unknown", + integrationId: a.integrationId, + integrationName: db.integrations.find((s) => s.id === a.integrationId)?.name ?? "Unknown", + })), + }; + }, + + async createApiGateway({ name, urlName }) { + await delay(); + const db = loadDb(); + const trimmed = name.trim(); + const slug = urlName.trim(); + if (trimmed.length < 2) fail("INVALID_NAME", "Give the gateway a name."); + if (!SLUG_PATTERN.test(slug)) + fail("INVALID_URL_NAME", "URL names are lowercase letters, digits and dashes (2–50 chars)."); + if (db.apiGateways.some((g) => g.urlName === slug)) + fail("URL_NAME_TAKEN", "Another API gateway already uses this URL name."); + if (db.apiGateways.some((g) => g.name.toLowerCase() === trimmed.toLowerCase())) + fail("NAME_TAKEN", "An API gateway with this name already exists."); + const gateway = { + id: Math.max(0, ...db.apiGateways.map((g) => g.id)) + 1, + name: trimmed, + urlName: slug, + createdOn: new Date().toISOString(), + attachments: [], + }; + db.apiGateways.push(gateway); + saveDb(db); + const { attachments: _a, ...dto } = gateway; + return structuredClone(dto); + }, + + async updateApiGateway(id, changes) { + await delay(); + const db = loadDb(); + const g = requireApiGateway(db, id); + const trimmed = changes.name.trim(); + const slug = changes.urlName.trim(); + if (trimmed.length < 2) fail("INVALID_NAME", "Give the gateway a name."); + if (!SLUG_PATTERN.test(slug)) + fail("INVALID_URL_NAME", "URL names are lowercase letters, digits and dashes (2–50 chars)."); + if (db.apiGateways.some((x) => x.id !== id && x.urlName === slug)) + fail("URL_NAME_TAKEN", "Another API gateway already uses this URL name."); + g.name = trimmed; + g.urlName = slug; + saveDb(db); + const { attachments: _a, ...dto } = g; + return structuredClone(dto); + }, + + async deleteApiGateway(id) { + await delay(); + const db = loadDb(); + requireApiGateway(db, id); + db.apiGateways = db.apiGateways.filter((g) => g.id !== id); + saveDb(db); + }, + + async attachGatewayPartner(id, { partnerId, integrationId }) { + await delay(); + const db = loadDb(); + const g = requireApiGateway(db, id); + if (!db.partners.some((p) => p.id === partnerId && !p.isSystem)) + fail("NOT_FOUND", "This partner no longer exists."); + if (g.attachments.some((a) => a.partnerId === partnerId)) + fail("ALREADY_ATTACHED", "This partner is already attached — edit its attachment instead."); + const integration = requireIntegration(db, integrationId); + if (integration.type !== "GatewayApiCall") + fail("WRONG_TYPE", "API gateways can only run API-gateway integrations."); + g.attachments.push({ partnerId, integrationId }); + saveDb(db); + }, + + async updateGatewayAttachment(id, { partnerId, integrationId }) { + await delay(); + const db = loadDb(); + const g = requireApiGateway(db, id); + const attachment = g.attachments.find((a) => a.partnerId === partnerId); + if (!attachment) fail("NOT_FOUND", "This attachment no longer exists."); + const integration = requireIntegration(db, integrationId); + if (integration.type !== "GatewayApiCall") + fail("WRONG_TYPE", "API gateways can only run API-gateway integrations."); + attachment!.integrationId = integrationId; + saveDb(db); + }, + + async removeGatewayAttachment(id, partnerId) { + await delay(); + const db = loadDb(); + const g = requireApiGateway(db, id); + g.attachments = g.attachments.filter((a) => a.partnerId !== partnerId); + saveDb(db); + }, + + // ——— bus gateways ——— + + async listBusGateways() { + await delay(); + const db = loadDb(); + return db.busGateways.map((g) => ({ + id: g.id, + name: g.name, + informationTypeId: g.informationTypeId, + informationTypeCode: + db.informationTypes.find((t) => t.id === g.informationTypeId)?.code ?? "UNKNOWN", + createdOn: g.createdOn, + routeCount: g.routes.length, + routes: g.routes.map((r) => ({ + id: r.id, + integrationId: r.integrationId, + integrationName: db.integrations.find((s) => s.id === r.integrationId)?.name ?? "Unknown", + partnerId: r.partnerId, + partnerName: r.partnerId ? (db.partners.find((p) => p.id === r.partnerId)?.name ?? null) : null, + matchExpression: structuredClone(r.matchExpression), + })), + })); + }, + + async getBusGateway(id) { + await delay(); + const db = loadDb(); + const g = requireBusGateway(db, id); + const infoType = db.informationTypes.find((t) => t.id === g.informationTypeId); + return { + id: g.id, + name: g.name, + informationTypeId: g.informationTypeId, + informationTypeCode: infoType?.code ?? "UNKNOWN", + informationTypeName: infoType?.name ?? "Unknown", + createdOn: g.createdOn, + routes: g.routes.map((r) => ({ + id: r.id, + integrationId: r.integrationId, + integrationName: db.integrations.find((s) => s.id === r.integrationId)?.name ?? "Unknown", + partnerId: r.partnerId, + partnerName: r.partnerId ? (db.partners.find((p) => p.id === r.partnerId)?.name ?? null) : null, + matchExpression: structuredClone(r.matchExpression), + })), + }; + }, + + async createBusGateway({ name, informationTypeId }) { + await delay(); + const db = loadDb(); + const trimmed = name.trim(); + if (trimmed.length < 2) fail("INVALID_NAME", "Give the gateway a name."); + if (db.busGateways.some((g) => g.name.toLowerCase() === trimmed.toLowerCase())) + fail("NAME_TAKEN", "A bus gateway with this name already exists."); + const infoType = db.informationTypes.find((t) => t.id === informationTypeId); + if (!infoType) fail("NOT_FOUND", "This information type no longer exists."); + if (!infoType!.busEnabled) + fail("NOT_BUS_ENABLED", `${infoType!.code} isn't available on the message bus — enable that on its page first.`); + const gateway = { + id: Math.max(0, ...db.busGateways.map((g) => g.id)) + 1, + name: trimmed, + informationTypeId, + createdOn: new Date().toISOString(), + routes: [], + }; + db.busGateways.push(gateway); + saveDb(db); + const { routes: _r, ...dto } = gateway; + return structuredClone(dto); + }, + + async updateBusGateway(id, changes) { + await delay(); + const db = loadDb(); + const g = requireBusGateway(db, id); + const trimmed = changes.name.trim(); + if (trimmed.length < 2) fail("INVALID_NAME", "Give the gateway a name."); + if (db.busGateways.some((x) => x.id !== id && x.name.toLowerCase() === trimmed.toLowerCase())) + fail("NAME_TAKEN", "A bus gateway with this name already exists."); + g.name = trimmed; + saveDb(db); + const { routes: _r, ...dto } = g; + return structuredClone(dto); + }, + + async deleteBusGateway(id) { + await delay(); + const db = loadDb(); + requireBusGateway(db, id); + db.busGateways = db.busGateways.filter((g) => g.id !== id); + saveDb(db); + }, + + async addBusRoute(id, { integrationId, partnerId, matchExpression }) { + await delay(); + const db = loadDb(); + const g = requireBusGateway(db, id); + const integration = requireIntegration(db, integrationId); + if (integration.type !== "BusGateway") + fail("WRONG_TYPE", "Bus gateways can only route to bus-gateway integrations."); + if (integration.informationTypeId !== g.informationTypeId) + fail("WRONG_TYPE", "The integration must carry the same information type as the gateway."); + if (partnerId !== null && !db.partners.some((p) => p.id === partnerId && !p.isSystem)) + fail("NOT_FOUND", "This partner no longer exists."); + g.routes.push({ + id: Math.max(0, ...g.routes.map((r) => r.id)) + 1, + integrationId, + partnerId, + matchExpression: structuredClone(matchExpression), + }); + saveDb(db); + }, + + async updateBusRoute(id, routeId, { integrationId, partnerId, matchExpression }) { + await delay(); + const db = loadDb(); + const g = requireBusGateway(db, id); + const route = g.routes.find((r) => r.id === routeId); + if (!route) fail("NOT_FOUND", "This route no longer exists."); + const integration = requireIntegration(db, integrationId); + if (integration.type !== "BusGateway") + fail("WRONG_TYPE", "Bus gateways can only route to bus-gateway integrations."); + if (integration.informationTypeId !== g.informationTypeId) + fail("WRONG_TYPE", "The integration must carry the same information type as the gateway."); + if (partnerId !== null && !db.partners.some((p) => p.id === partnerId && !p.isSystem)) + fail("NOT_FOUND", "This partner no longer exists."); + Object.assign(route!, { integrationId, partnerId, matchExpression: structuredClone(matchExpression) }); + saveDb(db); + }, + + async removeBusRoute(id, routeId) { + await delay(); + const db = loadDb(); + const g = requireBusGateway(db, id); + g.routes = g.routes.filter((r) => r.id !== routeId); + saveDb(db); + }, +} satisfies Partial; diff --git a/SW.Bitween.Web/ClientApp/src/api/mock/mockClient.ts b/SW.Bitween.Web/ClientApp/src/api/mock/mockClient.ts new file mode 100644 index 00000000..77214a6d --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/api/mock/mockClient.ts @@ -0,0 +1,398 @@ +import type { ApiClient } from "../client"; +import { PERMISSION_CATALOG } from "../permissions"; +import { + ApiRequestError, + type Invite, + type PermissionKey, + type Role, + type Session, + type User, +} from "../types"; +import { type MockDb, delay, loadDb, resetDb, saveDb } from "./store"; +import type { SeedRole } from "./seed"; +import { configClient } from "./configClient"; +import { integrationsClient } from "./integrationsClient"; +import { settingsClient } from "./settingsClient"; + +const ADMIN_ROLE_ID = "role-administrator"; +const INVITE_LIFETIME_DAYS = 7; + +const fail = (code: string, message: string): never => { + throw new ApiRequestError(code, message); +}; + +const withMemberCount = (db: MockDb, role: SeedRole): Role => ({ + ...role, + memberCount: db.users.filter((u) => u.roleIds.includes(role.id)).length, +}); + +const buildSession = (db: MockDb, user: User): Session => { + const roles = db.roles.filter((r) => user.roleIds.includes(r.id)); + const permissions = [...new Set(roles.flatMap((r) => r.permissions))] as PermissionKey[]; + return { user: structuredClone(user), roles: roles.map((r) => withMemberCount(db, r)), permissions }; +}; + +const requireUser = (db: MockDb, id: string): User => + db.users.find((u) => u.id === id) ?? fail("NOT_FOUND", "This member no longer exists."); + +/** True if, after applying `change`, no active administrator would remain. */ +const wouldOrphanAdmins = (db: MockDb, change: (u: User) => User | null): boolean => + !db.users + .map((u) => change(u) ?? null) + .some((u) => u !== null && u.status === "active" && u.roleIds.includes(ADMIN_ROLE_ID)); + +const newToken = (prefix: string) => `${prefix}-${crypto.randomUUID().slice(0, 8)}`; + +/** Invites carry resolved role names so the public accept page can show them. */ +const inviteDto = (db: MockDb, invite: Invite): Invite => ({ + ...structuredClone(invite), + roleNames: invite.roleIds.map((id) => db.roles.find((r) => r.id === id)?.name ?? "Unknown role"), +}); + +const startSession = (db: MockDb, user: User): Session => { + user.lastActiveOn = new Date().toISOString(); + db.sessionUserId = user.id; + saveDb(db); + return buildSession(db, user); +}; + +export const mockClient: ApiClient = { + ...configClient, + ...integrationsClient, + ...settingsClient, + + async getSession() { + await delay(); + const db = loadDb(); + if (!db.sessionUserId) return null; + const user = db.users.find((u) => u.id === db.sessionUserId); + if (!user || user.status !== "active") { + db.sessionUserId = null; + saveDb(db); + return null; + } + return buildSession(db, user); + }, + + async login(email, password) { + await delay(); + const db = loadDb(); + const user = db.users.find((u) => u.email.toLowerCase() === email.trim().toLowerCase()); + if (!user || db.passwords[user.id] !== password) + fail("INVALID_CREDENTIALS", "That email and password don't match."); + if (user!.status === "invited") + fail("INVITE_PENDING", "This account hasn't been set up yet — use the invite link instead."); + if (user!.status === "disabled") + fail("ACCOUNT_DISABLED", "This account is disabled. Ask an administrator to re-enable it."); + return startSession(db, user!); + }, + + async loginWithMicrosoft() { + await delay(); + const db = loadDb(); + const user = db.users.find((u) => u.microsoftLinked && u.status === "active"); + if (!user) + fail( + "NO_LINKED_ACCOUNT", + "No active member has a linked Microsoft account in this demo data.", + ); + return startSession(db, user!); + }, + + async logout() { + await delay(); + const db = loadDb(); + db.sessionUserId = null; + saveDb(db); + }, + + async updateProfile(changes) { + await delay(); + const db = loadDb(); + if (!db.sessionUserId) fail("UNAUTHENTICATED", "You're signed out."); + const user = requireUser(db, db.sessionUserId!); + if (changes.displayName !== undefined) { + if (changes.displayName.trim().length < 2) + fail("INVALID_NAME", "Display name needs at least 2 characters."); + user.displayName = changes.displayName.trim(); + } + if (changes.phone !== undefined) user.phone = changes.phone.trim() || undefined; + saveDb(db); + return buildSession(db, user); + }, + + async changePassword(currentPassword, newPassword) { + await delay(); + const db = loadDb(); + if (!db.sessionUserId) fail("UNAUTHENTICATED", "You're signed out."); + if (db.passwords[db.sessionUserId!] !== currentPassword) + fail("WRONG_PASSWORD", "Your current password isn't right."); + if (newPassword.length < 8) fail("WEAK_PASSWORD", "Use at least 8 characters."); + db.passwords[db.sessionUserId!] = newPassword; + saveDb(db); + }, + + async requestPasswordReset(email) { + await delay(); + const db = loadDb(); + const user = db.users.find( + (u) => u.email.toLowerCase() === email.trim().toLowerCase() && u.status === "active", + ); + if (!user) return { resetLink: "" }; // same outward response either way + const token = newToken("rst"); + db.resetTokens[token] = user.id; + saveDb(db); + return { resetLink: `${location.origin}${import.meta.env.BASE_URL}reset-password/${token}` }; + }, + + async resetPassword(token, newPassword) { + await delay(); + const db = loadDb(); + const userId = db.resetTokens[token]; + if (!userId) fail("INVALID_TOKEN", "This reset link is no longer valid."); + if (newPassword.length < 8) fail("WEAK_PASSWORD", "Use at least 8 characters."); + db.passwords[userId] = newPassword; + delete db.resetTokens[token]; + saveDb(db); + }, + + async listUsers() { + await delay(); + return structuredClone(loadDb().users); + }, + + async getUser(id) { + await delay(); + return structuredClone(requireUser(loadDb(), id)); + }, + + async inviteUser({ email, roleIds }) { + await delay(); + const db = loadDb(); + const normalized = email.trim().toLowerCase(); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalized)) + fail("INVALID_EMAIL", "That doesn't look like an email address."); + if (db.users.some((u) => u.email.toLowerCase() === normalized)) + fail("EMAIL_TAKEN", "A member with this email already exists."); + if (roleIds.length === 0) fail("NO_ROLES", "Pick at least one role."); + const inviter = db.users.find((u) => u.id === db.sessionUserId); + const user: User = { + id: `u-${crypto.randomUUID().slice(0, 8)}`, + displayName: normalized, + email: normalized, + roleIds: [...roleIds], + status: "invited", + microsoftLinked: false, + createdOn: new Date().toISOString(), + }; + const invite: Invite = { + token: newToken("inv"), + email: normalized, + roleIds: [...roleIds], + roleNames: [], + invitedByName: inviter?.displayName ?? "Unknown", + createdOn: new Date().toISOString(), + expiresOn: new Date(Date.now() + INVITE_LIFETIME_DAYS * 86_400_000).toISOString(), + }; + db.users.push(user); + db.invites.push(invite); + saveDb(db); + return inviteDto(db, invite); + }, + + async getInviteForUser(userId) { + await delay(); + const db = loadDb(); + const user = requireUser(db, userId); + const invite = db.invites.find((i) => i.email === user.email); + return invite ? inviteDto(db, invite) : null; + }, + + async resendInvite(userId) { + await delay(); + const db = loadDb(); + const user = requireUser(db, userId); + const invite = db.invites.find((i) => i.email === user.email); + if (!invite) fail("NOT_FOUND", "There's no pending invite for this member."); + invite!.token = newToken("inv"); + invite!.createdOn = new Date().toISOString(); + invite!.expiresOn = new Date(Date.now() + INVITE_LIFETIME_DAYS * 86_400_000).toISOString(); + saveDb(db); + return inviteDto(db, invite!); + }, + + async revokeInvite(userId) { + await delay(); + const db = loadDb(); + const user = requireUser(db, userId); + if (user.status !== "invited") fail("NOT_INVITED", "This member already joined."); + db.invites = db.invites.filter((i) => i.email !== user.email); + db.users = db.users.filter((u) => u.id !== userId); + saveDb(db); + }, + + async getInvite(token) { + await delay(); + const db = loadDb(); + const invite = db.invites.find((i) => i.token === token); + if (!invite) fail("INVALID_TOKEN", "This invite link is no longer valid."); + if (new Date(invite!.expiresOn) < new Date()) + fail("EXPIRED", "This invite has expired. Ask for a new one."); + return inviteDto(db, invite!); + }, + + async acceptInvite(token, { displayName, password }) { + await delay(); + const db = loadDb(); + const invite = db.invites.find((i) => i.token === token); + if (!invite) fail("INVALID_TOKEN", "This invite link is no longer valid."); + if (displayName.trim().length < 2) fail("INVALID_NAME", "Tell us your name."); + if (password.length < 8) fail("WEAK_PASSWORD", "Use at least 8 characters."); + const user = db.users.find((u) => u.email === invite!.email); + if (!user) fail("NOT_FOUND", "This invite's account was removed."); + user!.displayName = displayName.trim(); + user!.status = "active"; + db.passwords[user!.id] = password; + db.invites = db.invites.filter((i) => i.token !== token); + return startSession(db, user!); + }, + + async updateUserRoles(id, roleIds) { + await delay(); + const db = loadDb(); + const user = requireUser(db, id); + if (roleIds.length === 0) fail("NO_ROLES", "Members need at least one role."); + if (roleIds.some((r) => !db.roles.some((role) => role.id === r))) + fail("NOT_FOUND", "One of those roles no longer exists."); + if (wouldOrphanAdmins(db, (u) => (u.id === id ? { ...u, roleIds } : u))) + fail("LAST_ADMIN", "This is the only active administrator — grant the role to someone else first."); + user.roleIds = [...roleIds]; + saveDb(db); + return structuredClone(user); + }, + + async setUserDisabled(id, disabled) { + await delay(); + const db = loadDb(); + const user = requireUser(db, id); + if (id === db.sessionUserId) fail("SELF", "You can't disable your own account."); + if (disabled && wouldOrphanAdmins(db, (u) => (u.id === id ? { ...u, status: "disabled" } : u))) + fail("LAST_ADMIN", "This is the only active administrator — grant the role to someone else first."); + user.status = disabled ? "disabled" : "active"; + saveDb(db); + return structuredClone(user); + }, + + async deleteUser(id) { + await delay(); + const db = loadDb(); + const user = requireUser(db, id); + if (id === db.sessionUserId) fail("SELF", "You can't remove your own account."); + if (wouldOrphanAdmins(db, (u) => (u.id === id ? null : u))) + fail("LAST_ADMIN", "This is the only active administrator — grant the role to someone else first."); + db.users = db.users.filter((u) => u.id !== id); + db.invites = db.invites.filter((i) => i.email !== user.email); + delete db.passwords[id]; + saveDb(db); + }, + + async adminResetPassword(id) { + await delay(); + const db = loadDb(); + const user = requireUser(db, id); + if (user.status !== "active") fail("NOT_ACTIVE", "Only active members can reset a password."); + const token = newToken("rst"); + db.resetTokens[token] = id; + saveDb(db); + return { resetLink: `${location.origin}${import.meta.env.BASE_URL}reset-password/${token}` }; + }, + + async getPermissionCatalog() { + await delay(); + return structuredClone(PERMISSION_CATALOG); + }, + + async listRoles() { + await delay(); + const db = loadDb(); + return db.roles.map((r) => withMemberCount(db, structuredClone(r))); + }, + + async getRole(id) { + await delay(); + const db = loadDb(); + const role = db.roles.find((r) => r.id === id); + if (!role) fail("NOT_FOUND", "This role no longer exists."); + return withMemberCount(db, structuredClone(role!)); + }, + + async createRole({ name, description, permissions }) { + await delay(); + const db = loadDb(); + const trimmed = name.trim(); + if (trimmed.length < 2) fail("INVALID_NAME", "Give the role a name."); + if (db.roles.some((r) => r.name.toLowerCase() === trimmed.toLowerCase())) + fail("NAME_TAKEN", "A role with this name already exists."); + if (permissions.length === 0) fail("NO_PERMISSIONS", "Pick at least one permission."); + const role: SeedRole = { + id: `role-${crypto.randomUUID().slice(0, 8)}`, + name: trimmed, + description: description.trim(), + permissions: [...new Set(permissions)], + isSystem: false, + createdOn: new Date().toISOString(), + }; + db.roles.push(role); + saveDb(db); + return withMemberCount(db, structuredClone(role)); + }, + + async updateRole(id, { name, description, permissions }) { + await delay(); + const db = loadDb(); + const role = db.roles.find((r) => r.id === id); + if (!role) fail("NOT_FOUND", "This role no longer exists."); + if (role!.isSystem) fail("SYSTEM_ROLE", "Built-in roles can't be changed."); + const trimmed = name.trim(); + if (trimmed.length < 2) fail("INVALID_NAME", "Give the role a name."); + if (db.roles.some((r) => r.id !== id && r.name.toLowerCase() === trimmed.toLowerCase())) + fail("NAME_TAKEN", "A role with this name already exists."); + if (permissions.length === 0) fail("NO_PERMISSIONS", "Pick at least one permission."); + role!.name = trimmed; + role!.description = description.trim(); + role!.permissions = [...new Set(permissions)]; + saveDb(db); + return withMemberCount(db, structuredClone(role!)); + }, + + async deleteRole(id) { + await delay(); + const db = loadDb(); + const role = db.roles.find((r) => r.id === id); + if (!role) fail("NOT_FOUND", "This role no longer exists."); + if (role!.isSystem) fail("SYSTEM_ROLE", "Built-in roles can't be deleted."); + const members = db.users.filter((u) => u.roleIds.includes(id)).length; + if (members > 0) + fail("ROLE_IN_USE", `${members} member${members === 1 ? "" : "s"} still hold this role — move them to another role first.`); + db.roles = db.roles.filter((r) => r.id !== id); + saveDb(db); + }, + + demo: { + async listPersonas() { + await delay(); + return structuredClone(loadDb().users); + }, + async switchTo(userId) { + await delay(); + const db = loadDb(); + const user = requireUser(db, userId); + if (user.status !== "active") fail("NOT_ACTIVE", "Only active members can be impersonated."); + return startSession(db, user); + }, + async reset() { + await delay(); + resetDb(); + }, + }, +}; diff --git a/SW.Bitween.Web/ClientApp/src/api/mock/seed.ts b/SW.Bitween.Web/ClientApp/src/api/mock/seed.ts new file mode 100644 index 00000000..6169b5bc --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/api/mock/seed.ts @@ -0,0 +1,155 @@ +import { ALL_PERMISSIONS, permissionKey } from "../permissions"; +import type { Invite, PermissionKey, Role, User } from "../types"; + +/** Everything a role needs for the given areas/actions, view included. */ +const grants = (spec: Record): PermissionKey[] => + Object.entries(spec).flatMap(([area, actions]) => + actions.map((a) => permissionKey(area, a as never)), + ); + +const daysAgo = (n: number) => new Date(Date.now() - n * 86_400_000).toISOString(); +const daysAhead = (n: number) => new Date(Date.now() + n * 86_400_000).toISOString(); + +export interface SeedRole extends Omit {} + +export const SEED_ROLES: SeedRole[] = [ + { + id: "role-administrator", + name: "Administrator", + description: + "Full access to everything, including members, roles and settings. Built in — always holds every permission.", + permissions: [...ALL_PERMISSIONS], + isSystem: true, + createdOn: daysAgo(400), + }, + { + id: "role-integration-manager", + name: "Integration manager", + description: + "Builds and maintains integrations: subscriptions, gateways, partners and configuration. No member or role management.", + permissions: grants({ + dashboard: ["view"], + exchanges: ["view", "operate"], + monitoring: ["view"], + subscriptions: ["view", "create", "edit", "delete", "operate"], + "api-gateways": ["view", "create", "edit", "delete"], + "bus-gateways": ["view", "create", "edit", "delete"], + workgroups: ["view", "create", "edit", "delete"], + partners: ["view", "create", "edit", "delete"], + documents: ["view", "create", "edit", "delete"], + "global-values": ["view", "create", "edit", "delete"], + "retry-policies": ["view", "create", "edit", "delete", "operate"], + notifiers: ["view", "create", "edit", "delete"], + }), + isSystem: false, + createdOn: daysAgo(320), + }, + { + id: "role-operator", + name: "Operator", + description: + "Runs day-to-day traffic: watches exchanges, retries failures, pauses and resumes — without changing configuration.", + permissions: grants({ + dashboard: ["view"], + exchanges: ["view", "operate"], + monitoring: ["view"], + subscriptions: ["view", "operate"], + "api-gateways": ["view"], + "bus-gateways": ["view"], + workgroups: ["view"], + partners: ["view"], + documents: ["view"], + "global-values": ["view"], + "retry-policies": ["view", "operate"], + notifiers: ["view"], + }), + isSystem: false, + createdOn: daysAgo(320), + }, + { + id: "role-auditor", + name: "Auditor", + description: "Read-only access to everything, including members and roles.", + permissions: ALL_PERMISSIONS.filter((p) => p.endsWith(".view")), + isSystem: false, + createdOn: daysAgo(200), + }, +]; + +export const SEED_USERS: User[] = [ + { + id: "u-lina", + displayName: "Lina Haddad", + email: "lina@northline.co", + phone: "+962 79 000 1111", + roleIds: ["role-administrator"], + status: "active", + microsoftLinked: false, + createdOn: daysAgo(400), + lastActiveOn: daysAgo(0), + }, + { + id: "u-omar", + displayName: "Omar Nasser", + email: "omar@northline.co", + roleIds: ["role-integration-manager"], + status: "active", + microsoftLinked: false, + createdOn: daysAgo(310), + lastActiveOn: daysAgo(1), + }, + { + id: "u-sara", + displayName: "Sara Kanaan", + email: "sara@northline.co", + roleIds: ["role-operator"], + status: "active", + microsoftLinked: true, + createdOn: daysAgo(150), + lastActiveOn: daysAgo(0), + }, + { + id: "u-jude", + displayName: "Jude Farah", + email: "jude@northline.co", + roleIds: ["role-auditor"], + status: "active", + microsoftLinked: false, + createdOn: daysAgo(90), + lastActiveOn: daysAgo(6), + }, + { + id: "u-tariq", + displayName: "Tariq Salem", + email: "tariq@northline.co", + roleIds: ["role-integration-manager"], + status: "disabled", + microsoftLinked: false, + createdOn: daysAgo(280), + lastActiveOn: daysAgo(60), + }, + { + id: "u-dana", + displayName: "dana@northline.co", + email: "dana@northline.co", + roleIds: ["role-operator"], + status: "invited", + microsoftLinked: false, + createdOn: daysAgo(2), + }, +]; + +export const SEED_INVITES: Invite[] = [ + { + token: "inv-dana-7f3a", + email: "dana@northline.co", + roleIds: ["role-operator"], + roleNames: ["Operator"], + invitedByName: "Lina Haddad", + createdOn: daysAgo(2), + expiresOn: daysAhead(5), + }, +]; + +/** Demo password shared by all seeded active accounts. */ +export const DEMO_PASSWORD = "bitween"; diff --git a/SW.Bitween.Web/ClientApp/src/api/mock/seedConfig.ts b/SW.Bitween.Web/ClientApp/src/api/mock/seedConfig.ts new file mode 100644 index 00000000..4b1d1fdf --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/api/mock/seedConfig.ts @@ -0,0 +1,920 @@ +import type { + ApiGateway, + BusGateway, + ExchangeDocument, + ExchangeRef, + GlobalValuesSet, + InformationType, + Integration, + MatchGroup, + NotificationEntry, + Notifier, + Partner, + RetryPolicy, + Setting, + TrailEntry, + WorkGroup, +} from "../types"; + +const daysAgo = (n: number) => new Date(Date.now() - n * 86_400_000).toISOString(); +const minutesAgo = (n: number) => new Date(Date.now() - n * 60_000).toISOString(); + +/** Full credential keys, kept mock-side only; the API surface exposes prefixes. */ +export interface StoredCredential { + partnerId: number; + name: string; + key: string; + createdOn: string; +} + +export const SEED_PARTNERS: Partner[] = [ + { + id: 1, + name: "SYSTEM", + adapterProperties: {}, + isSystem: true, + createdOn: daysAgo(400), + }, + { + id: 2, + name: "Coral Retail", + adapterProperties: { + storeId: "CR-114", + callbackUrl: "https://coral-retail.example/webhooks/bitween", + }, + isSystem: false, + createdOn: daysAgo(320), + }, + { + id: 3, + name: "SwiftShip Couriers", + adapterProperties: { + accountNumber: "SS-88210", + region: "MENA", + }, + isSystem: false, + createdOn: daysAgo(210), + }, + { + id: 4, + name: "Atlas Freight", + adapterProperties: {}, + isSystem: false, + createdOn: daysAgo(40), + }, +]; + +export const SEED_CREDENTIALS: StoredCredential[] = [ + { partnerId: 1, name: "System key", key: "7facc758283844b49cc4ffd26a75b1de", createdOn: daysAgo(400) }, + { partnerId: 2, name: "Production", key: "b31c09d41f2a4c5e8a67d90f13b8ae52", createdOn: daysAgo(310) }, + { partnerId: 2, name: "Staging", key: "0d94ee12aa374f6bb2c81f4de95c7301", createdOn: daysAgo(90) }, + { partnerId: 3, name: "Default", key: "5a7d20c3ef98442e9b10c66a84d2f7b9", createdOn: daysAgo(200) }, +]; + +export const SEED_INFORMATION_TYPES: InformationType[] = [ + { + id: 1001, + code: "PURCHASE_ORDER", + name: "Purchase order", + format: "Json", + busEnabled: true, + busMessageTypeName: "purchase-order", + duplicateIntervalMinutes: 60, + disregardsUnfilteredMessages: false, + promotedProperties: [ + { key: "OrderNumber", path: "$.order.id" }, + { key: "Store", path: "$.order.storeId" }, + ], + createdOn: daysAgo(320), + }, + { + id: 1002, + code: "SHIPMENT_UPDATE", + name: "Shipment status update", + format: "Json", + busEnabled: true, + busMessageTypeName: "shipment-update", + duplicateIntervalMinutes: 0, + disregardsUnfilteredMessages: true, + promotedProperties: [ + { key: "TrackingNumber", path: "$.tracking.number" }, + { key: "Status", path: "$.tracking.status" }, + ], + createdOn: daysAgo(210), + }, + { + id: 1003, + code: "INVOICE", + name: "Customer invoice", + format: "Xml", + busEnabled: false, + duplicateIntervalMinutes: 1440, + disregardsUnfilteredMessages: false, + promotedProperties: [{ key: "InvoiceNumber", path: "//Invoice/Number" }], + createdOn: daysAgo(180), + }, + { + id: 10001, + code: "AGGREGATION_RESULT", + name: "Aggregation result", + format: "Json", + busEnabled: false, + duplicateIntervalMinutes: 0, + disregardsUnfilteredMessages: false, + promotedProperties: [], + createdOn: daysAgo(400), + }, +]; + +export const SEED_TRAILS: Record = { + 1001: [ + { on: daysAgo(320), action: "Created", by: "Lina Haddad", byUserId: "u-lina" }, + { on: daysAgo(35), action: "Updated", by: "Omar Nasser", byUserId: "u-omar" }, + ], + 1002: [{ on: daysAgo(210), action: "Created", by: "Omar Nasser", byUserId: "u-omar" }], + 1003: [{ on: daysAgo(180), action: "Created", by: "Lina Haddad", byUserId: "u-lina" }], + 10001: [{ on: daysAgo(400), action: "Created", by: "Admin" }], +}; + +export const SEED_VALUE_SETS: GlobalValuesSet[] = [ + { + id: "sap-prod", + name: "SAP production", + values: { + baseUrl: "https://sap.northline.example", + client: "100", + companyCode: "NL01", + }, + createdOn: daysAgo(300), + }, + { + id: "warehouse-defaults", + name: "Warehouse defaults", + values: { + pickupLocation: "AMM-Central-01", + timezone: "Asia/Amman", + }, + createdOn: daysAgo(120), + }, +]; + +/** Common defaults so integration seeds stay readable. */ +const baseIntegration = { + partnerId: null, + enabled: true, + pausedOn: null, + workGroupId: null, + retryPolicyId: null, + receiverId: null, + receiverProperties: {}, + validatorId: null, + validatorProperties: {}, + mapperId: null, + mapperProperties: {}, + handlerId: null, + handlerProperties: {}, + matchExpression: null, + schedules: [], + responseIntegrationId: null, + responseMessageTypeName: null, + aggregationForId: null, + isRunning: false, + lastReceiveOn: null, + consecutiveFailures: 0, + lastException: null, +} satisfies Partial; + +export const SEED_INTEGRATIONS: Integration[] = [ + { + ...baseIntegration, + id: 501, + name: "Coral orders receiver", + type: "Receiving", + informationTypeId: 1001, + retryPolicyId: 1, + receiverId: "NativeHttpReceiver", + receiverProperties: { + url: "https://coral-retail.example/api/orders?status=new", + authHeader: "coral-demo-token-not-real", + }, + handlerId: "NativeHttpHandler", + handlerProperties: { + url: "{{globals.sap-prod.baseUrl}}/orders/import?client={{globals.sap-prod.client}}", + method: "POST", + }, + schedules: [{ recurrence: "Hourly", days: 0, hours: 0, minutes: 15, backwards: false }], + lastReceiveOn: minutesAgo(48), + createdOn: daysAgo(300), + }, + { + ...baseIntegration, + id: 502, + name: "SwiftShip tracking sync", + type: "ApiCall", + informationTypeId: 1002, + partnerId: 3, + retryPolicyId: 1, + handlerId: "NativeHttpHandler", + handlerProperties: { + url: "https://api.swiftship.example/track/{{partner.accountNumber}}", + method: "GET", + }, + consecutiveFailures: 2, + lastException: "HttpRequestException: 504 Gateway Timeout from api.swiftship.example", + createdOn: daysAgo(280), + }, + { + ...baseIntegration, + id: 503, + name: "Invoice dispatch", + type: "BusGateway", + informationTypeId: 1003, + workGroupId: 2, + mapperId: "SW.Infolink.Adapters.Mappers.Liquid", + mapperProperties: { + template: '{"invoice": "{{ doc.Invoice.Number }}", "total": {{ doc.Invoice.Total }}}', + }, + handlerId: "NativeHttpHandler", + handlerProperties: { + url: "{{partner.callbackUrl}}", + method: "POST", + headers: "X-Company: {{globals.sap-prod.companyCode}}", + }, + createdOn: daysAgo(180), + }, + { + ...baseIntegration, + id: 504, + name: "Nightly PO aggregation", + type: "Aggregation", + informationTypeId: 10001, + retryPolicyId: 2, + aggregationForId: 501, + handlerId: "NativeS3UploadHandler", + handlerProperties: { + bucket: "northline-agg", + region: "me-south-1", + accessKey: "AKIA-demo", + secretKey: "s3-demo-secret", + keyTemplate: "po-{{globals.warehouse-defaults.timezone}}/{date}.json", + }, + schedules: [{ recurrence: "Daily", days: 0, hours: 2, minutes: 0, backwards: false }], + createdOn: daysAgo(200), + }, + { + ...baseIntegration, + id: 505, + name: "Coral orders intake", + type: "GatewayApiCall", + informationTypeId: 1001, + validatorId: "SW.Infolink.Adapters.Validators.JsonSchema", + validatorProperties: { schema: '{ "required": ["order"] }' }, + handlerId: "NativeHttpHandler", + handlerProperties: { + url: "{{globals.sap-prod.baseUrl}}/orders/import", + method: "POST", + headers: "X-Store: {{partner.storeId}}", + }, + createdOn: daysAgo(290), + }, + { + ...baseIntegration, + id: 506, + name: "SwiftShip tracking intake", + type: "GatewayApiCall", + informationTypeId: 1002, + handlerId: "NativeHttpHandler", + handlerProperties: { url: "https://tms.northline.example/api/tracking-events", method: "POST" }, + createdOn: daysAgo(200), + }, + { + ...baseIntegration, + id: 507, + name: "Coral PO forwarder", + type: "BusGateway", + informationTypeId: 1001, + handlerId: "NativeS3UploadHandler", + handlerProperties: { + bucket: "coral-po-archive", + region: "me-south-1", + accessKey: "AKIA-demo", + secretKey: "s3-demo-secret", + }, + createdOn: daysAgo(120), + }, +]; + +export const SEED_WORK_GROUPS: WorkGroup[] = [ + { + id: 1, + name: "Priority lane", + busMessageName: "priority-lane", + options: { rabbitMqOptions: { consumerSettings: { prefetch: 4, priority: 10 } } }, + createdOn: daysAgo(260), + }, + { + id: 2, + name: "Bulk uploads", + busMessageName: "bulk-uploads", + options: { rabbitMqOptions: { consumerSettings: { prefetch: 32, priority: 1 } } }, + createdOn: daysAgo(180), + }, + { + id: 3, + name: "Nightly batches", + busMessageName: "nightly-batches", + options: { rabbitMqOptions: { consumerSettings: { prefetch: 16, priority: 3 } } }, + createdOn: daysAgo(60), + }, +]; + +export const SEED_INTEGRATION_TRAILS: Record = { + 501: [ + { on: daysAgo(300), action: "Created", by: "Lina Haddad", byUserId: "u-lina" }, + { on: daysAgo(20), action: "Updated", by: "Omar Nasser", byUserId: "u-omar" }, + ], + 505: [{ on: daysAgo(290), action: "Created", by: "Lina Haddad", byUserId: "u-lina" }], +}; + +export const SEED_RETRY_POLICIES: RetryPolicy[] = [ + { + id: 1, + name: "Transient failures", + createdOn: daysAgo(150), + groups: [ + { + id: "g-timeouts", + name: "Timeouts & connection drops", + priority: 10, + enabled: true, + appliesTo: ["Error"], + matchers: [ + { type: "contains", value: "timeout", caseSensitive: false }, + { type: "exceptionType", value: "HttpRequestException", includeInner: true }, + ], + action: "Allow", + budget: { + maxAttemptsPerError: 3, + maxAttemptsTotal: 10, + delay: { type: "exponential", initialSeconds: 30, multiplier: 2, maxSeconds: 900 }, + }, + notes: "Network blips — safe to retry aggressively.", + }, + { + id: "g-auth", + name: "Authentication failures", + priority: 20, + enabled: true, + appliesTo: ["Error"], + matchers: [{ type: "contains", value: "401", caseSensitive: false }], + action: "Block", + notes: "Retrying won't fix a bad credential — alert instead.", + }, + ], + }, + { + id: 2, + name: "Careful nightly jobs", + createdOn: daysAgo(60), + groups: [ + { + id: "g-any", + name: "Any failure", + priority: 10, + enabled: true, + appliesTo: ["Error", "BadResult"], + matchers: [], + action: "Allow", + budget: { + maxAttemptsPerError: 2, + maxAttemptsTotal: 4, + delay: { type: "fixed", delaySeconds: 600 }, + }, + }, + ], + }, +]; + +export const SEED_NOTIFIERS: Notifier[] = [ + { + id: 1, + name: "Ops email on failures", + enabled: true, + onFailed: true, + onBadResult: true, + onSuccess: false, + channelId: "sendgrid", + channelProperties: { + apiKey: "SG.demo-key-not-real", + from: "bitween@northline.example", + to: "ops@northline.example", + }, + integrationIds: [501, 502, 505], + createdOn: daysAgo(140), + }, + { + id: 2, + name: "Teams alert — invoice dispatch", + enabled: true, + onFailed: true, + onBadResult: false, + onSuccess: false, + channelId: "msteams", + channelProperties: { + webhookUrl: "https://outlook.office.com/webhook/a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d/IncomingWebhook/demo", + }, + integrationIds: [503], + createdOn: daysAgo(35), + }, +]; + +/** Delivery history for the seeded notifiers, referencing seeded exchange ids. */ +export const seedNotifications = (): (NotificationEntry & { notifierId: number })[] => { + const xid = (i: number) => `0x8DD${(0x41f000 + i * 3271).toString(16).toUpperCase()}`; + return [ + { notifierId: 1, xchangeId: xid(2), success: true, on: minutesAgo(6 + 2 * 47) }, + { + notifierId: 1, + xchangeId: xid(14), + success: false, + exception: "SendGrid responded 401 Unauthorized — check the API key.", + on: minutesAgo(6 + 14 * 47), + }, + { notifierId: 1, xchangeId: xid(20), success: true, on: minutesAgo(6 + 20 * 47) }, + ]; +}; + +/** Gateways as stored: attachments/routes are raw id links; names resolve at read time. */ +export type StoredApiGateway = ApiGateway & { + attachments: { partnerId: number; integrationId: number }[]; +}; +export type StoredBusGateway = BusGateway & { + routes: { id: number; integrationId: number; partnerId: number | null; matchExpression: MatchGroup | null }[]; +}; + +export const SEED_API_GATEWAYS: StoredApiGateway[] = [ + { + id: 1, + name: "Orders inbound", + urlName: "orders", + createdOn: daysAgo(290), + attachments: [{ partnerId: 2, integrationId: 505 }], + }, + { + id: 2, + name: "Tracking inbound", + urlName: "tracking", + createdOn: daysAgo(200), + attachments: [{ partnerId: 3, integrationId: 506 }], + }, +]; + +export const SEED_BUS_GATEWAYS: StoredBusGateway[] = [ + { + id: 1, + name: "ERP events", + informationTypeId: 1001, + createdOn: daysAgo(250), + routes: [ + { + id: 1, + integrationId: 507, + partnerId: 2, + matchExpression: { op: "and", children: [{ op: "oneOf", path: "Store", values: ["CR-114"] }] }, + }, + ], + }, + { + id: 2, + name: "Invoice events", + informationTypeId: 1003, + createdOn: daysAgo(150), + routes: [{ id: 1, integrationId: 503, partnerId: 2, matchExpression: null }], + }, +]; + +/** Length is coprime-ish with the source mix so failures spread across types. */ +const EXCHANGE_STATUSES: ExchangeRef["status"][] = [ + "success", "success", "failed", "success", "processing", "success", +]; + +/** + * Small sample documents per pipeline stage (input → mapped → handled) so + * exchange rows can expand into per-stage previews. Failed and in-flight + * exchanges only carry the input document. + */ +const SAMPLE_DOCS: Record { input: string; mapped?: string; handled?: string }> = { + 1001: (i) => ({ + input: JSON.stringify( + { order: { id: `PO-1${String(i).padStart(3, "0")}`, storeId: "CR-114", lines: [{ sku: "SKU-2231", qty: 3 + (i % 4) }], total: 118.4 + i } }, + null, + 2, + ), + mapped: JSON.stringify( + { OrderHeader: { DocNum: `PO-1${String(i).padStart(3, "0")}`, SoldTo: "CR-114", Currency: "JOD" }, Items: [{ Material: "SKU-2231", Quantity: 3 + (i % 4) }] }, + null, + 2, + ), + handled: JSON.stringify({ sapDocumentNumber: `45000${1200 + i}`, status: "CREATED" }, null, 2), + }), + 1002: (i) => ({ + input: JSON.stringify( + { tracking: { number: `SS-7${String(i).padStart(4, "0")}`, status: i % 3 === 0 ? "OUT_FOR_DELIVERY" : "IN_TRANSIT", eta: "2026-07-14T09:00:00Z" } }, + null, + 2, + ), + mapped: JSON.stringify( + { shipment: { reference: `SS-7${String(i).padStart(4, "0")}`, state: i % 3 === 0 ? "out-for-delivery" : "in-transit", expectedOn: "2026-07-14" } }, + null, + 2, + ), + handled: JSON.stringify({ acknowledged: true, storeNotified: i % 3 === 0 }, null, 2), + }), + 1003: (i) => ({ + input: `\n INV-9${String(i).padStart(3, "0")}\n NL01\n ${(340 + i * 7).toFixed(2)}\n`, + mapped: JSON.stringify( + { invoice: { number: `INV-9${String(i).padStart(3, "0")}`, companyCode: "NL01", total: Number((340 + i * 7).toFixed(2)), currency: "JOD" } }, + null, + 2, + ), + handled: JSON.stringify({ delivered: true, channel: "sftp", file: `INV-9${String(i).padStart(3, "0")}.xml` }, null, 2), + }), + 10001: (i) => ({ + input: JSON.stringify({ files: [`https://files.northline.example/agg/${i}-a.json`, `https://files.northline.example/agg/${i}-b.json`] }, null, 2), + handled: JSON.stringify({ forwarded: true, fileCount: 2 }, null, 2), + }), +}; + +const stageDocuments = ( + docs: { input: string; mapped?: string; handled?: string }, + status: ExchangeRef["status"], +): ExchangeDocument[] => { + if (status !== "success") return [{ stage: "Input", content: docs.input }]; + return [ + { stage: "Input" as const, content: docs.input }, + ...(docs.mapped ? [{ stage: "Mapped" as const, content: docs.mapped }] : []), + ...(docs.handled ? [{ stage: "Handled" as const, content: docs.handled }] : []), + ]; +}; + +export type SeedExchange = ExchangeRef & { + partnerId: number | null; + informationTypeId: number; + integrationId: number; +}; + +/** Deterministic-ish recent traffic across partners, types and integrations. */ +export const seedExchanges = (): SeedExchange[] => { + const mix = [ + { partnerId: 2, informationTypeId: 1001, integrationId: 505 }, + { partnerId: 2, informationTypeId: 1003, integrationId: 503 }, + { partnerId: 3, informationTypeId: 1002, integrationId: 502 }, + { partnerId: null, informationTypeId: 10001, integrationId: 504 }, + { partnerId: null, informationTypeId: 1001, integrationId: 501 }, + ]; + return Array.from({ length: 24 }, (_, i) => { + const source = mix[i % mix.length]; + const status = EXCHANGE_STATUSES[i % EXCHANGE_STATUSES.length]; + const docs = SAMPLE_DOCS[source.informationTypeId]?.(i); + return { + id: `0x8DD${(0x41f000 + i * 3271).toString(16).toUpperCase()}`, + partnerId: source.partnerId, + informationTypeId: source.informationTypeId, + integrationId: source.integrationId, + partnerName: undefined, + informationTypeCode: "", + status, + on: minutesAgo(8 + i * 47), + documents: docs ? stageDocuments(docs, status) : undefined, + }; + }); +}; + +export const SEED_SETTINGS: Setting[] = [ + // ——— Documents & storage ——— + { + key: "Bitween.DocumentPrefix", + section: "Documents & storage", + label: "Document path prefix", + description: + "Add a custom prefix here if you want generated exchange documents stored under a different folder in the storage bucket — useful for keeping environments or tenants in separate trees.", + kind: "string", + defaultValue: "temp30/Bitweendocs", + value: null, + secret: false, + restartRequired: false, + }, + { + key: "Bitween.AreXChangeFilesPrivate", + section: "Documents & storage", + label: "Keep exchange files private", + description: + "Turn this on if you want generated exchange files kept private and served through short-lived signed links instead of public URLs.", + kind: "boolean", + defaultValue: "false", + value: null, + secret: false, + restartRequired: false, + }, + + // ——— API behavior ——— + { + key: "Bitween.ApiCallSubscriptionResponseAcceptedStatusCode", + section: "API behavior", + label: "Accepted response status code", + description: + "Change this if a partner's API expects a different HTTP status code (instead of 202 Accepted) when their request has been queued for async processing.", + kind: "number", + defaultValue: "202", + value: null, + secret: false, + restartRequired: false, + }, + { + key: "Bitween.JwtExpiryMinutes", + section: "API behavior", + label: "Sign-in session length (minutes)", + description: + "Shorten this for tighter session security, or lengthen it if teammates are being signed out more often than you'd like.", + kind: "number", + defaultValue: "60", + value: "120", + secret: false, + restartRequired: false, + }, + + // ——— Single sign-on (Microsoft) ——— + { + key: "Bitween.MsalClientId", + section: "Single sign-on (Microsoft)", + label: "Azure AD client ID", + description: + "Add this — together with the tenant ID and redirect URI below — if you want to let teammates sign in with a Microsoft account. All three are required for Microsoft sign-in to turn on.", + kind: "string", + defaultValue: "", + value: null, + secret: true, + restartRequired: false, + }, + { + key: "Bitween.MsalTenantId", + section: "Single sign-on (Microsoft)", + label: "Azure AD tenant ID", + description: "The Azure AD tenant Microsoft sign-in is restricted to. Required alongside the client ID and redirect URI.", + kind: "string", + defaultValue: "", + value: null, + secret: true, + restartRequired: false, + }, + { + key: "Bitween.MsalRedirectUri", + section: "Single sign-on (Microsoft)", + label: "Azure AD redirect URI", + description: + "The URL Azure AD sends users back to after signing in — must match the redirect URI registered on the Azure AD app. Required alongside the client ID and tenant ID.", + kind: "string", + defaultValue: "", + value: null, + secret: true, + restartRequired: false, + }, + + // ——— Messaging ——— + { + key: "Bitween.BusDefaultQueuePrefetch", + section: "Messaging", + label: "Default queue prefetch", + description: + "Raise this if consumers are sitting idle while messages queue up; lower it if one busy queue is starving the others of a fair share.", + kind: "number", + defaultValue: "12", + value: "24", + secret: false, + restartRequired: true, + }, + + // ——— Adapters ——— + { + key: "Bitween.RebexLicenseKey", + section: "Adapters", + label: "Rebex POP3 license key", + description: + "Add this if you want to use the native Rebex POP3 receiver adapter for partners that deliver over POP3 — without a key, that adapter isn't offered when picking a receiver.", + kind: "string", + defaultValue: "", + value: null, + secret: true, + restartRequired: true, + }, + + // ——— Reliability & jobs ——— + { + key: "Bitween.ServerlessCommandTimeout", + section: "Reliability & jobs", + label: "Serverless command timeout (seconds)", + description: + "Increase this if long-running serverless commands are being cancelled before they finish; decrease it to fail fast on commands that hang.", + kind: "number", + defaultValue: "300", + value: null, + secret: false, + restartRequired: true, + }, + { + key: "Bitween.ConsumeLegacyEventMessages", + section: "Reliability & jobs", + label: "Consume legacy event messages", + description: + "Keep this on while some publishers still emit the legacy (pre-migration) event shape. Turn it off once every publisher has moved to the current format.", + kind: "boolean", + defaultValue: "false", + value: null, + secret: false, + restartRequired: true, + }, + { + key: "Bitween.RetryJobCron", + section: "Reliability & jobs", + label: "Auto-retry poll schedule (cron)", + description: + "Change this cron expression if you want the auto-retry job to check for due exchanges more or less often than once a minute.", + kind: "string", + defaultValue: "0 * * * * ?", + value: null, + secret: false, + restartRequired: true, + }, + + // ——— Security ——— + { + key: "Bitween.CorsOrigins", + section: "Security", + label: "Allowed CORS origins", + description: + "List the browser origins (e.g. https://your-app.com) that should be allowed to call the API with cookies attached. Add one if you're building a browser app that needs cookie-based auth against this API; leave empty to allow any origin without credentials.", + kind: "string[]", + defaultValue: "", + value: null, + secret: false, + restartRequired: true, + }, + + // ——— Brand & theme ——— + { + key: "Theme.PrimaryColor", + section: "Brand & theme", + label: "Primary color", + description: + "Re-brands the whole app's accent color — buttons, links, active nav, focus rings — instantly, without waiting on a deploy.", + kind: "color", + defaultValue: "#e3311d", + value: null, + secret: false, + restartRequired: false, + }, + { + key: "Theme.CompanyName", + section: "Brand & theme", + label: "Company name", + description: "Shown in the footer and used in a few page titles.", + kind: "string", + defaultValue: "Simplify9", + value: null, + secret: false, + restartRequired: false, + }, + { + key: "Theme.TabTitle", + section: "Brand & theme", + label: "Browser tab title", + description: "What shows in the browser tab.", + kind: "string", + defaultValue: "Bitween", + value: null, + secret: false, + restartRequired: false, + }, + { + key: "Theme.TabIcon", + section: "Brand & theme", + label: "Favicon URL", + description: "The icon shown in the browser tab. Paste a URL to an .ico, .svg or .png.", + kind: "string", + defaultValue: "/favicon.ico", + value: null, + secret: false, + restartRequired: false, + }, + { + key: "Theme.LoginLogo", + section: "Brand & theme", + label: "Sign-in page logo", + description: "The logo shown above the sign-in form.", + kind: "string", + defaultValue: "/Graphics/s9.png", + value: null, + secret: false, + restartRequired: false, + }, + { + key: "Theme.BitweenLogo", + section: "Brand & theme", + label: "Sidebar logo", + description: "The full logo shown at the top of the sidebar.", + kind: "string", + defaultValue: "/Graphics/BitweenFull.svg", + value: null, + secret: false, + restartRequired: false, + }, + { + key: "Theme.BitweenIcon", + section: "Brand & theme", + label: "Collapsed sidebar icon", + description: "The compact icon shown when the sidebar is collapsed to icons only.", + kind: "string", + defaultValue: "/Graphics/BitweenIcon.png", + value: null, + secret: false, + restartRequired: false, + }, + { + key: "Theme.BitweenHeaderIcon", + section: "Brand & theme", + label: "Mobile header icon", + description: "Icon variant used in the mobile top bar.", + kind: "string", + defaultValue: "/Graphics/BitweenIcon.svg", + value: null, + secret: false, + restartRequired: false, + }, + { + key: "Theme.BitweenText", + section: "Brand & theme", + label: "Sign-in page blurb", + description: "The marketing description shown beside the sign-in form.", + kind: "string", + defaultValue: + "is all-in-one solution to solving integration with third parties, automating workflows with exchanges coming from all forms of requests, ranging from internal messages to files dumped on a server.", + value: null, + secret: false, + restartRequired: false, + }, + { + key: "Theme.LinkedinLink", + section: "Brand & theme", + label: "LinkedIn link", + description: "Add this if you want a LinkedIn link in the footer — leave blank to hide it.", + kind: "string", + defaultValue: "https://www.linkedin.com/company/simplify9", + value: null, + secret: false, + restartRequired: false, + }, + { + key: "Theme.GithubLink", + section: "Brand & theme", + label: "GitHub link", + description: "Add this if you want a GitHub link in the footer — leave blank to hide it.", + kind: "string", + defaultValue: "https://github.com/simplify9", + value: null, + secret: false, + restartRequired: false, + }, + { + key: "Theme.WebsiteLink", + section: "Brand & theme", + label: "Website link", + description: "Add this if you want a company website link in the footer — leave blank to hide it.", + kind: "string", + defaultValue: "https://www.simplify9.com/", + value: null, + secret: false, + restartRequired: false, + }, + { + key: "Theme.AllRightsReserved", + section: "Brand & theme", + label: "Copyright notice", + description: "The copyright notice text shown in the footer.", + kind: "string", + defaultValue: "All Rights Reserved.", + value: null, + secret: false, + restartRequired: false, + }, + { + key: "Theme.CopyRightsIcon", + section: "Brand & theme", + label: "Copyright symbol", + description: "The symbol shown before the copyright notice.", + kind: "string", + defaultValue: "©", + value: null, + secret: false, + restartRequired: false, + }, +]; diff --git a/SW.Bitween.Web/ClientApp/src/api/mock/settingsClient.ts b/SW.Bitween.Web/ClientApp/src/api/mock/settingsClient.ts new file mode 100644 index 00000000..01193d01 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/api/mock/settingsClient.ts @@ -0,0 +1,30 @@ +import type { ApiClient } from "../client"; +import { ApiRequestError, type SettingRow } from "../types"; +import { delay, loadDb, saveDb } from "./store"; + +const fail = (code: string, message: string): never => { + throw new ApiRequestError(code, message); +}; + +const settingRow = (setting: { value: string | null } & Omit): SettingRow => ({ + ...setting, + overridden: setting.value !== null, +}); + +export const settingsClient: Pick = { + async listSettings() { + await delay(); + const db = loadDb(); + return db.settings.map(settingRow); + }, + + async updateSetting(key, value) { + await delay(); + const db = loadDb(); + const setting = db.settings.find((s) => s.key === key); + if (!setting) fail("NOT_FOUND", "This setting no longer exists."); + setting!.value = value === null ? null : value.trim(); + saveDb(db); + return settingRow(setting!); + }, +}; diff --git a/SW.Bitween.Web/ClientApp/src/api/mock/store.ts b/SW.Bitween.Web/ClientApp/src/api/mock/store.ts new file mode 100644 index 00000000..377c5ad9 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/api/mock/store.ts @@ -0,0 +1,126 @@ +import type { + GlobalValuesSet, + InformationType, + Integration, + Invite, + NotificationEntry, + Notifier, + Partner, + RetryPolicy, + Setting, + TrailEntry, + User, + WorkGroup, +} from "../types"; +import { DEMO_PASSWORD, SEED_INVITES, SEED_ROLES, SEED_USERS, type SeedRole } from "./seed"; +import { + SEED_API_GATEWAYS, + SEED_BUS_GATEWAYS, + SEED_CREDENTIALS, + SEED_INFORMATION_TYPES, + SEED_INTEGRATIONS, + SEED_INTEGRATION_TRAILS, + SEED_NOTIFIERS, + SEED_PARTNERS, + SEED_RETRY_POLICIES, + SEED_SETTINGS, + SEED_TRAILS, + SEED_VALUE_SETS, + SEED_WORK_GROUPS, + seedExchanges, + seedNotifications, + type SeedExchange, + type StoredApiGateway, + type StoredBusGateway, + type StoredCredential, +} from "./seedConfig"; + +/** + * A tiny localStorage-backed "database" so demo changes (invites, new + * roles, disabled members) survive reloads. `reset()` restores the seeds. + */ +export interface MockDb { + users: User[]; + roles: SeedRole[]; + invites: Invite[]; + /** userId -> password (plaintext; prototype only). */ + passwords: Record; + /** reset token -> userId */ + resetTokens: Record; + sessionUserId: string | null; + // — configuration entities — + partners: Partner[]; + credentials: StoredCredential[]; + informationTypes: InformationType[]; + trails: Record; + valueSets: GlobalValuesSet[]; + integrations: Integration[]; + integrationTrails: Record; + workGroups: WorkGroup[]; + apiGateways: StoredApiGateway[]; + busGateways: StoredBusGateway[]; + exchanges: SeedExchange[]; + retryPolicies: RetryPolicy[]; + notifiers: Notifier[]; + notifications: (NotificationEntry & { notifierId: number })[]; + settings: Setting[]; +} + +const KEY = "bitween-proto-db-v7"; + +const seedDb = (): MockDb => ({ + users: structuredClone(SEED_USERS), + roles: structuredClone(SEED_ROLES), + invites: structuredClone(SEED_INVITES), + passwords: Object.fromEntries( + SEED_USERS.filter((u) => u.status !== "invited").map((u) => [u.id, DEMO_PASSWORD]), + ), + resetTokens: {}, + sessionUserId: null, + partners: structuredClone(SEED_PARTNERS), + credentials: structuredClone(SEED_CREDENTIALS), + informationTypes: structuredClone(SEED_INFORMATION_TYPES), + trails: structuredClone(SEED_TRAILS), + valueSets: structuredClone(SEED_VALUE_SETS), + integrations: structuredClone(SEED_INTEGRATIONS), + integrationTrails: structuredClone(SEED_INTEGRATION_TRAILS), + workGroups: structuredClone(SEED_WORK_GROUPS), + apiGateways: structuredClone(SEED_API_GATEWAYS), + busGateways: structuredClone(SEED_BUS_GATEWAYS), + exchanges: seedExchanges(), + retryPolicies: structuredClone(SEED_RETRY_POLICIES), + notifiers: structuredClone(SEED_NOTIFIERS), + notifications: seedNotifications(), + settings: structuredClone(SEED_SETTINGS), +}); + +export const loadDb = (): MockDb => { + try { + const raw = localStorage.getItem(KEY); + if (raw) return JSON.parse(raw) as MockDb; + } catch { + // corrupted storage — fall through to a fresh seed + } + const db = seedDb(); + saveDb(db); + return db; +}; + +export const saveDb = (db: MockDb) => { + localStorage.setItem(KEY, JSON.stringify(db)); +}; + +export const resetDb = (): MockDb => { + const current = loadDb(); + const db = seedDb(); + // keep the signed-in persona when it exists in the seeds + if (current.sessionUserId && db.users.some((u) => u.id === current.sessionUserId)) { + db.sessionUserId = current.sessionUserId; + } + saveDb(db); + return db; +}; + +/** Simulated network latency so loading states are honest. */ +export const delay = () => + new Promise((resolve) => setTimeout(resolve, 120 + Math.random() * 200)); diff --git a/SW.Bitween.Web/ClientApp/src/api/permissions.ts b/SW.Bitween.Web/ClientApp/src/api/permissions.ts new file mode 100644 index 00000000..106517e0 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/api/permissions.ts @@ -0,0 +1,208 @@ +import type { ActionId, PermissionArea, PermissionKey } from "./types"; + +/** + * The permission catalog: every gated page and action in Bitween. + * Roles are built by picking grants from this list. The area groups + * mirror the app's navigation groups so "what a role unlocks" maps + * one-to-one onto what its members see. + */ +export const PERMISSION_CATALOG: PermissionArea[] = [ + // ——— Operate ——— + { + id: "dashboard", + label: "Dashboard", + group: "Operate", + description: "Traffic and health overview.", + actions: [{ id: "view", description: "See the dashboard." }], + }, + { + id: "exchanges", + label: "Exchanges", + group: "Operate", + description: "Every message that flows through Bitween.", + actions: [ + { id: "view", description: "Browse exchanges, payloads and traces." }, + { id: "operate", description: "Retry or resubmit failed exchanges." }, + ], + }, + { + id: "monitoring", + label: "Queue health", + group: "Operate", + description: "Live message-queue throughput and consumers.", + actions: [{ id: "view", description: "See queue health and rates." }], + }, + + // ——— Integrations ——— + { + id: "subscriptions", + label: "Integrations", + group: "Integrations", + description: "The configured pipelines that process exchanges.", + actions: [ + { id: "view", description: "Browse integrations and their configuration." }, + { id: "create", description: "Create integrations." }, + { id: "edit", description: "Change adapters, mappings and settings." }, + { id: "delete", description: "Delete integrations." }, + { id: "operate", description: "Pause, resume, receive now, aggregate now." }, + ], + }, + { + id: "api-gateways", + label: "API gateways", + group: "Integrations", + description: "HTTP entry points partners call into.", + actions: [ + { id: "view", description: "Browse API gateways and attached partners." }, + { id: "create", description: "Create new API gateways." }, + { id: "edit", description: "Change gateways and partner attachments." }, + { id: "delete", description: "Delete API gateways." }, + ], + }, + { + id: "bus-gateways", + label: "Bus gateways", + group: "Integrations", + description: "Bus listeners that route documents to integrations.", + actions: [ + { id: "view", description: "Browse bus gateways and routes." }, + { id: "create", description: "Create new bus gateways." }, + { id: "edit", description: "Change gateways and routes." }, + { id: "delete", description: "Delete bus gateways." }, + ], + }, + { + id: "workgroups", + label: "Work groups", + group: "Integrations", + description: "Processing lanes that spread load across queues.", + actions: [ + { id: "view", description: "See work groups and their throughput." }, + { id: "create", description: "Create work groups." }, + { id: "edit", description: "Change work group settings." }, + { id: "delete", description: "Delete unused work groups." }, + ], + }, + + // ——— Configuration ——— + { + id: "partners", + label: "Partners", + group: "Configuration", + description: "The external parties you exchange data with.", + actions: [ + { id: "view", description: "Browse partners and their properties." }, + { id: "create", description: "Create partners." }, + { id: "edit", description: "Change partner details, properties and API keys." }, + { id: "delete", description: "Delete partners." }, + ], + }, + { + id: "documents", + label: "Information types", + group: "Configuration", + description: "The kinds of business documents that flow between partners.", + actions: [ + { id: "view", description: "Browse information types." }, + { id: "create", description: "Create information types." }, + { id: "edit", description: "Change information types, codes and promoted properties." }, + { id: "delete", description: "Delete unused information types." }, + ], + }, + { + id: "global-values", + label: "Global values", + group: "Configuration", + description: "Shared value sets adapters can reference.", + actions: [ + { id: "view", description: "Browse global value sets." }, + { id: "create", description: "Create value sets." }, + { id: "edit", description: "Change value sets." }, + { id: "delete", description: "Delete value sets." }, + ], + }, + { + id: "retry-policies", + label: "Retry policies", + group: "Configuration", + description: "Rules for retrying failed exchanges.", + actions: [ + { id: "view", description: "Browse retry policies and scheduled retries." }, + { id: "create", description: "Create retry policies." }, + { id: "edit", description: "Change retry policies." }, + { id: "delete", description: "Delete retry policies." }, + { id: "operate", description: "Run scheduled retries now." }, + ], + }, + { + id: "notifiers", + label: "Notifiers", + group: "Configuration", + description: "Alerts sent when exchanges fail or succeed.", + actions: [ + { id: "view", description: "Browse notifiers and their delivery history." }, + { id: "create", description: "Create notifiers." }, + { id: "edit", description: "Change notifiers." }, + { id: "delete", description: "Delete notifiers." }, + ], + }, + + // ——— Administration ——— + { + id: "users", + label: "Members", + group: "Administration", + description: "The people who can sign in to this Bitween instance.", + actions: [ + { id: "view", description: "See the member list." }, + { id: "create", description: "Invite new members." }, + { + id: "edit", + description: "Change members' roles, disable accounts, reset passwords.", + }, + { id: "delete", description: "Remove members." }, + ], + }, + { + id: "roles", + label: "Roles", + group: "Administration", + description: "What each kind of member is allowed to do.", + actions: [ + { id: "view", description: "See roles and their permissions." }, + { id: "create", description: "Create roles." }, + { id: "edit", description: "Change role permissions." }, + { id: "delete", description: "Delete unassigned roles." }, + ], + }, + { + id: "settings", + label: "Settings", + group: "Administration", + description: "Instance-wide configuration.", + actions: [ + { id: "view", description: "See instance settings." }, + { id: "edit", description: "Change instance settings." }, + ], + }, +]; + +export const PERMISSION_GROUPS = ["Operate", "Integrations", "Configuration", "Administration"]; + +export const ACTION_LABELS: Record = { + view: "View", + create: "Create", + edit: "Edit", + delete: "Delete", + operate: "Operate", +}; + +/** All actions in the order matrix columns render. */ +export const ACTION_ORDER: ActionId[] = ["view", "create", "edit", "delete", "operate"]; + +export const permissionKey = (areaId: string, actionId: ActionId): PermissionKey => + `${areaId}.${actionId}`; + +export const ALL_PERMISSIONS: PermissionKey[] = PERMISSION_CATALOG.flatMap((area) => + area.actions.map((a) => permissionKey(area.id, a.id)), +); diff --git a/SW.Bitween.Web/ClientApp/src/api/types.ts b/SW.Bitween.Web/ClientApp/src/api/types.ts new file mode 100644 index 00000000..ed4b43c9 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/api/types.ts @@ -0,0 +1,542 @@ +/** Shared API shapes. These model the contract the real backend will need to satisfy. */ + +export type ActionId = "view" | "create" | "edit" | "delete" | "operate"; + +/** e.g. "subscriptions.edit" */ +export type PermissionKey = string; + +export interface PermissionAction { + id: ActionId; + /** What this specific grant allows, in end-user words. */ + description: string; +} + +export interface PermissionArea { + id: string; + label: string; + group: string; + description: string; + actions: PermissionAction[]; +} + +export interface Role { + id: string; + name: string; + description: string; + permissions: PermissionKey[]; + /** Built-in roles (Administrator) can't be edited or deleted. */ + isSystem: boolean; + createdOn: string; + memberCount: number; +} + +export type UserStatus = "active" | "invited" | "disabled"; + +export interface User { + id: string; + displayName: string; + email: string; + phone?: string; + roleIds: string[]; + status: UserStatus; + /** Whether a Microsoft account is linked for SSO. */ + microsoftLinked: boolean; + createdOn: string; + lastActiveOn?: string; +} + +export interface Invite { + token: string; + email: string; + roleIds: string[]; + /** Resolved names for roleIds, so the public accept page can show them. */ + roleNames: string[]; + invitedByName: string; + createdOn: string; + expiresOn: string; +} + +export interface Session { + user: User; + roles: Role[]; + permissions: PermissionKey[]; +} + +export interface ApiError { + code: string; + message: string; +} + +export class ApiRequestError extends Error { + code: string; + constructor(code: string, message: string) { + super(message); + this.code = code; + } +} + +// ——— Configuration entities (sub-phase 2) ——— + +/** Lightweight references for "used by" panels. */ +export interface IntegrationSetupRef { + id: number; + name: string; + type: IntegrationType; +} +export interface ApiGatewayAttachmentRef { + gatewayId: number; + gatewayName: string; + urlName: string; +} +export interface BusGatewayRouteRef { + gatewayId: number; + gatewayName: string; + matchExpression: string; +} +/** The pipeline stages that can each produce a document for an exchange. */ +export type ExchangeDocStage = "Input" | "Mapped" | "Handled"; +export interface ExchangeDocument { + stage: ExchangeDocStage; + content: string; +} +export interface ExchangeRef { + id: string; + partnerName?: string; + informationTypeCode: string; + status: "success" | "failed" | "processing"; + on: string; + /** Documents produced as the exchange moved through the pipeline, for drill-down previews. */ + documents?: ExchangeDocument[]; +} + +/** + * Lightweight summary of every integration, cached client-side so pages + * can answer "who uses this property/value/policy?" without new requests. + * Derived server-side by scanning adapter property values for tokens. + */ +export interface IntegrationInfo { + id: number; + name: string; + type: IntegrationType; + /** + * Partners this integration runs for: its own partner (legacy types) + * plus partners linked through gateway attachments and bus routes. + */ + partnerIds: number[]; + informationTypeId: number; + workGroupId: number | null; + retryPolicyId: number | null; + /** Keys of partner properties referenced by its adapters ({{partner.KEY}}). */ + partnerPropKeys: string[]; + /** Global value references, per set. */ + globals: { setId: string; keys: string[] }[]; +} +export interface TrailEntry { + on: string; + action: "Created" | "Updated"; + by: string; + /** Absent for system-attributed entries with no real team member behind them. */ + byUserId?: string; +} + +export interface ApiCredentialRef { + name: string; + /** Only the first characters — full keys are shown once, at creation. */ + keyPrefix: string; + createdOn: string; +} + +export interface Partner { + id: number; + name: string; + /** Referenced in adapter configs as {{partner.KEY}}. */ + adapterProperties: Record; + /** The built-in SYSTEM partner can't be renamed or deleted. */ + isSystem: boolean; + createdOn: string; +} +export interface PartnerRow extends Partner { + credentialCount: number; + usedByCount: number; +} +export interface PartnerDetail extends Partner { + apiCredentials: ApiCredentialRef[]; + integrationSetups: IntegrationSetupRef[]; + apiGateways: ApiGatewayAttachmentRef[]; + busGatewayRoutes: BusGatewayRouteRef[]; + recentExchanges: ExchangeRef[]; +} + +export type InformationTypeFormat = "Json" | "Xml"; + +export interface InformationType { + id: number; + /** Short unique identity shown across the system, e.g. PURCHASE_ORDER. */ + code: string; + name: string; + format: InformationTypeFormat; + busEnabled: boolean; + busMessageTypeName?: string; + /** How long an identical incoming payload counts as a duplicate. 0 = off. */ + duplicateIntervalMinutes: number; + disregardsUnfilteredMessages: boolean; + /** Friendly name → JSONPath/XPath, matched by routes and filters. */ + promotedProperties: { key: string; path: string }[]; + createdOn: string; +} +export interface InformationTypeRow extends InformationType { + usedByCount: number; +} +export interface InformationTypeDetail extends InformationType { + integrationSetups: IntegrationSetupRef[]; + busGateways: { gatewayId: number; gatewayName: string }[]; + trail: TrailEntry[]; + recentExchanges: ExchangeRef[]; +} + +export interface GlobalValuesSet { + /** Caller-chosen slug; referenced as {{globals..}}. */ + id: string; + name: string; + values: Record; + createdOn: string; +} +export interface GlobalValuesSetRow extends GlobalValuesSet { + usedByCount: number; +} +export interface ValueSetUsage { + integrationSetup: IntegrationSetupRef; + keys: string[]; +} +export interface GlobalValuesSetDetail extends GlobalValuesSet { + usedBy: ValueSetUsage[]; +} + +// ——— Retry policies ——— + +export type RetryResultType = "Error" | "BadResult"; + +export type RetryMatcher = + | { type: "contains"; value: string; caseSensitive: boolean } + | { type: "regex"; pattern: string; flags: string } + | { type: "exceptionType"; value: string; includeInner: boolean } + | { type: "jsonPath"; path: string; op: "Eq" | "Neq" | "Contains" | "Exists" | "NotExists"; value?: string }; + +export type RetryDelay = + | { type: "fixed"; delaySeconds: number } + | { type: "linear"; initialSeconds: number; incrementSeconds: number } + | { type: "exponential"; initialSeconds: number; multiplier: number; maxSeconds: number }; + +export interface RetryGroup { + id: string; + name: string; + /** Lower runs first; the first matching group decides. */ + priority: number; + enabled: boolean; + appliesTo: RetryResultType[]; + /** OR logic; empty = any failure of the applicable kind. */ + matchers: RetryMatcher[]; + action: "Allow" | "Block"; + budget?: { maxAttemptsPerError: number; maxAttemptsTotal: number; delay: RetryDelay }; + notes?: string; +} + +export interface RetryPolicy { + id: number; + name: string; + groups: RetryGroup[]; + createdOn: string; +} +export interface RetryPolicyListRow extends RetryPolicy { + usedByCount: number; +} +export interface RetryPolicyDetail extends RetryPolicy { + integrations: IntegrationSetupRef[]; +} + +export interface RetryTestAttempt { + attempt: number; + shouldRetry: boolean; + delaySeconds?: number; + matchedGroup?: string; + reason: string; +} + +// ——— Notifiers ——— + +/** + * A delivery channel = a notifier handler adapter and the configuration + * it expects. Served by the API so new adapters appear without UI changes. + */ +export interface NotifierChannel { + id: string; + label: string; + props: { key: string; label: string; placeholder?: string }[]; +} + +export interface Notifier { + id: number; + name: string; + /** Off pauses the notifier without losing its setup. */ + enabled: boolean; + /** Which exchange outcomes trigger a notification. */ + onFailed: boolean; + onBadResult: boolean; + onSuccess: boolean; + channelId: string; + channelProperties: Record; + /** Integrations this notifier watches; empty = it never fires. */ + integrationIds: number[]; + createdOn: string; +} + +/** One delivery attempt from the notification history. */ +export interface NotificationEntry { + xchangeId: string; + success: boolean; + exception?: string; + on: string; +} + +export interface NotifierDetail extends Notifier { + recentNotifications: NotificationEntry[]; +} + +// ——— Integrations (subscriptions) ——— + +/** + * Backend Subscription.Type. Aggregation exists in data but is deferred in + * this UI; Internal and ApiCall are legacy — shown and editable, never created. + */ +export type IntegrationType = + | "Receiving" + | "GatewayApiCall" + | "BusGateway" + | "Internal" + | "ApiCall" + | "Aggregation"; + +export type AdapterKind = "receiver" | "handler" | "mapper" | "validator"; + +/** One configurable property of an adapter, from its startup-value metadata. */ +export interface AdapterProp { + key: string; + optional: boolean; + default?: string; + /** Secret values are write-only: masked after save, replaced not edited. */ + secret: boolean; + description?: string; +} + +export interface AdapterInfo { + id: string; + kind: AdapterKind; + /** Friendly display name, e.g. "HTTP endpoint" for NativeHttpHandler. */ + label: string; + /** Native adapters run in-process; others are deployed packages with versions. */ + native: boolean; + versions: string[]; + props: AdapterProp[]; +} + +/** + * Message filter over an information type's promoted properties. + * Groups are n-ary here (friendlier to edit); the backend's binary + * and/or tree converts losslessly both ways. + */ +export type MatchCondition = { + op: "oneOf" | "notOneOf"; + path: string; + values: string[]; +}; +export type MatchGroup = { + op: "and" | "or"; + children: MatchNode[]; +}; +export type MatchNode = MatchGroup | MatchCondition; + +export type Recurrence = "Hourly" | "Daily" | "Weekly" | "Monthly"; + +export interface Schedule { + recurrence: Recurrence; + /** Weekly: weekday 0–6 (Sun–Sat); Monthly: day of month 1–27; otherwise 0. */ + days: number; + hours: number; + minutes: number; + /** Count the offset from the end of the period instead of the start. */ + backwards: boolean; +} + +export interface Integration { + id: number; + name: string; + type: IntegrationType; + informationTypeId: number; + /** Direct partner — legacy Internal/ApiCall (and Aggregation) only. */ + partnerId: number | null; + /** Inverse of backend Inactive. Disabled = not scheduled, not matched. */ + enabled: boolean; + /** Paused still accepts work but holds it for later release. */ + pausedOn: string | null; + workGroupId: number | null; + retryPolicyId: number | null; + receiverId: string | null; + receiverProperties: Record; + validatorId: string | null; + validatorProperties: Record; + mapperId: string | null; + mapperProperties: Record; + handlerId: string | null; + handlerProperties: Record; + /** Legacy Internal only: which documents this integration picks up. */ + matchExpression: MatchGroup | null; + /** Receiving (and Aggregation) only. */ + schedules: Schedule[]; + /** Feed the handler's response into another integration. */ + responseIntegrationId: number | null; + responseMessageTypeName: string | null; + aggregationForId: number | null; + // — health (read-only) — + isRunning: boolean; + lastReceiveOn: string | null; + consecutiveFailures: number; + lastException: string | null; + createdOn: string; +} + +export interface IntegrationRow { + id: number; + name: string; + type: IntegrationType; + informationTypeId: number; + informationTypeCode: string; + partners: { id: number; name: string }[]; + enabled: boolean; + paused: boolean; + isRunning: boolean; + consecutiveFailures: number; + lastException: string | null; + scheduleSummary?: string; + lastReceiveOn: string | null; + createdOn: string; +} + +export interface IntegrationDetail extends Integration { + informationTypeCode: string; + informationTypeName: string; + /** Where this integration is plugged in (entry points). */ + apiGatewayAttachments: { gatewayId: number; gatewayName: string; urlName: string; partnerId: number; partnerName: string }[]; + busGatewayRoutes: { gatewayId: number; gatewayName: string; partnerId: number | null; partnerName: string | null }[]; + watchingNotifiers: { id: number; name: string }[]; + recentExchanges: ExchangeRef[]; + trail: TrailEntry[]; +} + +export interface WorkGroupOptions { + rabbitMqOptions: { + consumerSettings: { + prefetch: number; + priority: number; + }; + }; +} + +export interface WorkGroup { + id: number; + name: string; + /** Queue name suffix; combined with the id to form the real queue name. */ + busMessageName: string; + options: WorkGroupOptions; + createdOn: string; +} +export interface WorkGroupRow extends WorkGroup { + usedByCount: number; + /** Best-effort live count from the RabbitMQ management API. */ + consumerCount: number; +} +export interface WorkGroupDetail extends WorkGroup { + integrations: IntegrationSetupRef[]; +} + +// ——— API gateways ——— + +export interface ApiGatewayAttachment { + partnerId: number; + partnerName: string; + integrationId: number; + integrationName: string; +} +export interface ApiGateway { + id: number; + name: string; + urlName: string; + createdOn: string; +} +export interface ApiGatewayRow extends ApiGateway { + partnerCount: number; + attachments: ApiGatewayAttachment[]; +} +export interface ApiGatewayDetail extends ApiGateway { + attachments: ApiGatewayAttachment[]; +} + +// ——— Bus gateways ——— + +export interface BusGatewayRoute { + id: number; + integrationId: number; + integrationName: string; + partnerId: number | null; + partnerName: string | null; + /** null = route matches every message of the gateway's type. */ + matchExpression: MatchGroup | null; +} +export interface BusGateway { + id: number; + name: string; + informationTypeId: number; + createdOn: string; +} +export interface BusGatewayRow extends BusGateway { + informationTypeCode: string; + routeCount: number; + routes: BusGatewayRoute[]; +} +export interface BusGatewayDetail extends BusGateway { + informationTypeCode: string; + informationTypeName: string; + routes: BusGatewayRoute[]; +} + +// ——— Settings ——— + +export type SettingSection = + | "Documents & storage" + | "API behavior" + | "Single sign-on (Microsoft)" + | "Messaging" + | "Adapters" + | "Reliability & jobs" + | "Security" + | "Brand & theme"; + +export type SettingValueKind = "string" | "number" | "boolean" | "string[]" | "color"; + +export interface Setting { + /** Stable key; mirrors the real config path (e.g. "Bitween.RebexLicenseKey"). */ + key: string; + section: SettingSection; + label: string; + description: string; + kind: SettingValueKind; + /** Stored as a string regardless of kind; parsed for display per `kind`. */ + defaultValue: string; + /** null = not overridden, falls back to `defaultValue`. */ + value: string | null; + secret: boolean; + /** Read once at process startup; changing it has no effect until the backend restarts. */ + restartRequired: boolean; +} +export interface SettingRow extends Setting { + overridden: boolean; +} diff --git a/SW.Bitween.Web/ClientApp/src/auth/SessionContext.tsx b/SW.Bitween.Web/ClientApp/src/auth/SessionContext.tsx new file mode 100644 index 00000000..7c520dd7 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/auth/SessionContext.tsx @@ -0,0 +1,96 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { api, type PermissionKey, type Session } from "../api"; + +interface SessionContextValue { + session: Session | null; + /** True until the stored session has been checked once at startup. */ + initializing: boolean; + can: (permission: PermissionKey) => boolean; + signIn: (email: string, password: string) => Promise; + signInWithMicrosoft: () => Promise; + /** Adopt a session produced elsewhere (invite acceptance, demo switch). */ + adoptSession: (session: Session) => void; + /** Re-fetch the session after profile changes. */ + refresh: () => Promise; + signOut: () => Promise; +} + +const SessionContext = createContext(null); + +export function SessionProvider({ children }: { children: ReactNode }) { + const [session, setSession] = useState(null); + const [initializing, setInitializing] = useState(true); + const queryClient = useQueryClient(); + + useEffect(() => { + api + .getSession() + .then(setSession) + .finally(() => setInitializing(false)); + }, []); + + const adoptSession = useCallback( + (next: Session) => { + // a different identity invalidates everything previously fetched + queryClient.clear(); + setSession(next); + }, + [queryClient], + ); + + const signIn = useCallback( + async (email: string, password: string) => { + const next = await api.login(email, password); + adoptSession(next); + return next; + }, + [adoptSession], + ); + + const signInWithMicrosoft = useCallback(async () => { + const next = await api.loginWithMicrosoft(); + adoptSession(next); + return next; + }, [adoptSession]); + + const refresh = useCallback(async () => { + setSession(await api.getSession()); + }, []); + + const signOut = useCallback(async () => { + await api.logout(); + queryClient.clear(); + setSession(null); + }, [queryClient]); + + const value = useMemo( + () => ({ + session, + initializing, + can: (permission) => session?.permissions.includes(permission) ?? false, + signIn, + signInWithMicrosoft, + adoptSession, + refresh, + signOut, + }), + [session, initializing, signIn, signInWithMicrosoft, adoptSession, refresh, signOut], + ); + + return {children}; +} + +export function useSession(): SessionContextValue { + const ctx = useContext(SessionContext); + if (!ctx) throw new Error("useSession must be used inside "); + return ctx; +} diff --git a/SW.Bitween.Web/ClientApp/src/auth/guards.tsx b/SW.Bitween.Web/ClientApp/src/auth/guards.tsx new file mode 100644 index 00000000..30ca651b --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/auth/guards.tsx @@ -0,0 +1,73 @@ +import type { ReactNode } from "react"; +import { Navigate, Outlet, useLocation } from "react-router"; +import { Lock } from "lucide-react"; +import type { PermissionKey } from "../api"; +import { PERMISSION_CATALOG, ACTION_LABELS } from "../api/permissions"; +import { useSession } from "./SessionContext"; + +/** Redirects to /login when signed out; shows a splash while checking. */ +export function RequireAuth() { + const { session, initializing } = useSession(); + const location = useLocation(); + + if (initializing) { + return ( +
+ Bitween +
+ ); + } + if (!session) { + return ; + } + return ; +} + +export const permissionLabel = (key: PermissionKey): string => { + const [areaId, actionId] = key.split("."); + const area = PERMISSION_CATALOG.find((a) => a.id === areaId); + const action = ACTION_LABELS[actionId as keyof typeof ACTION_LABELS]; + return area ? `${area.label} · ${action ?? actionId}` : key; +}; + +/** Full-page stop for routes the session's roles don't unlock. */ +export function AccessDenied({ permission }: { permission: PermissionKey }) { + return ( +
+ + + +

You don't have access to this page

+

+ Seeing it needs the {permissionLabel(permission)}{" "} + permission, which none of your roles grant. An administrator can change that under Team → Roles. +

+
+ ); +} + +/** Route-level permission gate. */ +export function RequirePermission({ + permission, + children, +}: { + permission: PermissionKey; + children: ReactNode; +}) { + const { can } = useSession(); + if (!can(permission)) return ; + return <>{children}; +} + +/** Inline gate: unauthorized actions are hidden, never dimmed. */ +export function Can({ permission, children }: { permission: PermissionKey; children: ReactNode }) { + const { can } = useSession(); + if (!can(permission)) return null; + return <>{children}; +} + +/** Convenience hook for components that branch on a permission. */ +export function useSessionCan(permission: PermissionKey): boolean { + const { can } = useSession(); + return can(permission); +} diff --git a/SW.Bitween.Web/ClientApp/src/components/config/AdapterConfig.tsx b/SW.Bitween.Web/ClientApp/src/components/config/AdapterConfig.tsx new file mode 100644 index 00000000..b8bce695 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/components/config/AdapterConfig.tsx @@ -0,0 +1,305 @@ +import { useEffect, useRef, useState } from "react"; +import { Link } from "react-router"; +import { useQuery } from "@tanstack/react-query"; +import { ArrowUpRight, Braces, Search } from "lucide-react"; +import { api, type AdapterInfo, type AdapterKind } from "../../api"; +import { Button } from "../ui/basics"; +import { Field, Select } from "../ui/forms"; + +export function useAdapterCatalog(kind: AdapterKind) { + return useQuery({ + queryKey: ["adapters", kind], + queryFn: () => api.listAdapters(kind), + staleTime: Infinity, + }); +} + +interface ReferenceToken { + label: string; + token: string; + /** Globals resolve to one literal value; partner properties resolve per-exchange. */ + value?: string; +} + +/** All insertable reference tokens: every global key + every known partner property. */ +function useReferenceTokens() { + const sets = useQuery({ queryKey: ["value-sets"], queryFn: () => api.listValueSets(), staleTime: 60_000 }); + const partners = useQuery({ queryKey: ["partners"], queryFn: () => api.listPartners(), staleTime: 60_000 }); + const globals: ReferenceToken[] = (sets.data ?? []).flatMap((s) => + Object.entries(s.values).map(([key, value]) => ({ + label: `${s.id}.${key}`, + token: `{{globals.${s.id}.${key}}}`, + value, + })), + ); + const partnerKeys: ReferenceToken[] = [ + ...new Set((partners.data ?? []).flatMap((p) => Object.keys(p.adapterProperties))), + ].map((key) => ({ label: `partner.${key}`, token: `{{partner.${key}}}` })); + return { globals, partnerKeys }; +} + +/** + * Searchable popover for inserting a `{{globals.…}}` / `{{partner.…}}` + * reference. Globals show their literal value; partner properties resolve + * per-exchange, so they show a note instead. + */ +function ReferenceMenu({ + globals, + partnerKeys, + onPick, + label, +}: { + globals: ReferenceToken[]; + partnerKeys: ReferenceToken[]; + onPick: (token: string) => void; + label: string; +}) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + const onDown = (e: MouseEvent) => { + if (!ref.current?.contains(e.target as Node)) setOpen(false); + }; + const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false); + document.addEventListener("mousedown", onDown); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDown); + document.removeEventListener("keydown", onKey); + }; + }, [open]); + + const needle = query.trim().toLowerCase(); + const matches = (t: ReferenceToken) => + !needle || t.label.toLowerCase().includes(needle) || (t.value?.toLowerCase().includes(needle) ?? false); + const filteredGlobals = globals.filter(matches); + const filteredPartnerKeys = partnerKeys.filter(matches); + const noMatches = filteredGlobals.length === 0 && filteredPartnerKeys.length === 0; + + const pick = (token: string) => { + onPick(token); + setOpen(false); + setQuery(""); + }; + + return ( +
+ + {open && ( +
+
+ + setQuery(e.target.value)} + placeholder="Search references" + aria-label="Search references" + className="h-8 w-full rounded-md border border-ink-200 bg-white pr-2 pl-8 text-[13px] placeholder:text-ink-400 focus:border-crimson-400 focus:ring-2 focus:ring-crimson-100 focus:outline-none" + /> +
+
+ {noMatches &&

No matches.

} + {filteredGlobals.length > 0 && ( +
+

+ Global values +

+ {filteredGlobals.map((t) => ( + + ))} +
+ )} + {filteredPartnerKeys.length > 0 && ( +
+

+ Partner properties +

+ {filteredPartnerKeys.map((t) => ( + + ))} +
+ )} +
+
+ )} +
+ ); +} + +/** One adapter property: grows with content, can insert reference tokens. */ +function PropField({ + prop, + value, + disabled, + onChange, +}: { + prop: AdapterInfo["props"][number]; + value: string; + disabled: boolean; + onChange: (value: string) => void; +}) { + const { globals, partnerKeys } = useReferenceTokens(); + const [replacing, setReplacing] = useState(false); + const masked = prop.secret && !!value && !replacing; + + return ( + + {masked ? ( +
+ •••••••• + {!disabled && ( + + )} +
+ ) : ( +
+
+