Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
5f2963b
spec1: type system
antosubash May 22, 2026
d7d5646
spec3: field components
antosubash May 22, 2026
22b38f7
spec2: api and service
antosubash May 22, 2026
f8ee074
spec4: page layout
antosubash May 22, 2026
413a194
integration: wire sensitive masking, extend validator, drop SettingDe…
antosubash May 22, 2026
f43ba47
fix(settings): clean up row layout and sync field state on reset
antosubash May 22, 2026
1a421f0
test(settings): cover new field types and inheritance display in e2e
antosubash May 22, 2026
0cec0dc
chore: add verification screenshot
antosubash May 22, 2026
bb87893
refactor(settings): adopt design system Card/Section/SearchInput/Badg…
antosubash May 22, 2026
7d854fd
chore: refresh verification screenshot for design-system rework
antosubash May 22, 2026
c5f809a
chore: add admin/color/user verification screenshots
antosubash May 22, 2026
b6a5980
refactor(settings): row uses horizontal layout for compact field types
antosubash May 23, 2026
f14c4d3
style(settings): give rows proper vertical breathing room
antosubash May 23, 2026
01c3510
style(settings): replace toggle button with checkbox for filter
antosubash May 23, 2026
30bf7ac
fix(settings): surface save errors + keep toolbar mounted when filter…
antosubash May 23, 2026
48aaf78
fix(settings): gate write endpoints + binding 400s + reject admin Use…
antosubash May 23, 2026
1e409e1
fix(settings): validator dup error + url scheme + bulk user-scope cle…
antosubash May 23, 2026
6bebb9e
merge: endpoint hardening fixes (GAP-PERM, BUG-3/4/6)
antosubash May 23, 2026
c4bb0ea
merge: validator + service fixes (BUG-1/2/5)
antosubash May 23, 2026
0ba2c95
merge: frontend UX fixes (BUG-7/8)
antosubash May 23, 2026
eabd3be
test(settings): flip QA bug-doc tests + dedup bulk-user-scope assertion
antosubash May 23, 2026
ad970c1
test(settings): add comprehensive QA e2e specs (field types, validati…
antosubash May 23, 2026
4de0d8b
test(settings): make Select smoke + Bulk discard tests idempotent
antosubash May 23, 2026
4a3acd4
Merge branch 'main' into settings-refactor-v2
antosubash May 23, 2026
ff709fe
test(core): align EventDurability test with new settings API contract
antosubash May 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .verify/admin-color.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .verify/admin-system.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified .verify/screenshot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .verify/user-settings.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 10 additions & 0 deletions framework/SimpleModule.Core/Settings/SettingDefinition.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Collections.Generic;

namespace SimpleModule.Core.Settings;

public class SettingDefinition
Expand All @@ -9,4 +11,12 @@ public class SettingDefinition
public SettingScope Scope { get; set; }
public string? DefaultValue { get; set; }
public SettingType Type { get; set; }
public IReadOnlyList<string>? AllowedValues { get; set; }
public double? Min { get; set; }
public double? Max { get; set; }
public string? Pattern { get; set; }
public bool Required { get; set; }
public bool Sensitive { get; set; }
public int Order { get; set; }
public string? Placeholder { get; set; }
}
7 changes: 7 additions & 0 deletions framework/SimpleModule.Core/Settings/SettingType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,11 @@ public enum SettingType
Number = 1,
Bool = 2,
Json = 3,
Select = 4,
Color = 5,
Url = 6,
Email = 7,
Password = 8,
MultilineText = 9,
DateTime = 10,
}
19 changes: 19 additions & 0 deletions modules/Core/src/SimpleModule.Core/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Auto-generated from [Dto] types — do not edit
export interface SettingDefinition {
key: string;
displayName: string;
description: string;
group: string;
scope: any;
defaultValue: string;
type: any;
allowedValues: string[];
min: number | null;
max: number | null;
pattern: string;
required: boolean;
sensitive: boolean;
order: number;
placeholder: string;
}

Original file line number Diff line number Diff line change
Expand Up @@ -232,24 +232,58 @@ private sealed class FakeSettingsContracts(string? language, Action? onGet = nul
return Task.FromResult(language);
}

public Task<System.Text.Json.JsonElement?> ResolveUserSettingElementAsync(
string key,
string userId
)
{
if (language is null)
return Task.FromResult<System.Text.Json.JsonElement?>(null);
using var doc = System.Text.Json.JsonDocument.Parse(
System.Text.Json.JsonSerializer.Serialize(language)
);
return Task.FromResult<System.Text.Json.JsonElement?>(doc.RootElement.Clone());
}

