Skip to content

Settings and app configuration

Coworkee has two kinds of configuration that administrators change at runtime.

Settings App configuration
For single values per installation, tenant or user (SMTP host, theme, registration on/off) a typed options class of the app (limits, feature switches, integrations)
Defined by ISettingDefinitionContributor a C# class, optionally generated from JSON
Stored in settings table, secrets encrypted cw.ConfigurationEntries, read through IConfiguration
Read with ISettingProvider IOptionsMonitor<T>
Admin page Settings, tabs System and Organisation Settings, one tab per class (system tenant only)

Settings

internal sealed class MailSettingDefinitions : ISettingDefinitionContributor
{
    public void Define(SettingDefinitionContext context) =>
        context.Group("Mail", "Mail")
            .Add("Mail.Smtp.Host", "SMTP host", SettingType.String, [SettingScope.Global])
            .Add("Mail.Smtp.Port", "SMTP port", SettingType.Int, [SettingScope.Global], defaultValue: "25")
            .Add("Mail.Smtp.Password", "SMTP password", SettingType.Secret, [SettingScope.Global]);
}

Types are String, Text, Bool, Int, Secret and Choice; scopes are Global, Tenant and User. The most specific value wins. Secrets are encrypted with ASP.NET Core data protection and never sent to the client; settings with visibleToClient: true are.

var allowed = await settings.GetAsync<bool>(AccountSettings.AllowRegistration, ct);

Defaults can also come from configuration: Coworkee:Settings:Defaults:Mail.Smtp.Host. The app host uses that for Mailpit.

The section Cloud file pickers of the System tab holds the ids of Google Drive, OneDrive and Dropbox for every upload field, see Files.

Own settings in the admin page

The tabs System (scope Global) and Organisation (scope Tenant) and the user's My settings (scope User) have no form code per setting. They load the definitions and render them with MudExObjectEditForm, so a setting of your module appears there as soon as its contributor is registered:

Server module
internal sealed class BillingSettingDefinitions : ISettingDefinitionContributor
{
    public void Define(SettingDefinitionContext context) =>
        context.Group("Billing", "Billing")
            .Add("Billing.Currency", "Currency", SettingType.Choice, [SettingScope.Tenant], "EUR", choices: ["EUR", "USD", "CHF"],
                description: "The currency of new invoices.")
            .Add("Billing.ApiKey", "API key", SettingType.Secret, [SettingScope.Global]);
}

context.Services.AddSingleton<ISettingDefinitionContributor, BillingSettingDefinitions>();
Definition Form
group a section with the group's display name
display name, description label and help text (looked up in the translations, so de.json can translate them)
String / Int text or number field; the default value is the placeholder, an empty field falls back to the default
Text a text field with four lines
Bool a check box, showing the stored value or the default
Choice a select of the choices
Secret a password field that stays empty; the help text says whether a value is stored, and only a typed value is sent
scopes a setting shows in the tab of each of its scopes

Saving sends only the changed values; the server checks them (Expected a whole number. and so on) and the message shows in the snackbar. Who sees the tabs follows the Settings.Manage permission; System is shown in the system organisation only.

Behind the form is a class built at runtime with one property per setting (Mail.Smtp.Host becomes Mail_Smtp_Host), so the form behaves like every other object form. Fields have no reset of their own: clear a text or number field to return to the default.

Settings page

Typed app settings

The app describes its settings as classes. Values come from appsettings and the environment; what administrators change is stored in cw.ConfigurationEntries and lies on top for every service of the app. The Settings page renders each class with MudExObjectEditForm, the way the classic settings page did, and services read IOptionsMonitor<T> and see changes within seconds. Values read once at startup apply after a restart.

Every service that should see the changes adds the database layer to its configuration:

Program.cs of the api, the auth server and workers
builder.Configuration.AddCoworkeeDatabaseConfiguration("myapp"); // the connection string name of the app database

Built-in app settings

Without further code the page shows App settings: CoworkeeAppSettings, bound to the section Coworkee, with self registration (Registration), notification digests (Notifications) and background jobs (Jobs). Wiring values the app host sets (Jobs:ConnectionStringName, Jobs:RunServer, Jobs:DashboardPath, Notifications:PublicAppUrl) are locked; lists the configuration binder could only append to (Jobs:Queues, Jobs:RetryDelaysInSeconds) are hidden.

Extend the app settings

Derive from CoworkeeAppSettings and register the type on both sides. The locks of the built-in sections stay; add your own:

Contracts
public sealed class MyAppSettings : CoworkeeAppSettings
{
    public BillingSettings Billing { get; set; } = new();
}

public sealed class BillingSettings
{
    public string Currency { get; set; } = "EUR";
    public string? ApiKey { get; set; }
    public string? WebhookSecret { get; set; }
}
Server module
context.Services.AddCoworkeeSettings<MyAppSettings>(context.Configuration, rules => rules
    .Lock(s => s.Billing.Currency)          // shown, never changed
    .Hide(s => s.Billing.WebhookSecret));   // never leaves the server
Blazor client
builder.Services.AddCoworkeeSettings<MyAppSettings>(meta =>
{
    meta.Property(s => s.Billing.ApiKey).WithGroup("Billing");
    meta.Property(s => s.Billing.Currency).WithGroup("Billing");
});

Services read IOptionsMonitor<MyAppSettings>; the new properties live under Coworkee:Billing.

Replace the app settings

Any other class replaces the built-in one completely, usually with a section of its own:

