Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
00ec0c3
feat: serve the admin UI from the backend under /bitween
hamzahalq Jul 16, 2026
1d18190
feat: add the redesigned admin UI
hamzahalq Jul 19, 2026
09114c5
feat: sign in against the real backend
hamzahalq Jul 19, 2026
cae9079
feat: wire partners and information types to the backend
hamzahalq Jul 20, 2026
62c5acc
feat: wire global values, work groups and retry policies
hamzahalq Jul 20, 2026
5456b05
feat: wire integrations, adapters and gateways to the backend
hamzahalq Jul 20, 2026
47ac99e
feat: wire exchanges and operations, and fix schedule timezones
hamzahalq Jul 22, 2026
709280c
feat: wire notifiers, and fix the mapper editor and adapter pickers
hamzahalq Jul 26, 2026
13aec1a
feat: permission catalog, roles and the team page
hamzahalq Jul 27, 2026
cb23f11
feat: move live-changeable settings into the database
hamzahalq Jul 29, 2026
83365d4
fix: serve the admin UI from the site root instead of /bitween
hamzahalq Jul 30, 2026
06fd3de
fix: keep failure colors red when the brand color changes
hamzahalq Jul 30, 2026
d8c7157
fix: reload settings on other instances when one changes them
hamzahalq Jul 30, 2026
bcb6bed
fix: refresh bus consumers when a bus-enabled document is created
hamzahalq Jul 30, 2026
ca84660
fix: partner property count, "used by" and reference tokens
hamzahalq Jul 30, 2026
9613d9f
fix: confirm the password an admin just set
hamzahalq Jul 30, 2026
ffa993f
fix: wire the favicon and mobile header icon theme settings
hamzahalq Jul 30, 2026
f8aff70
feat: link a scheduled retry to the policy that scheduled it
hamzahalq Jul 30, 2026
e66e208
chore: drop the Created columns
hamzahalq Jul 30, 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