Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
fcc4e79
feat: serve admin UI from backend under /bitween
hamzahalq Jul 16, 2026
56f50fb
feat: add admin UI redesign prototype (mock data)
hamzahalq Jul 16, 2026
044b2ed
feat: operate pages, entity pickers, staged settings, slimmer forms
hamzahalq Jul 19, 2026
624bb42
feat: wire batch 0 — real backend auth
hamzahalq Jul 19, 2026
f5338c7
feat: wire partners to real backend
hamzahalq Jul 19, 2026
805260c
fix: re-sync edit drafts after save on all 9 detail pages
hamzahalq Jul 19, 2026
6c25189
feat: add Code field and auto-generated Id to information types
hamzahalq Jul 19, 2026
d727d5b
feat: wire information types to real backend
hamzahalq Jul 19, 2026
1612878
feat: make Document.Code fully optional, drop existing-row backfill
hamzahalq Jul 20, 2026
a0ba143
feat: make information type Code optional, fall back to name when unset
hamzahalq Jul 20, 2026
e9b8a79
test: add Playwright for browser-level UI verification
hamzahalq Jul 20, 2026
cdab48f
feat: work groups - live consumer count, skip stale cache reads
hamzahalq Jul 20, 2026
228dc26
feat: wire global values, work groups, retry policies to real backend
hamzahalq Jul 20, 2026
d9bbd69
test: add Playwright coverage for global values, work groups, retry p…
hamzahalq Jul 20, 2026
e8123db
feat: wire integrations and adapters to real backend
hamzahalq Jul 20, 2026
f522d96
feat: wire API and bus gateways to real backend
hamzahalq Jul 20, 2026
5180d44
feat: wire exchanges, scheduled retries, queue health, dashboard to r…
hamzahalq Jul 21, 2026
ba8236a
fix: null subscription crash in bulk retry, isolate adapter prop-fetc…
hamzahalq Jul 21, 2026
75e38f3
fix: exchange documents not viewable in the drawer
hamzahalq Jul 21, 2026
e3376d3
fix: match expressions were saving the friendly name instead of the J…
hamzahalq Jul 21, 2026
c6079f0
fix: {{globals.…}}/{{partner.…}} tokens not resolving in several Xcha…
hamzahalq Jul 22, 2026
673f0d7
fix: wire Used by/recent exchanges/history to real data, fix partner-…
hamzahalq Jul 22, 2026
ccd38c6
fix: reject bus message type names containing spaces
hamzahalq Jul 22, 2026
3c2058b
fix: schedules were computed and firing in different timezones
hamzahalq Jul 22, 2026
d3c08f0
feat: show local-time translation next to UTC schedule inputs
hamzahalq Jul 22, 2026
d22e9b4
fix: mapper editor showed template text instead of evaluated JSON, an…
hamzahalq Jul 22, 2026
4b98df0
fix: partner/global-referencing mappings broke for keys with hyphens,…
hamzahalq Jul 23, 2026
06188e4
fix: Used by panels on work groups and retry policies were always empty
hamzahalq Jul 23, 2026
dcbb7a9
fix: work group bus message names with spaces broke queue naming
hamzahalq Jul 23, 2026
80da3c6
fix: retry group with no conditions never matched any failure
hamzahalq Jul 23, 2026
4f854fa
fix: adapter pickers showed "vundefined" for custom adapters
hamzahalq Jul 26, 2026
09a8127
feat: wire notifiers to the backend
hamzahalq Jul 26, 2026
19713ef
fix: SQL Server and MySQL were missing the Documents auto-id migration
hamzahalq Jul 26, 2026
1fd1523
feat: permission catalog and role-based access control
hamzahalq Jul 26, 2026
9489139
feat: manage team members
hamzahalq Jul 27, 2026
0c32eed
feat: let an administrator set a member's password
hamzahalq Jul 27, 2026
3b7dcaf
fix: drop the unused account phone column
hamzahalq Jul 27, 2026
e545eab
feat: wire the team page to the backend
hamzahalq Jul 27, 2026
6da1ab7
test: cover team, roles and permission enforcement end to end
hamzahalq Jul 27, 2026
7023c12
Merge pull request #231 from simplify9/hamza/feature/team-rbac
hamzahalq Jul 27, 2026
55d7bb7
fix: integration tests stopped compiling after the permission guards
hamzahalq Jul 27, 2026
911c012
fix: work group create, edit and delete were not permission checked
hamzahalq Jul 27, 2026
bffe8fa
fix: drop permissions that nothing enforces
hamzahalq Jul 27, 2026
4a3760b
feat: require view permissions to read
hamzahalq Jul 27, 2026
259e941
feat: move live-changeable settings into the database
hamzahalq Jul 29, 2026
fa2dcb3
Merge pull request #232 from simplify9/hamza/feature/settings
hamzahalq Jul 29, 2026
bf98fb7
chore: delete the dead mock API client
hamzahalq Jul 29, 2026
9df94b0
feat: show environment-owned settings, make the retry schedule editable
hamzahalq Jul 29, 2026
0fe707a
Merge releases/r10.0 into v2
hamzahalq Jul 29, 2026
3a8428f
fix: honour DisableEmailPasswordLogin in the new UI
hamzahalq Jul 29, 2026
105a64b
fix: stop using the real Rebex license key as test data
hamzahalq Jul 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
12 changes: 12 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -365,3 +365,15 @@ 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

