diff --git a/.verify/admin-color.png b/.verify/admin-color.png new file mode 100644 index 00000000..0ac029ec Binary files /dev/null and b/.verify/admin-color.png differ diff --git a/.verify/admin-system.png b/.verify/admin-system.png new file mode 100644 index 00000000..1e42c599 Binary files /dev/null and b/.verify/admin-system.png differ diff --git a/.verify/screenshot.png b/.verify/screenshot.png index bca6a584..1e42c599 100644 Binary files a/.verify/screenshot.png and b/.verify/screenshot.png differ diff --git a/.verify/user-settings.png b/.verify/user-settings.png new file mode 100644 index 00000000..3ea9deec Binary files /dev/null and b/.verify/user-settings.png differ diff --git a/framework/SimpleModule.Core/Settings/SettingDefinition.cs b/framework/SimpleModule.Core/Settings/SettingDefinition.cs index 62046dde..819662b2 100644 --- a/framework/SimpleModule.Core/Settings/SettingDefinition.cs +++ b/framework/SimpleModule.Core/Settings/SettingDefinition.cs @@ -1,3 +1,5 @@ +using System.Collections.Generic; + namespace SimpleModule.Core.Settings; public class SettingDefinition @@ -9,4 +11,12 @@ public class SettingDefinition public SettingScope Scope { get; set; } public string? DefaultValue { get; set; } public SettingType Type { get; set; } + public IReadOnlyList? 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; } } diff --git a/framework/SimpleModule.Core/Settings/SettingType.cs b/framework/SimpleModule.Core/Settings/SettingType.cs index 8f6e29bb..d3722d85 100644 --- a/framework/SimpleModule.Core/Settings/SettingType.cs +++ b/framework/SimpleModule.Core/Settings/SettingType.cs @@ -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, } diff --git a/modules/Core/src/SimpleModule.Core/types.ts b/modules/Core/src/SimpleModule.Core/types.ts new file mode 100644 index 00000000..8e9fb0a0 --- /dev/null +++ b/modules/Core/src/SimpleModule.Core/types.ts @@ -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; +} + diff --git a/modules/Localization/tests/SimpleModule.Localization.Tests/Unit/LocaleResolutionMiddlewareTests.cs b/modules/Localization/tests/SimpleModule.Localization.Tests/Unit/LocaleResolutionMiddlewareTests.cs index 19dcdd79..cefedd27 100644 --- a/modules/Localization/tests/SimpleModule.Localization.Tests/Unit/LocaleResolutionMiddlewareTests.cs +++ b/modules/Localization/tests/SimpleModule.Localization.Tests/Unit/LocaleResolutionMiddlewareTests.cs @@ -232,9 +232,22 @@ private sealed class FakeSettingsContracts(string? language, Action? onGet = nul return Task.FromResult(language); } + public Task ResolveUserSettingElementAsync( + string key, + string userId + ) + { + if (language is null) + return Task.FromResult(null); + using var doc = System.Text.Json.JsonDocument.Parse( + System.Text.Json.JsonSerializer.Serialize(language) + ); + return Task.FromResult(doc.RootElement.Clone()); + } + public Task SetSettingAsync( string key, - string value, + System.Text.Json.JsonElement value, SettingScope scope, string? userId = null ) @@ -242,14 +255,35 @@ public Task SetSettingAsync( return Task.CompletedTask; } + public Task SetManyAsync(IReadOnlyList updates) + { + return Task.CompletedTask; + } + public Task DeleteSettingAsync(string key, SettingScope scope, string? userId = null) { return Task.CompletedTask; } - public Task> GetSettingsAsync(SettingsFilter? filter = null) + public Task ResetToDefaultAsync(string key, SettingScope scope, string? userId = null) + { + return Task.CompletedTask; + } + + public Task> GetSettingValuesAsync( + SettingsFilter? filter = null + ) + { + return Task.FromResult>([]); + } + + public Task GetSettingValueAsync( + string key, + SettingScope scope, + string? userId = null + ) { - return Task.FromResult>([]); + return Task.FromResult(null); } } diff --git a/modules/Settings/src/SimpleModule.Settings.Contracts/BulkUpdateSettingsRequest.cs b/modules/Settings/src/SimpleModule.Settings.Contracts/BulkUpdateSettingsRequest.cs new file mode 100644 index 00000000..ecd122f9 --- /dev/null +++ b/modules/Settings/src/SimpleModule.Settings.Contracts/BulkUpdateSettingsRequest.cs @@ -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 Updates { get; set; } = []; +} diff --git a/modules/Settings/src/SimpleModule.Settings.Contracts/ISettingsContracts.cs b/modules/Settings/src/SimpleModule.Settings.Contracts/ISettingsContracts.cs index 6a0f681e..3ab41777 100644 --- a/modules/Settings/src/SimpleModule.Settings.Contracts/ISettingsContracts.cs +++ b/modules/Settings/src/SimpleModule.Settings.Contracts/ISettingsContracts.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using SimpleModule.Core.Settings; namespace SimpleModule.Settings.Contracts; @@ -7,7 +8,15 @@ public interface ISettingsContracts Task GetSettingAsync(string key, SettingScope scope, string? userId = null); Task GetSettingAsync(string key, SettingScope scope, string? userId = null); Task ResolveUserSettingAsync(string key, string userId); - Task SetSettingAsync(string key, string value, SettingScope scope, string? userId = null); + Task ResolveUserSettingElementAsync(string key, string userId); + Task SetSettingAsync(string key, JsonElement value, SettingScope scope, string? userId = null); + Task SetManyAsync(IReadOnlyList updates); Task DeleteSettingAsync(string key, SettingScope scope, string? userId = null); - Task> GetSettingsAsync(SettingsFilter? filter = null); + Task ResetToDefaultAsync(string key, SettingScope scope, string? userId = null); + Task> GetSettingValuesAsync(SettingsFilter? filter = null); + Task GetSettingValueAsync( + string key, + SettingScope scope, + string? userId = null + ); } diff --git a/modules/Settings/src/SimpleModule.Settings.Contracts/Setting.cs b/modules/Settings/src/SimpleModule.Settings.Contracts/Setting.cs index 4794a663..347bf01f 100644 --- a/modules/Settings/src/SimpleModule.Settings.Contracts/Setting.cs +++ b/modules/Settings/src/SimpleModule.Settings.Contracts/Setting.cs @@ -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; } } diff --git a/modules/Settings/src/SimpleModule.Settings.Contracts/SettingsConstants.cs b/modules/Settings/src/SimpleModule.Settings.Contracts/SettingsConstants.cs index c7eb476d..9fcb1d76 100644 --- a/modules/Settings/src/SimpleModule.Settings.Contracts/SettingsConstants.cs +++ b/modules/Settings/src/SimpleModule.Settings.Contracts/SettingsConstants.cs @@ -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"; diff --git a/modules/Settings/src/SimpleModule.Settings.Contracts/UpdateSettingRequest.cs b/modules/Settings/src/SimpleModule.Settings.Contracts/UpdateSettingRequest.cs index 0b47fee7..e650a98b 100644 --- a/modules/Settings/src/SimpleModule.Settings.Contracts/UpdateSettingRequest.cs +++ b/modules/Settings/src/SimpleModule.Settings.Contracts/UpdateSettingRequest.cs @@ -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; } } diff --git a/modules/Settings/src/SimpleModule.Settings/Endpoints/Settings/BulkUpdateSettingsEndpoint.cs b/modules/Settings/src/SimpleModule.Settings/Endpoints/Settings/BulkUpdateSettingsEndpoint.cs new file mode 100644 index 00000000..d808a5d8 --- /dev/null +++ b/modules/Settings/src/SimpleModule.Settings/Endpoints/Settings/BulkUpdateSettingsEndpoint.cs @@ -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 ( + 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 { [ex.Key] = ex.Errors.ToArray() } + ); + } + } + ) + .RequirePermission(SettingsPermissions.Update); +} diff --git a/modules/Settings/src/SimpleModule.Settings/Endpoints/Settings/DeleteSettingEndpoint.cs b/modules/Settings/src/SimpleModule.Settings/Endpoints/Settings/DeleteSettingEndpoint.cs index 56598fd2..c6f16468 100644 --- a/modules/Settings/src/SimpleModule.Settings/Endpoints/Settings/DeleteSettingEndpoint.cs +++ b/modules/Settings/src/SimpleModule.Settings/Endpoints/Settings/DeleteSettingEndpoint.cs @@ -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; @@ -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 (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); } diff --git a/modules/Settings/src/SimpleModule.Settings/Endpoints/Settings/GetResolvedSettingEndpoint.cs b/modules/Settings/src/SimpleModule.Settings/Endpoints/Settings/GetResolvedSettingEndpoint.cs new file mode 100644 index 00000000..508eaef6 --- /dev/null +++ b/modules/Settings/src/SimpleModule.Settings/Endpoints/Settings/GetResolvedSettingEndpoint.cs @@ -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 ( + 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(); +} diff --git a/modules/Settings/src/SimpleModule.Settings/Endpoints/Settings/GetSettingEndpoint.cs b/modules/Settings/src/SimpleModule.Settings/Endpoints/Settings/GetSettingEndpoint.cs index fdf529dc..f1e78b5d 100644 --- a/modules/Settings/src/SimpleModule.Settings/Endpoints/Settings/GetSettingEndpoint.cs +++ b/modules/Settings/src/SimpleModule.Settings/Endpoints/Settings/GetSettingEndpoint.cs @@ -14,19 +14,23 @@ public class GetSettingEndpoint : IEndpoint public void Map(IEndpointRouteBuilder app) => app.MapGet( Route, - async Task (string key, SettingScope scope, ISettingsContracts settings) => + async Task ( + 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(); diff --git a/modules/Settings/src/SimpleModule.Settings/Endpoints/Settings/GetSettingsEndpoint.cs b/modules/Settings/src/SimpleModule.Settings/Endpoints/Settings/GetSettingsEndpoint.cs index 1c281a6d..a36e14ad 100644 --- a/modules/Settings/src/SimpleModule.Settings/Endpoints/Settings/GetSettingsEndpoint.cs +++ b/modules/Settings/src/SimpleModule.Settings/Endpoints/Settings/GetSettingsEndpoint.cs @@ -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); } ) diff --git a/modules/Settings/src/SimpleModule.Settings/Endpoints/Settings/UpdateSettingEndpoint.cs b/modules/Settings/src/SimpleModule.Settings/Endpoints/Settings/UpdateSettingEndpoint.cs index 425f0c5e..359ef328 100644 --- a/modules/Settings/src/SimpleModule.Settings/Endpoints/Settings/UpdateSettingEndpoint.cs +++ b/modules/Settings/src/SimpleModule.Settings/Endpoints/Settings/UpdateSettingEndpoint.cs @@ -2,6 +2,8 @@ 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; @@ -14,15 +16,29 @@ public class UpdateSettingEndpoint : IEndpoint public void Map(IEndpointRouteBuilder app) => app.MapPut( Route, - async (UpdateSettingRequest request, ISettingsContracts settings) => + async Task (UpdateSettingRequest request, ISettingsContracts settings) => { - await settings.SetSettingAsync( - request.Key, - request.Value ?? string.Empty, - request.Scope - ); - return TypedResults.NoContent(); + if (request.Scope == SettingScope.User) + { + return TypedResults.Problem( + detail: "Use /api/settings/me to set user-scoped settings.", + statusCode: StatusCodes.Status400BadRequest, + title: "Invalid scope" + ); + } + + try + { + await settings.SetSettingAsync(request.Key, request.Value, request.Scope); + return TypedResults.NoContent(); + } + catch (SettingValidationException ex) + { + return TypedResults.ValidationProblem( + new Dictionary { [ex.Key] = ex.Errors.ToArray() } + ); + } } ) - .RequireAuthorization(); + .RequirePermission(SettingsPermissions.Update); } diff --git a/modules/Settings/src/SimpleModule.Settings/Endpoints/UserSettings/GetMySettingsEndpoint.cs b/modules/Settings/src/SimpleModule.Settings/Endpoints/UserSettings/GetMySettingsEndpoint.cs index 190bc31d..2465444a 100644 --- a/modules/Settings/src/SimpleModule.Settings/Endpoints/UserSettings/GetMySettingsEndpoint.cs +++ b/modules/Settings/src/SimpleModule.Settings/Endpoints/UserSettings/GetMySettingsEndpoint.cs @@ -25,23 +25,27 @@ ClaimsPrincipal principal if (string.IsNullOrEmpty(userId)) return Results.Unauthorized(); - var definitions = registry.GetDefinitions(SettingScope.User); - var results = new List(); + var defs = registry.GetDefinitions(SettingScope.User); + var results = new List(defs.Count); - foreach (var def in definitions) + foreach (var def in defs) { - var resolved = await settings.ResolveUserSettingAsync(def.Key, userId); - var userValue = await settings.GetSettingAsync( + var userDto = await settings.GetSettingValueAsync( def.Key, SettingScope.User, userId ); + var resolvedElement = await settings.ResolveUserSettingElementAsync( + def.Key, + userId + ); results.Add( - new + new UserSettingValueDto { - definition = def, - value = resolved, - isOverridden = userValue is not null, + Key = def.Key, + Value = userDto?.Value, + ResolvedValue = resolvedElement, + IsOverridden = userDto is not null, } ); } diff --git a/modules/Settings/src/SimpleModule.Settings/Endpoints/UserSettings/UpdateMySettingEndpoint.cs b/modules/Settings/src/SimpleModule.Settings/Endpoints/UserSettings/UpdateMySettingEndpoint.cs index 2cea42d4..0b32a549 100644 --- a/modules/Settings/src/SimpleModule.Settings/Endpoints/UserSettings/UpdateMySettingEndpoint.cs +++ b/modules/Settings/src/SimpleModule.Settings/Endpoints/UserSettings/UpdateMySettingEndpoint.cs @@ -16,7 +16,7 @@ public class UpdateMySettingEndpoint : IEndpoint public void Map(IEndpointRouteBuilder app) => app.MapPut( Route, - async ( + async Task ( UpdateSettingRequest request, ISettingsContracts settings, ClaimsPrincipal principal @@ -26,13 +26,22 @@ ClaimsPrincipal principal if (string.IsNullOrEmpty(userId)) return Results.Unauthorized(); - await settings.SetSettingAsync( - request.Key, - request.Value ?? string.Empty, - SettingScope.User, - userId - ); - return TypedResults.NoContent(); + try + { + await settings.SetSettingAsync( + request.Key, + request.Value, + SettingScope.User, + userId + ); + return TypedResults.NoContent(); + } + catch (SettingValidationException ex) + { + return TypedResults.ValidationProblem( + new Dictionary { [ex.Key] = ex.Errors.ToArray() } + ); + } } ) .RequireAuthorization(); diff --git a/modules/Settings/src/SimpleModule.Settings/Locales/en.json b/modules/Settings/src/SimpleModule.Settings/Locales/en.json index 92989989..1bf43bfe 100644 --- a/modules/Settings/src/SimpleModule.Settings/Locales/en.json +++ b/modules/Settings/src/SimpleModule.Settings/Locales/en.json @@ -1,11 +1,41 @@ { + "AdminSettings.NoResults": "No settings match your search.", + "AdminSettings.SaveError": "{detail}", + "AdminSettings.SaveErrorTitle": "Failed to save setting", "AdminSettings.Title": "Settings", "AdminSettings.TabSystem": "System", "AdminSettings.TabApplication": "Application", + "AdminSettings.SearchPlaceholder": "Search settings…", + "AdminSettings.ShowOnlyModified": "Show only modified", + "AdminSettings.BulkEditToggle": "Bulk edit", + "AdminSettings.BulkSaveButton": "Save all", + "AdminSettings.BulkDiscardButton": "Discard", + "AdminSettings.UnsavedChanges": "{count} unsaved changes", + "AdminSettings.ResetToDefault": "Reset to default", + "AdminSettings.Overridden": "Overridden", + "AdminSettings.Default": "Default", + "AdminSettings.Current": "Current", + "AdminSettings.InheritedDefault": "inherited default", + "AdminSettings.YourOverride": "your override", + "AdminSettings.RequiredAsterisk": "Required", + "AdminSettings.ScopeSystem": "System", + "AdminSettings.ScopeApplication": "Application", + "AdminSettings.ScopeUser": "User", + "UserSettings.NoResults": "No settings match your search.", + "UserSettings.SaveError": "{detail}", + "UserSettings.SaveErrorTitle": "Failed to save setting", "UserSettings.Title": "My Settings", "UserSettings.BadgeOverridden": "Overridden", "UserSettings.BadgeDefault": "Default", "UserSettings.ResetButton": "Reset", + "UserSettings.SearchPlaceholder": "Search settings…", + "UserSettings.OnlyOverridden": "Only overridden", + "UserSettings.ResetToDefault": "Reset to default", + "UserSettings.Overridden": "Overridden", + "UserSettings.Default": "Default", + "UserSettings.Current": "Current", + "UserSettings.InheritedDefault": "inherited default", + "UserSettings.YourOverride": "your override", "MenuManager.Title": "Menu Manager", "MenuManager.Description": "Configure the public navigation menu. Add, reorder, and organize menu items.", "MenuManager.BreadcrumbSettings": "Settings", diff --git a/modules/Settings/src/SimpleModule.Settings/Locales/keys.ts b/modules/Settings/src/SimpleModule.Settings/Locales/keys.ts index f25ee642..cebfb84d 100644 --- a/modules/Settings/src/SimpleModule.Settings/Locales/keys.ts +++ b/modules/Settings/src/SimpleModule.Settings/Locales/keys.ts @@ -1,8 +1,27 @@ export const SettingsKeys = { AdminSettings: { + BulkDiscardButton: 'AdminSettings.BulkDiscardButton', + BulkEditToggle: 'AdminSettings.BulkEditToggle', + BulkSaveButton: 'AdminSettings.BulkSaveButton', + Current: 'AdminSettings.Current', + Default: 'AdminSettings.Default', + InheritedDefault: 'AdminSettings.InheritedDefault', + NoResults: 'AdminSettings.NoResults', + Overridden: 'AdminSettings.Overridden', + RequiredAsterisk: 'AdminSettings.RequiredAsterisk', + ResetToDefault: 'AdminSettings.ResetToDefault', + SaveError: 'AdminSettings.SaveError', + SaveErrorTitle: 'AdminSettings.SaveErrorTitle', + SearchPlaceholder: 'AdminSettings.SearchPlaceholder', + ShowOnlyModified: 'AdminSettings.ShowOnlyModified', + ScopeApplication: 'AdminSettings.ScopeApplication', + ScopeSystem: 'AdminSettings.ScopeSystem', + ScopeUser: 'AdminSettings.ScopeUser', TabApplication: 'AdminSettings.TabApplication', TabSystem: 'AdminSettings.TabSystem', Title: 'AdminSettings.Title', + UnsavedChanges: 'AdminSettings.UnsavedChanges', + YourOverride: 'AdminSettings.YourOverride', }, MenuManager: { AddButton: 'MenuManager.AddButton', @@ -25,7 +44,18 @@ export const SettingsKeys = { UserSettings: { BadgeDefault: 'UserSettings.BadgeDefault', BadgeOverridden: 'UserSettings.BadgeOverridden', + Current: 'UserSettings.Current', + Default: 'UserSettings.Default', + InheritedDefault: 'UserSettings.InheritedDefault', + NoResults: 'UserSettings.NoResults', + OnlyOverridden: 'UserSettings.OnlyOverridden', + Overridden: 'UserSettings.Overridden', ResetButton: 'UserSettings.ResetButton', + ResetToDefault: 'UserSettings.ResetToDefault', + SaveError: 'UserSettings.SaveError', + SaveErrorTitle: 'UserSettings.SaveErrorTitle', + SearchPlaceholder: 'UserSettings.SearchPlaceholder', Title: 'UserSettings.Title', + YourOverride: 'UserSettings.YourOverride', }, } as const; diff --git a/modules/Settings/src/SimpleModule.Settings/Pages/AdminSettings.tsx b/modules/Settings/src/SimpleModule.Settings/Pages/AdminSettings.tsx index 2ad268c6..e5cb7d6b 100644 --- a/modules/Settings/src/SimpleModule.Settings/Pages/AdminSettings.tsx +++ b/modules/Settings/src/SimpleModule.Settings/Pages/AdminSettings.tsx @@ -1,85 +1,353 @@ import { useTranslation } from '@simplemodule/client/use-translation'; -import { PageShell, Tabs, TabsContent, TabsList, TabsTrigger } from '@simplemodule/ui'; -import { useMemo, useState } from 'react'; +import { + Button, + EmptyState, + PageShell, + Tabs, + TabsContent, + TabsList, + TabsTrigger, + Toggle, +} from '@simplemodule/ui'; +import { useCallback, useMemo, useState } from 'react'; import type { SettingDefinition } from '@/components/SettingField'; import SettingGroup from '@/components/SettingGroup'; +import SettingRow from '@/components/SettingRow'; +import SettingsBulkSaveBar from '@/components/SettingsBulkSaveBar'; +import SettingsLayout from '@/components/SettingsLayout'; +import SettingsSearch from '@/components/SettingsSearch'; import { SettingsKeys } from '@/Locales/keys'; -interface StoredSetting { +interface SettingValueDto { key: string; - value: string | null; - scope: number; + scope: 0 | 1 | 2; + value: unknown | null; + isOverridden: boolean; + userId?: string | null; + updatedAt?: string | null; +} + +interface ValidationProblemDetails { + type?: string; + title?: string; + status?: number; + detail?: string; + errors?: Record; } interface AdminSettingsProps { definitions: SettingDefinition[]; - settings: StoredSetting[]; + settings: SettingValueDto[]; +} + +function buildValueMap(settings: SettingValueDto[]): Map { + const map = new Map(); + for (const s of settings) { + map.set(s.key, s); + } + return map; +} + +function groupDefinitions(defs: SettingDefinition[]): Record { + const groups: Record = {}; + const sorted = [...defs].sort((a, b) => a.order - b.order); + for (const def of sorted) { + const group = def.group ?? 'General'; + if (!groups[group]) groups[group] = []; + groups[group].push(def); + } + return groups; +} + +function matchesSearch(def: SettingDefinition, query: string): boolean { + if (!query) return true; + const q = query.toLowerCase(); + return ( + def.displayName.toLowerCase().includes(q) || + def.key.toLowerCase().includes(q) || + (def.group ?? '').toLowerCase().includes(q) || + (def.description ?? '').toLowerCase().includes(q) + ); +} + +async function parseErrorDetail(res: Response): Promise { + try { + const body = (await res.json()) as ValidationProblemDetails; + if (body.detail) return body.detail; + if (body.title) return body.title; + if (body.errors) { + const messages = Object.values(body.errors).flat(); + if (messages.length > 0) return messages.join(' '); + } + } catch { + // not JSON — fall through to generic message + } + return `HTTP ${res.status}`; } export default function AdminSettings({ definitions, settings }: AdminSettingsProps) { const { t } = useTranslation('Settings'); - const [settingsMap, setSettingsMap] = useState>(() => { - const map: Record = {}; - for (const s of settings) { - map[s.key] = s.value; - } - return map; - }); + + const [valueMap, setValueMap] = useState>(() => + buildValueMap(settings), + ); + + const [query, setQuery] = useState(''); + const [showOnlyModified, setShowOnlyModified] = useState(false); + const [bulkMode, setBulkMode] = useState(false); + const [dirtyKeys, setDirtyKeys] = useState>(new Set()); + const [pendingValues, setPendingValues] = useState< + Map + >(new Map()); + const [bulkSaving, setBulkSaving] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); const systemDefs = useMemo(() => definitions.filter((d) => d.scope === 0), [definitions]); const appDefs = useMemo(() => definitions.filter((d) => d.scope === 1), [definitions]); - const groupBy = (defs: SettingDefinition[]) => { - const groups: Record = {}; - for (const def of defs) { - const group = def.group ?? 'General'; - if (!groups[group]) groups[group] = []; - groups[group].push(def); - } - return groups; - }; + const filterDefs = useCallback( + (defs: SettingDefinition[]): SettingDefinition[] => { + return defs.filter((def) => { + if (!matchesSearch(def, query)) return false; + if (showOnlyModified) { + const v = valueMap.get(def.key); + return v?.isOverridden ?? false; + } + return true; + }); + }, + [query, showOnlyModified, valueMap], + ); - const handleSave = async (key: string, value: string, scope: number) => { - await fetch('/api/settings', { + const handleSave = async (key: string, scope: number, value: unknown) => { + if (bulkMode) { + setPendingValues((prev) => new Map(prev).set(key, { scope, value })); + return; + } + setErrorMessage(null); + const res = await fetch('/api/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ key, value, scope }), + body: JSON.stringify({ key, scope, value }), + }); + if (!res.ok) { + const detail = await parseErrorDetail(res); + setErrorMessage(detail); + return; + } + setValueMap((prev) => { + const next = new Map(prev); + const existing = next.get(key); + next.set(key, { ...existing, key, scope: scope as 0 | 1 | 2, value, isOverridden: true }); + return next; + }); + }; + + const handleReset = async (key: string, scope: number) => { + setErrorMessage(null); + const res = await fetch(`/api/settings/${encodeURIComponent(key)}?scope=${scope}`, { + method: 'DELETE', + }); + if (!res.ok) { + const detail = await parseErrorDetail(res); + setErrorMessage(detail); + return; + } + setValueMap((prev) => { + const next = new Map(prev); + const existing = next.get(key); + if (existing) { + next.set(key, { ...existing, value: null, isOverridden: false }); + } + return next; + }); + setDirtyKeys((prev) => { + const next = new Set(prev); + next.delete(key); + return next; + }); + setPendingValues((prev) => { + const next = new Map(prev); + next.delete(key); + return next; + }); + }; + + const handleDirty = useCallback((key: string, isDirty: boolean) => { + setDirtyKeys((prev) => { + const next = new Set(prev); + if (isDirty) next.add(key); + else next.delete(key); + return next; }); - setSettingsMap((prev) => ({ ...prev, [key]: value })); + }, []); + + const handleBulkSave = async () => { + if (pendingValues.size === 0) return; + setBulkSaving(true); + setErrorMessage(null); + try { + const updates = Array.from(pendingValues.entries()).map(([key, { scope, value }]) => ({ + key, + scope, + value, + })); + const res = await fetch('/api/settings/bulk', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ updates }), + }); + if (!res.ok) { + const detail = await parseErrorDetail(res); + setErrorMessage(detail); + return; + } + setValueMap((prev) => { + const next = new Map(prev); + for (const { key, scope, value } of updates) { + const existing = next.get(key); + next.set(key, { + ...existing, + key, + scope: scope as 0 | 1 | 2, + value, + isOverridden: true, + }); + } + return next; + }); + setPendingValues(new Map()); + setDirtyKeys(new Set()); + } finally { + setBulkSaving(false); + } + }; + + const handleDiscard = () => { + setPendingValues(new Map()); + setDirtyKeys(new Set()); }; + const toolbar = ( +
+ { + setQuery(q); + setErrorMessage(null); + }} + showOnlyModified={showOnlyModified} + onShowOnlyModifiedChange={setShowOnlyModified} + modifiedLabel={t(SettingsKeys.AdminSettings.ShowOnlyModified)} + /> + { + setBulkMode(v); + if (!v) handleDiscard(); + }} + variant="outline" + aria-label={t(SettingsKeys.AdminSettings.BulkEditToggle)} + > + {t(SettingsKeys.AdminSettings.BulkEditToggle)} + +
+ ); + + const renderGroups = (defs: SettingDefinition[]) => { + const filtered = filterDefs(defs); + const grouped = groupDefinitions(filtered); + const groupNames = Object.keys(grouped); + + return ( + + {errorMessage !== null && ( +
+

{t(SettingsKeys.AdminSettings.SaveErrorTitle)}

+

{errorMessage}

+ +
+ )} + {groupNames.length === 0 ? ( + setQuery('')}> + Clear search + + ) : undefined + } + /> + ) : ( + groupNames.map((group) => ( + + {(grouped[group] ?? []).map((def) => { + const v = valueMap.get(def.key); + const pending = pendingValues.get(def.key); + return ( + + ); + })} + + )) + )} +
+ ); + }; + + const totalDirty = bulkMode ? pendingValues.size : dirtyKeys.size; + return ( - - + + {t(SettingsKeys.AdminSettings.TabSystem)} {t(SettingsKeys.AdminSettings.TabApplication)} - - {Object.entries(groupBy(systemDefs)).map(([group, defs]) => ( - - ))} + + {renderGroups(systemDefs)} - - {Object.entries(groupBy(appDefs)).map(([group, defs]) => ( - - ))} + + {renderGroups(appDefs)} + + {bulkMode && ( + + )} ); } diff --git a/modules/Settings/src/SimpleModule.Settings/Pages/AdminSettingsEndpoint.cs b/modules/Settings/src/SimpleModule.Settings/Pages/AdminSettingsEndpoint.cs index 7f4f5675..fc165d81 100644 --- a/modules/Settings/src/SimpleModule.Settings/Pages/AdminSettingsEndpoint.cs +++ b/modules/Settings/src/SimpleModule.Settings/Pages/AdminSettingsEndpoint.cs @@ -19,10 +19,10 @@ public void Map(IEndpointRouteBuilder app) async (ISettingsContracts settings, ISettingsDefinitionRegistry registry) => { var definitions = registry.GetDefinitions(); - var storedSettings = await settings.GetSettingsAsync(); + var settingValues = await settings.GetSettingValuesAsync(); return Inertia.Render( "Settings/AdminSettings", - new { definitions, settings = storedSettings } + new { definitions, settings = settingValues } ); } ) diff --git a/modules/Settings/src/SimpleModule.Settings/Pages/UserSettings.tsx b/modules/Settings/src/SimpleModule.Settings/Pages/UserSettings.tsx index 24107335..106704c3 100644 --- a/modules/Settings/src/SimpleModule.Settings/Pages/UserSettings.tsx +++ b/modules/Settings/src/SimpleModule.Settings/Pages/UserSettings.tsx @@ -1,105 +1,254 @@ import { useTranslation } from '@simplemodule/client/use-translation'; -import { - Badge, - Button, - Card, - CardContent, - CardHeader, - CardTitle, - Container, -} from '@simplemodule/ui'; -import { useState } from 'react'; +import { Button, Container, EmptyState } from '@simplemodule/ui'; +import { useCallback, useMemo, useState } from 'react'; import type { SettingDefinition } from '@/components/SettingField'; -import SettingField from '@/components/SettingField'; +import SettingGroup from '@/components/SettingGroup'; +import SettingRow from '@/components/SettingRow'; +import { toAnchorId } from '@/components/SettingsGroupNav'; +import SettingsSearch from '@/components/SettingsSearch'; import { SettingsKeys } from '@/Locales/keys'; -interface UserSettingView { - definition: SettingDefinition; - value: string | null; +interface UserSettingValueDto { + key: string; + value: unknown | null; + resolvedValue: unknown | null; isOverridden: boolean; } +interface ValidationProblemDetails { + type?: string; + title?: string; + status?: number; + detail?: string; + errors?: Record; +} + interface UserSettingsProps { - settings: UserSettingView[]; + definitions: SettingDefinition[]; + settings: UserSettingValueDto[]; +} + +function buildValueMap(settings: UserSettingValueDto[]): Map { + const map = new Map(); + for (const s of settings) { + map.set(s.key, s); + } + return map; +} + +function groupDefinitions(defs: SettingDefinition[]): Record { + const groups: Record = {}; + const sorted = [...defs].sort((a, b) => a.order - b.order); + for (const def of sorted) { + const group = def.group ?? 'General'; + if (!groups[group]) groups[group] = []; + groups[group].push(def); + } + return groups; +} + +function matchesSearch(def: SettingDefinition, query: string): boolean { + if (!query) return true; + const q = query.toLowerCase(); + return ( + def.displayName.toLowerCase().includes(q) || + def.key.toLowerCase().includes(q) || + (def.group ?? '').toLowerCase().includes(q) || + (def.description ?? '').toLowerCase().includes(q) + ); +} + +async function parseErrorDetail(res: Response): Promise { + try { + const body = (await res.json()) as ValidationProblemDetails; + if (body.detail) return body.detail; + if (body.title) return body.title; + if (body.errors) { + const messages = Object.values(body.errors).flat(); + if (messages.length > 0) return messages.join(' '); + } + } catch { + // not JSON — fall through to generic message + } + return `HTTP ${res.status}`; } -export default function UserSettings({ settings: initial }: UserSettingsProps) { +export default function UserSettings({ definitions, settings }: UserSettingsProps) { const { t } = useTranslation('Settings'); - const [settings, setSettings] = useState(initial); - const handleSave = async (key: string, value: string) => { - await fetch('/api/settings/me', { + const [valueMap, setValueMap] = useState>(() => + buildValueMap(settings), + ); + + const [query, setQuery] = useState(''); + const [onlyOverridden, setOnlyOverridden] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + const filteredDefs = useMemo(() => { + return definitions.filter((def) => { + if (!matchesSearch(def, query)) return false; + if (onlyOverridden) { + const v = valueMap.get(def.key); + return v?.isOverridden ?? false; + } + return true; + }); + }, [definitions, query, onlyOverridden, valueMap]); + + const grouped = useMemo(() => groupDefinitions(filteredDefs), [filteredDefs]); + const groupNames = useMemo(() => Object.keys(grouped), [grouped]); + + const handleSave = useCallback(async (key: string, _scope: number, value: unknown) => { + setErrorMessage(null); + const res = await fetch('/api/settings/me', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key, value, scope: 2 }), }); - setSettings((prev) => - prev.map((s) => (s.definition.key === key ? { ...s, value, isOverridden: true } : s)), - ); - }; - - const handleReset = async (key: string) => { - await fetch(`/api/settings/me/${key}`, { method: 'DELETE' }); - setSettings((prev) => - prev.map((s) => - s.definition.key === key - ? { ...s, isOverridden: false, value: s.definition.defaultValue ?? null } - : s, - ), - ); - }; - - const groups: Record = {}; - for (const s of settings) { - const group = s.definition.group ?? 'General'; - if (!groups[group]) groups[group] = []; - groups[group].push(s); - } + if (!res.ok) { + const detail = await parseErrorDetail(res); + setErrorMessage(detail); + return; + } + setValueMap((prev) => { + const next = new Map(prev); + const existing = next.get(key); + if (existing) { + next.set(key, { ...existing, value, isOverridden: true, resolvedValue: value }); + } + return next; + }); + }, []); + + const handleReset = useCallback(async (key: string, _scope: number) => { + setErrorMessage(null); + const res = await fetch(`/api/settings/me/${encodeURIComponent(key)}`, { method: 'DELETE' }); + if (!res.ok) { + const detail = await parseErrorDetail(res); + setErrorMessage(detail); + return; + } + setValueMap((prev) => { + const next = new Map(prev); + const existing = next.get(key); + if (existing) { + next.set(key, { + ...existing, + value: null, + isOverridden: false, + resolvedValue: existing.resolvedValue, + }); + } + return next; + }); + }, []); return ( - -

{t(SettingsKeys.UserSettings.Title)}

- {Object.entries(groups).map(([group, items]) => ( - - - {group} - - - {items.map((s) => ( -
-
- -
- {s.isOverridden ? ( - <> - - {t(SettingsKeys.UserSettings.BadgeOverridden)} - - - - ) : ( - {t(SettingsKeys.UserSettings.BadgeDefault)} - )} -
-
- -
+ +

+ {t(SettingsKeys.UserSettings.Title)} +

+ +
+
+ { + setQuery(q); + setErrorMessage(null); + }} + showOnlyModified={onlyOverridden} + onShowOnlyModifiedChange={setOnlyOverridden} + modifiedLabel={t(SettingsKeys.UserSettings.OnlyOverridden)} + /> +
+
+ + {errorMessage !== null && ( +
+

{t(SettingsKeys.UserSettings.SaveErrorTitle)}

+

{errorMessage}

+ +
+ )} + +
+ + +
+ {groupNames.length === 0 ? ( + setQuery('')}> + Clear search + + ) : undefined + } + /> + ) : ( +
+ {groupNames.map((group) => ( + + {(grouped[group] ?? []).map((def) => { + const v = valueMap.get(def.key); + return ( + + ); + })} + + ))} +
+ )} +
+
); } diff --git a/modules/Settings/src/SimpleModule.Settings/Pages/UserSettingsEndpoint.cs b/modules/Settings/src/SimpleModule.Settings/Pages/UserSettingsEndpoint.cs index 73236af7..a84585f8 100644 --- a/modules/Settings/src/SimpleModule.Settings/Pages/UserSettingsEndpoint.cs +++ b/modules/Settings/src/SimpleModule.Settings/Pages/UserSettingsEndpoint.cs @@ -26,29 +26,33 @@ ClaimsPrincipal principal var userId = principal.FindFirstValue(ClaimTypes.NameIdentifier); var definitions = registry.GetDefinitions(SettingScope.User); - var userSettings = new List(); + var userSettings = new List(definitions.Count); foreach (var def in definitions) { - var resolved = await settings.ResolveUserSettingAsync( - def.Key, - userId ?? string.Empty - ); - var userValue = await settings.GetSettingAsync( + var userDto = await settings.GetSettingValueAsync( def.Key, SettingScope.User, userId ); + var resolvedElement = await settings.ResolveUserSettingElementAsync( + def.Key, + userId ?? string.Empty + ); userSettings.Add( - new + new UserSettingValueDto { - definition = def, - value = resolved, - isOverridden = userValue is not null, + Key = def.Key, + Value = userDto?.Value, + ResolvedValue = resolvedElement, + IsOverridden = userDto is not null, } ); } - return Inertia.Render("Settings/UserSettings", new { settings = userSettings }); + return Inertia.Render( + "Settings/UserSettings", + new { definitions, settings = userSettings } + ); } ) .RequireAuthorization(); diff --git a/modules/Settings/src/SimpleModule.Settings/SettingValidationException.cs b/modules/Settings/src/SimpleModule.Settings/SettingValidationException.cs new file mode 100644 index 00000000..c30a044a --- /dev/null +++ b/modules/Settings/src/SimpleModule.Settings/SettingValidationException.cs @@ -0,0 +1,22 @@ +namespace SimpleModule.Settings; + +public sealed class SettingValidationException : Exception +{ + public string Key { get; } = string.Empty; + public IReadOnlyList Errors { get; } = []; + + public SettingValidationException() { } + + public SettingValidationException(string message) + : base(message) { } + + public SettingValidationException(string message, Exception innerException) + : base(message, innerException) { } + + public SettingValidationException(string key, IReadOnlyList errors) + : base($"Validation failed for setting '{key}': {string.Join("; ", errors)}") + { + Key = key; + Errors = errors; + } +} diff --git a/modules/Settings/src/SimpleModule.Settings/SettingValidator.cs b/modules/Settings/src/SimpleModule.Settings/SettingValidator.cs new file mode 100644 index 00000000..5558e133 --- /dev/null +++ b/modules/Settings/src/SimpleModule.Settings/SettingValidator.cs @@ -0,0 +1,181 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using SimpleModule.Core.Settings; + +namespace SimpleModule.Settings; + +internal static class SettingValidator +{ + private static readonly Regex EmailRegex = new( + @"^[^@\s]+@[^@\s]+\.[^@\s]+$", + RegexOptions.Compiled + ); + + private static readonly Regex ColorRegex = new(@"^#[0-9a-fA-F]{6}$", RegexOptions.Compiled); + + internal static List Validate(SettingDefinition definition, JsonElement value) + { + var errors = new List(); + var isEmpty = + value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined + || (value.ValueKind == JsonValueKind.String && string.IsNullOrEmpty(value.GetString())); + + if (isEmpty) + { + if (definition.Required) + errors.Add($"Setting '{definition.Key}' is required."); + return errors; + } + + ValidateType(definition, value, errors); + ValidateRange(definition, value, errors); + ValidatePattern(definition, value, errors); + ValidateAllowedValues(definition, value, errors); + + return errors; + } + + private static void ValidateType( + SettingDefinition definition, + JsonElement value, + List errors + ) + { + switch (definition.Type) + { + case SettingType.Number: + if (!TryGetNumber(value, out _)) + errors.Add( + $"Setting '{definition.Key}' expects a number but received {value.ValueKind}." + ); + break; + + case SettingType.Bool: + if (value.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) + errors.Add( + $"Setting '{definition.Key}' expects a boolean but received {value.ValueKind}." + ); + break; + + case SettingType.Email: + if ( + value.ValueKind != JsonValueKind.String + || !EmailRegex.IsMatch(value.GetString()!) + ) + errors.Add($"Setting '{definition.Key}' must be a valid email address."); + break; + + case SettingType.Color: + if ( + value.ValueKind != JsonValueKind.String + || !ColorRegex.IsMatch(value.GetString()!) + ) + errors.Add($"Setting '{definition.Key}' must be a hex color like #3b82f6."); + break; + + case SettingType.Url: + if ( + value.ValueKind != JsonValueKind.String + || !Uri.TryCreate(value.GetString(), UriKind.Absolute, out var uri) + || uri.Scheme is not ("http" or "https") + ) + errors.Add($"Setting '{definition.Key}' must be a valid absolute URL."); + break; + + case SettingType.DateTime: + if ( + value.ValueKind != JsonValueKind.String + || !DateTimeOffset.TryParse(value.GetString(), out _) + ) + errors.Add($"Setting '{definition.Key}' must be a valid ISO 8601 timestamp."); + break; + + case SettingType.Text: + case SettingType.Json: + case SettingType.Select: + case SettingType.Password: + case SettingType.MultilineText: + break; + } + } + + private static void ValidateRange( + SettingDefinition definition, + JsonElement value, + List errors + ) + { + if (definition.Type != SettingType.Number) + return; + if (!TryGetNumber(value, out var num)) + return; + + if (definition.Min is { } min && num < min) + errors.Add($"Setting '{definition.Key}' must be at least {min}."); + if (definition.Max is { } max && num > max) + errors.Add($"Setting '{definition.Key}' must be at most {max}."); + } + + private static void ValidatePattern( + SettingDefinition definition, + JsonElement value, + List errors + ) + { + if (string.IsNullOrEmpty(definition.Pattern)) + return; + if (value.ValueKind != JsonValueKind.String) + return; + + // Structured types enforce their own format via ValidateType; skip the user-supplied + // Pattern to prevent reporting two errors for the same invalid value. + if ( + definition.Type + is SettingType.Email + or SettingType.Url + or SettingType.Color + or SettingType.DateTime + ) + return; + + try + { + if (!Regex.IsMatch(value.GetString()!, definition.Pattern)) + errors.Add($"Setting '{definition.Key}' does not match the required pattern."); + } + catch (ArgumentException) + { + errors.Add($"Setting '{definition.Key}' has an invalid pattern regex configured."); + } + } + + private static void ValidateAllowedValues( + SettingDefinition definition, + JsonElement value, + List errors + ) + { + if (definition.AllowedValues is null || definition.AllowedValues.Count == 0) + return; + if (value.ValueKind != JsonValueKind.String) + return; + + var str = value.GetString(); + if (!definition.AllowedValues.Contains(str)) + errors.Add( + $"Setting '{definition.Key}' must be one of: {string.Join(", ", definition.AllowedValues)}." + ); + } + + internal static bool TryGetNumber(JsonElement value, out double result) + { + if (value.ValueKind == JsonValueKind.Number) + { + result = value.GetDouble(); + return true; + } + + result = 0; + return false; + } +} diff --git a/modules/Settings/src/SimpleModule.Settings/SettingsModule.cs b/modules/Settings/src/SimpleModule.Settings/SettingsModule.cs index 11e14e73..f04f20c8 100644 --- a/modules/Settings/src/SimpleModule.Settings/SettingsModule.cs +++ b/modules/Settings/src/SimpleModule.Settings/SettingsModule.cs @@ -117,6 +117,56 @@ public void ConfigureSettings(ISettingsBuilder settings) DefaultValue = "true", Type = SettingType.Bool, } + ) + .Add( + new SettingDefinition + { + Key = "app.primary_color", + DisplayName = "Primary Color", + Description = "Brand color used throughout the application", + Group = "Appearance", + Scope = SettingScope.Application, + DefaultValue = "\"#3b82f6\"", + Type = SettingType.Color, + } + ) + .Add( + new SettingDefinition + { + Key = "app.support_email", + DisplayName = "Support Email", + Description = "Email address displayed on support pages", + Group = "General", + Scope = SettingScope.Application, + Type = SettingType.Email, + Pattern = @"^[^@\s]+@[^@\s]+\.[^@\s]+$", + Placeholder = "support@example.com", + } + ) + .Add( + new SettingDefinition + { + Key = "app.welcome_message", + DisplayName = "Welcome Message", + Description = "Message shown to users on the dashboard", + Group = "General", + Scope = SettingScope.Application, + Type = SettingType.MultilineText, + Placeholder = "Welcome to the application!", + } + ) + .Add( + new SettingDefinition + { + Key = "user.preferred_density", + DisplayName = "Display Density", + Description = "Controls spacing and size of UI elements", + Group = "Appearance", + Scope = SettingScope.User, + DefaultValue = "\"comfortable\"", + Type = SettingType.Select, + AllowedValues = ["compact", "comfortable", "spacious"], + } ); } } diff --git a/modules/Settings/src/SimpleModule.Settings/SettingsService.cs b/modules/Settings/src/SimpleModule.Settings/SettingsService.cs index 6ab10c86..eefb23a7 100644 --- a/modules/Settings/src/SimpleModule.Settings/SettingsService.cs +++ b/modules/Settings/src/SimpleModule.Settings/SettingsService.cs @@ -82,13 +82,33 @@ ILogger logger return definition?.DefaultValue; } + public async Task ResolveUserSettingElementAsync(string key, string userId) + { + var raw = await ResolveUserSettingAsync(key, userId); + if (raw is null) + return null; + + return ParseElement(raw); + } + public async Task SetSettingAsync( string key, - string value, + JsonElement value, SettingScope scope, string? userId = null ) { + var definition = definitions.GetDefinition(key); + + if (definition is not null) + { + var errors = SettingValidator.Validate(definition, value); + if (errors.Count > 0) + throw new SettingValidationException(key, errors); + } + + var storageValue = value.GetRawText(); + var existing = await db.Settings.FirstOrDefaultAsync(s => s.Key == key && s.Scope == scope @@ -99,7 +119,7 @@ public async Task SetSettingAsync( if (existing is not null) { - existing.Value = value; + existing.Value = storageValue; } else { @@ -107,7 +127,7 @@ public async Task SetSettingAsync( new SettingEntity { Key = key, - Value = value, + Value = storageValue, Scope = scope, UserId = scope == SettingScope.User ? userId : null, } @@ -120,7 +140,73 @@ public async Task SetSettingAsync( // IMessageBus is Lazy to break the SettingsService → IMessageBus → AuditingMessageBus // → ISettingsContracts → SettingsService cycle at construction time. - await bus.Value.PublishAsync(new SettingChangedEvent(key, oldValue, value, scope)); + await bus.Value.PublishAsync(new SettingChangedEvent(key, oldValue, storageValue, scope)); + } + + public async Task SetManyAsync(IReadOnlyList updates) + { + // User-scoped settings cannot be bulk-updated because there is no authenticated + // user identity in a bulk request. Callers must use /api/settings/me instead. + var userScopedKey = updates.FirstOrDefault(u => u.Scope == SettingScope.User)?.Key; + if (userScopedKey is not null) + throw new SettingValidationException( + userScopedKey, + [ + "Bulk updates do not support User scope; use /api/settings/me for per-user settings.", + ] + ); + + foreach (var update in updates) + { + var definition = definitions.GetDefinition(update.Key); + if (definition is not null) + { + var errors = SettingValidator.Validate(definition, update.Value); + if (errors.Count > 0) + throw new SettingValidationException(update.Key, errors); + } + } + + var events = new List(updates.Count); + + foreach (var update in updates) + { + var storageValue = update.Value.GetRawText(); + + var existing = await db.Settings.FirstOrDefaultAsync(s => + s.Key == update.Key && s.Scope == update.Scope && s.UserId == null + ); + + var oldValue = existing?.Value; + + if (existing is not null) + { + existing.Value = storageValue; + } + else + { + db.Settings.Add( + new SettingEntity + { + Key = update.Key, + Value = storageValue, + Scope = update.Scope, + UserId = null, + } + ); + } + + events.Add(new SettingChangedEvent(update.Key, oldValue, storageValue, update.Scope)); + } + + await db.SaveChangesAsync(); + + foreach (var evt in events) + { + await cache.RemoveAsync(BuildCacheKey(evt.Key, evt.Scope, null)); + LogSettingUpdated(evt.Key, evt.Scope); + await bus.Value.PublishAsync(evt); + } } public async Task DeleteSettingAsync(string key, SettingScope scope, string? userId = null) @@ -142,7 +228,12 @@ public async Task DeleteSettingAsync(string key, SettingScope scope, string? use } } - public async Task> GetSettingsAsync(SettingsFilter? filter = null) + public Task ResetToDefaultAsync(string key, SettingScope scope, string? userId = null) => + DeleteSettingAsync(key, scope, userId); + + public async Task> GetSettingValuesAsync( + SettingsFilter? filter = null + ) { var query = db.Settings.AsQueryable(); @@ -159,17 +250,72 @@ public async Task> GetSettingsAsync(SettingsFilter? filter query = query.Where(s => keysInGroup.Contains(s.Key)); } - return await query + var entities = await query .AsNoTracking() - .Select(e => new Setting + .Select(e => new { - Key = e.Key, - Value = e.Value, - Scope = e.Scope, - UserId = e.UserId, - UpdatedAt = e.UpdatedAt, + e.Key, + e.Value, + e.Scope, + e.UserId, + e.UpdatedAt, }) .ToListAsync(); + + return entities.Select(e => new SettingValueDto + { + Key = e.Key, + Scope = e.Scope, + Value = IsSensitive(e.Key) ? null : ParseElement(e.Value), + IsOverridden = true, + UserId = e.UserId, + UpdatedAt = e.UpdatedAt, + }); + } + + public async Task GetSettingValueAsync( + string key, + SettingScope scope, + string? userId = null + ) + { + var entity = await db + .Settings.AsNoTracking() + .FirstOrDefaultAsync(s => + s.Key == key + && s.Scope == scope + && (scope == SettingScope.User ? s.UserId == userId : s.UserId == null) + ); + + if (entity is null) + return null; + + return new SettingValueDto + { + Key = entity.Key, + Scope = entity.Scope, + Value = IsSensitive(key) ? null : ParseElement(entity.Value), + IsOverridden = true, + UserId = entity.UserId, + UpdatedAt = entity.UpdatedAt, + }; + } + + private bool IsSensitive(string key) => definitions.GetDefinition(key)?.Sensitive ?? false; + + private static JsonElement? ParseElement(string? raw) + { + if (raw is null) + return null; + + try + { + return JsonSerializer.Deserialize(raw); + } + catch (JsonException) + { + return null; + } } [LoggerMessage( diff --git a/modules/Settings/src/SimpleModule.Settings/components/SettingField.tsx b/modules/Settings/src/SimpleModule.Settings/components/SettingField.tsx index ea4e0ae2..162fefcf 100644 --- a/modules/Settings/src/SimpleModule.Settings/components/SettingField.tsx +++ b/modules/Settings/src/SimpleModule.Settings/components/SettingField.tsx @@ -1,158 +1,45 @@ -import { Button, Input, Switch, Textarea } from '@simplemodule/ui'; -import { useMemo, useState } from 'react'; - -const SettingTypes = { - Text: 0, - Number: 1, - Bool: 2, - Json: 3, -} as const; - -type SettingType = (typeof SettingTypes)[keyof typeof SettingTypes]; - -interface SettingDefinition { - key: string; - displayName: string; - description?: string; - group?: string; - scope: number; - defaultValue?: string; - type: SettingType; -} - -interface SettingFieldProps { +import type React from 'react'; +import type { SettingDefinition } from './fields'; +import { + BoolField, + ColorField, + DateTimeField, + EmailField, + JsonField, + MultilineTextField, + NumberField, + PasswordField, + SelectField, + TextField, + UrlField, +} from './fields'; + +export interface SettingFieldProps { definition: SettingDefinition; - currentValue?: string | null; - onSave: (key: string, value: string, scope: number) => Promise; + value: unknown; + onSave: (value: unknown) => Promise; + onDirty?: (isDirty: boolean) => void; + disabled?: boolean; + autoFocus?: boolean; } -function decodeForDisplay(stored: string, type: SettingType): string { - if (stored === '') return ''; - try { - const parsed = JSON.parse(stored); - switch (type) { - case SettingTypes.Text: - return typeof parsed === 'string' ? parsed : stored; - case SettingTypes.Number: - return typeof parsed === 'number' ? String(parsed) : stored; - case SettingTypes.Bool: - return typeof parsed === 'boolean' ? String(parsed) : stored; - case SettingTypes.Json: - return JSON.stringify(parsed, null, 2); - default: - return stored; - } - } catch { - return stored; - } -} - -function encodeForStorage(input: string, type: SettingType): string { - switch (type) { - case SettingTypes.Text: - return JSON.stringify(input); - case SettingTypes.Number: { - const num = Number(input); - return Number.isFinite(num) && input.trim() !== '' ? String(num) : JSON.stringify(input); - } - case SettingTypes.Bool: - return input === 'true' ? 'true' : 'false'; - case SettingTypes.Json: - return input; - default: - return input; - } -} - -export default function SettingField({ definition, currentValue, onSave }: SettingFieldProps) { - const storedRaw = currentValue ?? definition.defaultValue ?? ''; - const initial = useMemo( - () => decodeForDisplay(storedRaw, definition.type), - [storedRaw, definition.type], - ); - const [value, setValue] = useState(initial); - const [saving, setSaving] = useState(false); - const [jsonError, setJsonError] = useState(null); - const hasChanged = value !== initial; - - const performSave = async (rawInput: string) => { - if (definition.type === SettingTypes.Json) { - try { - JSON.parse(rawInput); - setJsonError(null); - } catch (err) { - setJsonError(err instanceof Error ? err.message : 'Invalid JSON'); - return; - } - } - setSaving(true); - try { - await onSave(definition.key, encodeForStorage(rawInput, definition.type), definition.scope); - } finally { - setSaving(false); - } - }; - - const renderInput = () => { - switch (definition.type) { - case SettingTypes.Text: - return ( - setValue(e.target.value)} /> - ); - case SettingTypes.Number: - return ( - setValue(e.target.value)} - /> - ); - case SettingTypes.Bool: - return ( - { - const newVal = String(checked); - setValue(newVal); - void performSave(newVal); - }} - /> - ); - case SettingTypes.Json: - return ( -