context.Services.AddCoworkeeSettings<ShopSettings>(context.Configuration, section: "Shop", title: "Shop");   // server
builder.Services.AddCoworkeeSettings<ShopSettings>(section: "Shop", title: "Shop");                         // client

Rules

Lock(s => s.A.B) the value shows read only; the server keeps what applies whatever comes in. Locking an object locks all its properties
Hide(s => s.A.B) the value is neither sent to the browser nor changed
secrets properties named like password, secret, api key, token or connection string come masked; sending the mask back keeps the stored value
meta the client meta groups, labels, orders or renders properties like any MudExObjectEditForm; locks and hides from the server apply on top. The form shows each section under its heading in two columns (lists over the full width); WrapInMudItem(i => i.md = 12) widens a single field. Computed properties (no setter) are left out and fields have no reset of their own; a list item opened in its dialog (like a registration document) follows the same rules
scope typed settings apply to the whole installation and are edited from the system organisation only; per tenant and per user values are settings (above)

Restore defaults drops the values changed in the app; appsettings and environment apply again.

Editors for file types, sizes, schedules and translations

Four attributes from Coworkee.Contracts.Configuration pick a better editor for a property, in the Settings page and in every other object form of the app:

Attribute Property Editor
[ContentTypes] List<string> or string[] of MIME types chips with readable names and icons, and a search over every MIME type Nextended's MimeType knows, by name, file extension (.epub) or MIME type, each hit with its icon and kind (images, documents, videos, audio, archives, text, fonts, 3D models, other). The wildcards (image/*, video/*, audio/*, text/*) and common types come first; the menu next to it adds whole groups (all images, Office documents, ...). It shows at most 50 hits, so the editor stays fast. Empty accepts every type
[FileSize] long? or long in bytes a number in KB, MB or GB; empty means no limit
[Cron] string with a cron expression presets (every few minutes, hourly, daily, weekly, monthly) and the expression itself, described in words (UTC); an invalid expression is not taken
[Translations] Dictionary<string, string> of culture to text a row per language with its name and code; the app's languages to pick, other culture codes like fr-CA typed in, rows removed with their button
Contracts
public sealed class ImportSettings
{
    [Cron]
    public string Schedule { get; set; } = "0 2 * * *";

    [ContentTypes]
    public List<string> AcceptedFiles { get; set; } = ["text/csv"];

    [FileSize]
    public long? MaxFileSize { get; set; } = 10 * 1024 * 1024;

    [Translations]
    public Dictionary<string, string> Titles { get; set; } = [];
}

The built-in settings use them for the registration documents (Registration:Documents, with their names and descriptions per language) and the digest time (Notifications:DigestCron). A RenderWith in the client meta still wins over the attribute.

Labels, help texts and groups from [Display]

[Display] from System.ComponentModel.DataAnnotations describes a property where it is declared, without client code: Name is the label, Description the help text under the field and GroupName the section it shows in. Like every text of the form they go through the translations, so the German text belongs in de.json.

public sealed class BillingSettings
{
    [Display(Name = "Currency", GroupName = "Invoices", Description = "The currency of new invoices.")]
    public string Currency { get; set; } = "EUR";
}

A registration document shows how that reads: its dialog has the sections Shown to the user (name and hint, valid in every language), Translations (optional) (the name and hint for single languages, each row labeled Translation for German and so on) and Upload (required, accepted file types, largest file).

More sections

Further classes show as further tabs next to the app settings:

Server
builder.Configuration
    .AddCoworkeeAppConfigurationDefaults(SharemeConfiguration.Defaults(), SharemeConfiguration.Section)
    .AddCoworkeeDatabaseConfiguration("shareme");

context.Services.AddCoworkeeAppConfiguration<SharemeConfiguration>(context.Configuration, SharemeConfiguration.Section, SharemeConfiguration.Title,
    rules => rules.Lock(c => c.Storage));
Blazor client
builder.Services.AddCoworkeeAppConfiguration<SharemeConfiguration>(SharemeConfiguration.Section, SharemeConfiguration.Title);

AddCoworkeeAppConfigurationDefaults adds a JSON file of the section as the lowest layer. Sharemee generates SharemeConfiguration from appconfig.json with Nextended.CodeGen, so the JSON defaults and the class cannot drift apart:

CodeGen.config.json
{ "JsonConfigs": [ { "SourceFile": "Configuration/appconfig.json", "RootClassName": "SharemeConfiguration", "Namespace": "Shareme.Contracts.Configuration" } ] }

The old address /admin/configuration leads to the Settings page.

Services

CoworkeeServicesOptions (section Coworkee:Services) names the services of the app: the api, auth server and web app, the Aspire dashboard, the Hangfire dashboard, Mailpit, pgAdmin and every other resource with a web endpoint. The app host fills it while developing; deployments list their public addresses in appsettings:

{ "Coworkee": { "Services": {
    "myapp-api": { "Url": "https://api.example.com", "HealthPath": "/health" },
    "grafana": { "Url": "https://grafana.example.com", "Title": "Grafana" } } } }
public sealed class Links(IOptionsMonitor<CoworkeeServicesOptions> services)
{
    public string? Mailpit => services.CurrentValue.GetValueOrDefault("mail")?.Url;
}

Administration > System > Services shows them as tiles with icon, address and live state; a click opens the service in a new tab. The browser asks the api (GET /api/v1/services, settings permission, system organisation), which probes every service every ten seconds: with HealthPath a success status counts as running, without it any answer below 500.