# Playwright MCP tool output and test-run artifacts (created at repo root when
# run from here, separate from ClientApp's own .gitignore'd copies)
/.playwright-mcp/
/test-results/
14 changes: 12 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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/"]
Expand All @@ -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"]
COPY --from=ui-build /src/SW.Bitween.Web/wwwroot ./wwwroot
ENTRYPOINT ["dotnet", "SW.Bitween.Web.dll"]
2 changes: 1 addition & 1 deletion SW.Bitween.Api/Controllers/GatewayController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ private async Task<IActionResult> ProcessAsync([FromRoute] string gatewayApiName

var json = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync();

var xchangeFile = new XchangeFile(json);
var xchangeFile = new XchangeFile(json, $"{gatewayApiName}.json");

var validatorProperties = subscription.ValidatorProperties.ToDictionary()
.Fill(partner, globalAdapterValuesSet);
Expand Down
54 changes: 51 additions & 3 deletions SW.Bitween.Api/Data/BitweenDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,13 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
modelBuilder.Entity<Document>(b =>
{
b.ToTable("Documents");
b.Property(p => p.Id).ValueGeneratedNever();
b.Property(p => p.Name).HasMaxLength(100).IsUnicode(false).IsRequired();
b.Property(p => p.Code).HasMaxLength(50).IsUnicode(false);
b.Property(p => p.BusMessageTypeName).IsUnicode(false).HasMaxLength(500);
b.Property(p => p.PromotedProperties).StoreAsJson();
b.Property(p => p.DisregardsUnfilteredMessages).IsRequired(false);
b.HasIndex(p => p.Name).IsUnique();
b.HasIndex(p => p.Code).IsUnique();
b.HasIndex(p => p.BusMessageTypeName).IsUnique();

b.HasMany<Subscription>().WithOne().HasForeignKey(p => p.DocumentId).OnDelete(DeleteBehavior.Restrict);
Expand Down Expand Up @@ -349,7 +350,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
b.HasIndex(p => p.Email).IsUnique();

b.Property(p => p.Email).IsUnicode(false).HasMaxLength(200);
b.Property(p => p.Phone).IsUnicode(false).HasMaxLength(20);
b.Property(p => p.Password).IsUnicode(false).HasMaxLength(500);
b.Property(p => p.DisplayName).IsRequired().HasMaxLength(200);

Expand Down Expand Up @@ -382,9 +382,57 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
b.Property(p => p.AccountId);
b.Property(p => p.LoginMethod).HasConversion<byte>();
});


modelBuilder.Entity<Role>(b =>
{
b.ToTable("Roles");
b.HasKey(p => p.Id);
b.Property(p => p.Id).ValueGeneratedOnAdd();
b.HasIndex(p => p.Name).IsUnique();

b.Property(p => p.Name).IsRequired().HasMaxLength(100);
b.Property(p => p.Description).HasMaxLength(500);
b.Property(p => p.Permissions).StoreAsJson();

b.HasData(SystemRoleSeed());
});

modelBuilder.Entity<AccountRoleLink>(b =>
{
b.ToTable("AccountRoles");
b.HasKey(p => new { p.AccountId, p.RoleId });
b.HasOne<Account>().WithMany().HasForeignKey(p => p.AccountId).OnDelete(DeleteBehavior.Cascade);
b.HasOne<Role>().WithMany().HasForeignKey(p => p.RoleId).OnDelete(DeleteBehavior.Cascade);
});

modelBuilder.Entity<Setting>(b =>
{
b.ToTable("Settings");
b.HasKey(p => p.Id);
// Id is the catalog key, e.g. "Theme.PrimaryColor". Value is left unbounded:
// it carries anything from a hex color to a license key or a page of blurb.
b.Property(p => p.Id).IsUnicode(false).HasMaxLength(200);
});

}