public Task SetSettingAsync(
string key,
string value,
System.Text.Json.JsonElement value,
SettingScope scope,
string? userId = null
)
{
return Task.CompletedTask;
}

public Task SetManyAsync(IReadOnlyList<BulkSettingUpdate> updates)
{
return Task.CompletedTask;
}

public Task DeleteSettingAsync(string key, SettingScope scope, string? userId = null)
{
return Task.CompletedTask;
}

public Task<IEnumerable<Setting>> GetSettingsAsync(SettingsFilter? filter = null)
public Task ResetToDefaultAsync(string key, SettingScope scope, string? userId = null)
{
return Task.CompletedTask;
}

public Task<IEnumerable<SettingValueDto>> GetSettingValuesAsync(
SettingsFilter? filter = null
)
{
return Task.FromResult<IEnumerable<SettingValueDto>>([]);
}

public Task<SettingValueDto?> GetSettingValueAsync(
string key,
SettingScope scope,
string? userId = null
)
{
return Task.FromResult<IEnumerable<Setting>>([]);
return Task.FromResult<SettingValueDto?>(null);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using System.Text.Json;
using SimpleModule.Core;
using SimpleModule.Core.Settings;

namespace SimpleModule.Settings.Contracts;

[Dto]
public class BulkSettingUpdate
{
public string Key { get; set; } = string.Empty;
public SettingScope Scope { get; set; }
public JsonElement Value { get; set; }
}

[Dto]
public class BulkUpdateSettingsRequest
{
public IReadOnlyList<BulkSettingUpdate> Updates { get; set; } = [];
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Text.Json;
using SimpleModule.Core.Settings;

namespace SimpleModule.Settings.Contracts;
Expand All @@ -7,7 +8,15 @@ public interface ISettingsContracts
Task<string?> GetSettingAsync(string key, SettingScope scope, string? userId = null);
Task<T?> GetSettingAsync<T>(string key, SettingScope scope, string? userId = null);
Task<string?> ResolveUserSettingAsync(string key, string userId);
Task SetSettingAsync(string key, string value, SettingScope scope, string? userId = null);
Task<JsonElement?> ResolveUserSettingElementAsync(string key, string userId);
Task SetSettingAsync(string key, JsonElement value, SettingScope scope, string? userId = null);
Task SetManyAsync(IReadOnlyList<BulkSettingUpdate> updates);
Task DeleteSettingAsync(string key, SettingScope scope, string? userId = null);
Task<IEnumerable<Setting>> GetSettingsAsync(SettingsFilter? filter = null);
Task ResetToDefaultAsync(string key, SettingScope scope, string? userId = null);
Task<IEnumerable<SettingValueDto>> GetSettingValuesAsync(SettingsFilter? filter = null);
Task<SettingValueDto?> GetSettingValueAsync(
string key,
SettingScope scope,
string? userId = null
);
}
21 changes: 17 additions & 4 deletions modules/Settings/src/SimpleModule.Settings.Contracts/Setting.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,25 @@
using System.Text.Json;
using SimpleModule.Core;
using SimpleModule.Core.Settings;

namespace SimpleModule.Settings.Contracts;

public class Setting
[Dto]
public class SettingValueDto
{
public string Key { get; set; } = string.Empty;
public string? Value { get; set; }
public string Key { get; set; } = "";
public SettingScope Scope { get; set; }
public JsonElement? Value { get; set; }
public bool IsOverridden { get; set; }
public string? UserId { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public DateTimeOffset? UpdatedAt { get; set; }
}

[Dto]
public class UserSettingValueDto
{
public string Key { get; set; } = "";
public JsonElement? Value { get; set; }
public JsonElement? ResolvedValue { get; set; }
public bool IsOverridden { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ public static class Api
public const string GetSettings = "/";
public const string GetSetting = "/{key}";
public const string GetDefinitions = "/definitions";
public const string GetResolvedSetting = "/{key}/resolved";
public const string UpdateSetting = "/";
public const string BulkUpdateSettings = "/bulk";
public const string DeleteSetting = "/{key}";
public const string GetMySettings = "/me";
public const string UpdateMySetting = "/me";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
using System.Text.Json;
using SimpleModule.Core;
using SimpleModule.Core.Settings;

namespace SimpleModule.Settings.Contracts;

[Dto]
public class UpdateSettingRequest
{
public string Key { get; set; } = string.Empty;
public string? Value { get; set; }
public SettingScope Scope { get; set; }
public JsonElement Value { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using SimpleModule.Core;
using SimpleModule.Core.Authorization;
using SimpleModule.Core.Settings;
using SimpleModule.Settings.Contracts;

namespace SimpleModule.Settings.Endpoints.Settings;

public class BulkUpdateSettingsEndpoint : IEndpoint
{
public const string Route = SettingsConstants.Routes.Api.BulkUpdateSettings;
public const string Method = "PUT";

public void Map(IEndpointRouteBuilder app) =>
app.MapPut(
Route,
async Task<IResult> (
BulkUpdateSettingsRequest request,
ISettingsContracts settings
) =>
{
var userScopedKey = request.Updates.FirstOrDefault(u => u.Scope == SettingScope.User)?.Key;
if (userScopedKey is not null)
{
return TypedResults.Problem(
detail: "Use /api/settings/me to set user-scoped settings.",
statusCode: StatusCodes.Status400BadRequest,
title: "Invalid scope"
);
}

try
{
await settings.SetManyAsync(request.Updates);
return TypedResults.Ok(new { count = request.Updates.Count });
}
catch (SettingValidationException ex)
{
return TypedResults.ValidationProblem(
new Dictionary<string, string[]> { [ex.Key] = ex.Errors.ToArray() }
);
}
}
)
.RequirePermission(SettingsPermissions.Update);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using SimpleModule.Core;
using SimpleModule.Core.Authorization;
using SimpleModule.Core.Settings;
using SimpleModule.Settings.Contracts;

Expand All @@ -15,11 +16,20 @@ public class DeleteSettingEndpoint : IEndpoint
public void Map(IEndpointRouteBuilder app) =>
app.MapDelete(
Route,
async (string key, SettingScope scope, ISettingsContracts settings) =>
async Task<IResult> (string key, SettingScope? scope, ISettingsContracts settings) =>
{
await settings.DeleteSettingAsync(key, scope);
if (scope is null)
{
return TypedResults.Problem(
detail: "Query parameter 'scope' is required.",
statusCode: StatusCodes.Status400BadRequest,
title: "Missing required parameter"
);
}

await settings.ResetToDefaultAsync(key, scope.Value);
return TypedResults.NoContent();
}
)
.RequireAuthorization();
.RequirePermission(SettingsPermissions.Update);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using SimpleModule.Core;
using SimpleModule.Settings.Contracts;

namespace SimpleModule.Settings.Endpoints.Settings;

public class GetResolvedSettingEndpoint : IEndpoint
{
public const string Route = SettingsConstants.Routes.Api.GetResolvedSetting;

public void Map(IEndpointRouteBuilder app) =>
app.MapGet(
Route,
async Task<IResult> (
string key,
ISettingsContracts settings,
ClaimsPrincipal principal
) =>
{
var userId =
principal.FindFirstValue(ClaimTypes.NameIdentifier) ?? string.Empty;
var value = await settings.ResolveUserSettingElementAsync(key, userId);
return TypedResults.Ok(new { key, value });
}
)
.RequireAuthorization();
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,23 @@ public class GetSettingEndpoint : IEndpoint
public void Map(IEndpointRouteBuilder app) =>
app.MapGet(
Route,
async Task<IResult> (string key, SettingScope scope, ISettingsContracts settings) =>
async Task<IResult> (
string key,
SettingScope? scope,
ISettingsContracts settings
) =>
{
var value = await settings.GetSettingAsync(key, scope);
return value is not null
? TypedResults.Ok(
new
{
key,
value,
scope,
}
)
: TypedResults.NotFound();
if (scope is null)
{
return TypedResults.Problem(
detail: "Query parameter 'scope' is required.",
statusCode: StatusCodes.Status400BadRequest,
title: "Missing required parameter"
);
}

var dto = await settings.GetSettingValueAsync(key, scope.Value);
return dto is not null ? TypedResults.Ok(dto) : TypedResults.NotFound();
}
)
.RequireAuthorization();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,15 @@ public class GetSettingsEndpoint : IEndpoint
public void Map(IEndpointRouteBuilder app) =>
app.MapGet(
Route,
async (ISettingsContracts settings, SettingScope? scope) =>
async (ISettingsContracts settings, SettingScope? scope, string? group) =>
{
var filter = scope is not null ? new SettingsFilter { Scope = scope } : null;
var results = await settings.GetSettingsAsync(filter);
SettingsFilter? filter = null;
if (scope is not null || group is not null)
{
filter = new SettingsFilter { Scope = scope, Group = group };
}

var results = await settings.GetSettingValuesAsync(filter);
return TypedResults.Ok(results);
}
)
Expand Down
Loading
Loading