/// <summary>
/// The built-in roles. Their grants aren't stored — <see cref="Role.GetEffectivePermissions"/>
/// derives them from the catalog — so adding a permission never needs a data fix-up here.
/// </summary>
protected Role[] SystemRoleSeed() =>
[
new Role(Role.AdministratorId, "Administrator",
"Full access to everything, including members, roles and settings.")
{ CreatedOn = defaultCreatedOn.ToUniversalTime() },
new Role(Role.MemberId, "Member",
"Runs and configures integrations. Can't manage members, roles or settings.")
{ CreatedOn = defaultCreatedOn.ToUniversalTime() },
new Role(Role.ViewerId, "Viewer",
"Read-only access to integrations, exchanges and configuration.")
{ CreatedOn = defaultCreatedOn.ToUniversalTime() }
];

async public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
ChangeTracker.ApplyAuditValues(requestContext.GetNameIdentifier());
Expand Down
21 changes: 20 additions & 1 deletion SW.Bitween.Api/Domain/Accounts/Account.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ public Account(string displayName, string email, string password, AccountRole ro
}

public string Email { get; private set; }
public string Phone { get; private set; }
public string DisplayName { get; set; }

public AccountRole Role { get; private set; }
Expand Down Expand Up @@ -63,6 +62,26 @@ public void Update(string name, AccountRole role)
Role = role;
}

/// <summary>Self-service details. Deliberately cannot touch <see cref="Role"/>.</summary>
public void UpdateProfile(string name)
{
DisplayName = name;
}

/// <summary>
/// Legacy coarse role, kept in step with the account's roles for older API consumers.
/// Authorization itself reads the role assignments, not this.
/// </summary>
public void SetRole(AccountRole role)
{
Role = role;
}

public void SetDisabled(bool disabled)
{
Disabled = disabled;
}

public DateTime CreatedOn { get; set; }
public string CreatedBy { get; set; }
public DateTime? ModifiedOn { get; set; }
Expand Down
18 changes: 18 additions & 0 deletions SW.Bitween.Api/Domain/Accounts/AccountRoleLink.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace SW.Bitween.Domain.Accounts;

/// <summary>Which roles an account holds. Composite key — one row per account/role pair.</summary>
public class AccountRoleLink
{
private AccountRoleLink()
{
}

public AccountRoleLink(int accountId, int roleId)
{
AccountId = accountId;
RoleId = roleId;
}

public int AccountId { get; private set; }
public int RoleId { get; private set; }
}
80 changes: 80 additions & 0 deletions SW.Bitween.Api/Domain/Accounts/Role.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
using System;
using System.Collections.Generic;
using System.Linq;
using SW.Bitween.Model;
using SW.PrimitiveTypes;

namespace SW.Bitween.Domain.Accounts;

/// <summary>
/// A reusable set of permission keys. Members hold any number of roles and may do anything
/// any of their roles allows.
/// </summary>
public class Role : BaseEntity, IAudited
{
public const int AdministratorId = 1;
public const int MemberId = 2;
public const int ViewerId = 3;

/// <summary>The groups the built-in Member and Viewer roles reach; Administration stays admin-only.</summary>
private static readonly string[] NonAdminGroups = ["Operate", "Integrations", "Configuration"];

private Role()
{
}

/// <summary>Seeding path for the built-in roles — fixed Id, grants computed at runtime.</summary>
public Role(int id, string name, string description)
{
Id = id;
Name = name;
Description = description;
IsSystem = true;
}

public Role(string name, string description, IEnumerable<string> permissions)
{
Name = name;
Description = description;
Permissions = PermissionCatalog.Sanitize(permissions);
}

public string Name { get; private set; }
public string Description { get; private set; }

/// <summary>
/// Catalog keys, stored as one JSON column. Empty for built-in roles — see
/// <see cref="GetEffectivePermissions"/>.
/// </summary>
public List<string> Permissions { get; private set; } = [];

/// <summary>Built-in roles can be assigned, but never edited or deleted.</summary>
public bool IsSystem { get; private set; }

public void Update(string name, string description, IEnumerable<string> permissions)
{
Name = name;
Description = description;
Permissions = PermissionCatalog.Sanitize(permissions);
}

/// <summary>
/// What this role actually grants. Built-in roles derive their grants from the catalog at
/// runtime instead of storing them, so adding a permission to the catalog reaches
/// Administrators — and the right groups — without a migration or a data fix-up.
/// </summary>
public List<string> GetEffectivePermissions() => IsSystem ? SystemPermissions(Id) : Permissions;

public static List<string> SystemPermissions(int roleId) => roleId switch
{
AdministratorId => PermissionCatalog.AllKeys.ToList(),
MemberId => PermissionCatalog.InGroups(false, NonAdminGroups),
ViewerId => PermissionCatalog.InGroups(true, NonAdminGroups),
_ => []
};

public DateTime CreatedOn { get; set; }
public string CreatedBy { get; set; }
public DateTime? ModifiedOn { get; set; }
public string ModifiedBy { get; set; }
}
20 changes: 20 additions & 0 deletions SW.Bitween.Api/Domain/Document/Document.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,17 @@ public Document(int id, string name, DocumentFormat format)
DocumentFormat = format;
}

/// <summary>Production creation path — Id is database-generated. Code is optional.</summary>
public Document(string code, string name, DocumentFormat format)
{
Code = code;
Name = name ?? throw new ArgumentNullException(nameof(name));
PromotedProperties = new Dictionary<string, string>();
DocumentFormat = format;
}

public string Name { get; private set; }
public string Code { get; private set; }
public bool BusEnabled { get; set; }
public string BusMessageTypeName { get; set; }
public int DuplicateInterval { get; set; }
Expand All @@ -37,5 +47,15 @@ public void SetDictionaries(IReadOnlyDictionary<string, string> promotedProperti
{
PromotedProperties = promotedProperties;
}

public void SetName(string name)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
}

public void SetCode(string code)
{
Code = code;
}
}
}
26 changes: 26 additions & 0 deletions SW.Bitween.Api/Domain/Setting.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using System;
using SW.PrimitiveTypes;

namespace SW.Bitween.Domain;

/// <summary>
/// One instance-wide setting, keyed by the setting's catalog key (e.g. <c>Theme.PrimaryColor</c>).
/// This table is the single source of truth: configuration seeds a key once — on the first boot
/// after that key exists — and is ignored for it from then on. Every catalog key normally has a
/// row; "reset to default" rewrites the row with the product default rather than removing it.
/// <para>
/// Deliberately a plain key/value store: the definition of a key (label, section, type,
/// whether it's a secret) lives in <see cref="Services.SettingsCatalog"/>, so adding a
/// setting never needs a migration or a data fix-up. A secret's value is encrypted before it
/// gets here — see <see cref="Services.SettingsProtector"/>.
/// </para>
/// </summary>
public class Setting : BaseEntity<string>, IAudited
{
public string Value { get; set; }

public DateTime CreatedOn { get; set; }
public string CreatedBy { get; set; }
public DateTime? ModifiedOn { get; set; }
public string ModifiedBy { get; set; }
}
6 changes: 5 additions & 1 deletion SW.Bitween.Api/Domain/Subscription/Schedule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,11 @@ public override int GetHashCode()

public DateTime Next(DateTime? currentDate = null)
{
var utcNow = currentDate == null ? DateTime.UtcNow : currentDate.Value;
// On.Hours/On.Minutes are UTC wall-clock time — the Quartz cron trigger that
// actually fires this schedule is explicitly pinned to TimeZoneInfo.Utc
// (see SW.Scheduler's WithCronSchedule(...).InTimeZone(TimeZoneInfo.Utc) call
// sites), so this must agree rather than each side defaulting independently.
var utcNow = currentDate ?? DateTime.UtcNow;
DateTime nextSelectedDate;

switch (Recurrence)
Expand Down
7 changes: 4 additions & 3 deletions SW.Bitween.Api/Domain/Xchange/Xchange.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,15 +76,16 @@ public Xchange(Xchange xchange, XchangeFile file, IWorkGroup workGroup, IReadOnl
}

//retry with reset subscription properties
public Xchange(Subscription subscription, Xchange xchange, XchangeFile file, IReadOnlyDictionary<string, int> groupAttemptCounts = null) :
public Xchange(Subscription subscription, Xchange xchange, XchangeFile file, Partner gatewayPartner = null,
GlobalAdapterValuesSet[] globalAdapterValuesSets = null, IReadOnlyDictionary<string, int> groupAttemptCounts = null) :
this(xchange.DocumentId, subscription.WorkGroup, file, xchange.References)
{
SubscriptionId = xchange.SubscriptionId;
PartnerId = xchange.PartnerId ?? subscription.PartnerId;
MapperId = subscription.MapperId;
HandlerId = subscription.HandlerId;
MapperProperties = subscription.MapperProperties;
HandlerProperties = subscription.HandlerProperties;
MapperProperties = (subscription.MapperProperties ?? new Dictionary<string, string>()).ToDictionary().Fill(gatewayPartner, globalAdapterValuesSets);
HandlerProperties = (subscription.HandlerProperties ?? new Dictionary<string, string>()).ToDictionary().Fill(gatewayPartner, globalAdapterValuesSets);
ResponseSubscriptionId = subscription.ResponseSubscriptionId;
RetryFor = xchange.Id;
CorrelationId = xchange.CorrelationId;
Expand Down
4 changes: 0 additions & 4 deletions SW.Bitween.Api/Extensions/AccountExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,6 @@ private static ClaimsIdentity CreateClaimsIdentity(this Account account, LoginMe
case LoginMethod.EmailAndPassword:
claims.Add(new Claim(ClaimTypes.Name, account.Email));
break;
case LoginMethod.PhoneAndOtp:
claims.Add(new Claim(ClaimTypes.Name, account.Phone));
break;
case LoginMethod.ApiKey:
claims.Add(new Claim(ClaimTypes.Name, account.Id.ToString()));
break;
Expand All @@ -91,7 +88,6 @@ private static ClaimsIdentity CreateClaimsIdentity(this Account account, LoginMe
}

if (account.Email != null) claims.Add(new Claim(ClaimTypes.Email, account.Email));
if (account.Phone != null) claims.Add(new Claim(ClaimTypes.MobilePhone, account.Phone));


return new ClaimsIdentity(claims, "Bitween");
Expand Down
Loading