Let’s make User Management better in Business Central

Recently, I got a bit frustrated with creating users in Business Central because user management is scattered across many pages. In this video, I build a centralized user management page to give a better overview and easy access to all the different pieces. Check it out:

https://youtu.be/VY4-6g59hVw

In this video, Erik tackles a common frustration for Business Central administrators: the scattered and fragmented user management experience. When onboarding a new user, you have to navigate through multiple separate pages — user settings, user setup, approval setup, warehouse employees, FA journal setup, and more — with no unified view showing what’s been configured for each user. Erik builds a custom “User Management” page from scratch that consolidates all of this information into a single, easy-to-read list.

The Problem: User Setup is Scattered Everywhere

If you’ve ever had to set up a new user in Business Central, you know the pain. Erik walks through the various places you need to visit:

  • License Configuration — Assigns base permissions based on license type
  • Users — Where users appear after being assigned a license in the Microsoft 365 portal, and where you assign permission sets
  • User Settings — Default company, role center, and personalization (confusingly different from “User Setup”)
  • User Setup — Posting date restrictions and other accounting controls
  • Approval User Setup — Workflow approval configuration
  • Warehouse Employees — Warehouse location assignments
  • FA Journal Setup — Fixed asset journal configuration
  • Email Policies, User Tasks — And even more scattered pages

The naming is also confusing. “User Settings” and “User Setup” are two different things. “Warehouse Employees” is really a user-location assignment. The field is called State in the table but displayed as “Status” on the page. It’s a mess of inconsistency that makes onboarding tedious and error-prone.

The Solution: A Unified User Management Page

Erik’s approach is elegantly simple: create a list page sourced from the User table, then add columns that act as visual indicators (using Unicode checkmark and cross-mark emoji) showing whether each user has been configured in the various setup areas. Each column is also drillable — clicking on it opens the relevant setup page filtered to that user.

Page Structure

The page is a non-editable list based on the User table. The first few columns come directly from the source table:

page 50100 "User Management"
{
    ApplicationArea = All;
    Caption = 'User Management';
    PageType = List;
    SourceTable = User;
    UsageCategory = Administration;
    Editable = false;

    layout
    {
        area(Content)
        {
            repeater(General)
            {
                field("User Name"; Rec."User Name")
                {
                    Width = 5;
                }
                field("Full Name"; Rec."Full Name")
                {
                    Width = 10;
                }
                field(State; Rec.State)
                {
                    Width = 5;
                }
                // ... additional indicator columns follow
            }
        }
    }
}

Adding Indicator Columns with Drill-Down

Each setup area gets its own text variable that displays either a ✅ or ❌. The columns are made clickable by adding an OnDrillDown trigger that opens the relevant setup page, filtered to the current user. Here’s the pattern, shown for the Settings column:

field(SettingsCtl; Settings)
{
    Editable = false;
    Caption = 'Settings';
    Width = 1;
    trigger OnDrillDown()
    var
        UserSettings: Record "User Personalization";
    begin
        UserSettings.Setrange("User SID", Rec."User Security ID");
        Page.RunModal(Page::"User Personalization", UserSettings);
        CurrPage.Update();
    end;
}

The CurrPage.Update() call after the modal page closes ensures the indicators refresh — so if you just configured something for a user, you’ll immediately see the ❌ change to ✅ when you return to the list.

The Same Pattern for Every Setup Area

This pattern repeats for each configuration area. The key differences are the table being queried, the filter field, and the page that opens on drill-down:

  • Settings — Queries User Personalization filtered by User SID, opens the User Personalization page
  • Accounting — Queries User Setup filtered by User ID, opens the User Setup page
  • Approval — Queries User Setup again but checks for approval-related fields, opens the Approval User Setup page
  • Warehouse — Queries Warehouse Employee filtered by User ID, opens the Warehouse Employees page
  • Fixed Assets — Queries FA Journal Setup filtered by User ID, opens the FA Journal Setup page
  • Resource — Queries the Resource table filtered by No., opens the Resource Card
  • Employee — Queries the Employee table filtered by No., opens the Employee Card

Note that the Resource and Employee lookups assume the resource/employee number matches the username — Erik acknowledges this is a simplification that may not hold true in all environments.

Populating the Indicators

The UpdateGlobals procedure runs on both OnAfterGetRecord and OnAfterGetCurrRecord to ensure the indicators are always current. Here’s the complete procedure:

local procedure UpdateGlobals()
var
    UserSettings: Record "User Personalization";
    UserSetup: Record "User Setup";
    WarehouseEmployee: Record "Warehouse Employee";
    FASetup: Record "FA Journal Setup";
    ResourceRec: Record Resource;
    EmployeeRec: Record Employee;
begin
    UserSettings.Setrange("User SID", Rec."User Security ID");
    if UserSettings.IsEmpty() then
        Settings := '❌'
    else
        Settings := '✅';

    UserSetup.Setrange("User ID", Rec."User Name");
    If UserSetup.IsEmpty() then
        AccountingSetup := '❌'
    else
        AccountingSetup := '✅';

    Approval := '❌';
    If UserSetup.FindFirst() then
        if (UserSetup."Sales Amount Approval Limit" <> 0) or
           (UserSetup."Purchase Amount Approval Limit" <> 0) or
           (UserSetup."Unlimited Sales Approval") or
           (UserSetup."Unlimited Purchase Approval") or
           (UserSetup."Unlimited Request Approval") or
           (UserSetup."Unlimited Request Approval") or
           (UserSetup."Approver ID" <> '') then
            Approval := '✅';

    WarehouseEmployee.Setrange("User ID", Rec."User Name");
    if WarehouseEmployee.IsEmpty() then
        Warehouse := '❌'
    else
        Warehouse := '✅';

    FASetup.Setrange("User ID", Rec."User Name");
    if FASetup.IsEmpty() then
        FA := '❌'
    else
        FA := '✅';

    ResourceRec.Setrange("No.", Rec."User Name");
    if ResourceRec.IsEmpty() then
        Resource := '❌'
    else
        Resource := '✅';

    EmployeeRec.Setrange("No.", Rec."User Name");
    if EmployeeRec.IsEmpty() then
        Employee := '❌'
    else
        Employee := '✅';
end;

The Approval Column: A Special Case

The approval indicator was the trickiest to get right. Erik initially tried filtering the User Setup table for a non-blank Approver ID, but realized that’s not the correct way to determine if someone is set up for approvals. In the final code, the logic checks multiple approval-related fields — sales and purchase amount limits, unlimited approval flags, and whether an approver ID is assigned. If any of these are configured, the user gets a green checkmark for approvals.

Gotchas Encountered Along the Way

Erik hit several real-world issues during development that are worth noting:

  • Wrong table name: He initially used User Settings instead of User Personalization, which caused a “table IDs do not match” error. The User Personalization table is what actually stores per-user settings.
  • Inconsistent primary keys: Different tables use different field names — User SID on User Personalization, User ID on User Setup, and No. on Resource and Employee. There’s no consistency in how Business Central identifies users across its tables.
  • Copy-paste errors: Several times, copying and modifying code blocks led to forgetting to update the page reference or table name — a great reminder to be careful with this approach.

Extensibility: The Best Part

One of the most compelling aspects of this design is how easily it can be extended. Because this is a standard AL list page, any app can add its own columns using page extensions. Erik specifically calls out that an app like Advanced Cloud Security could add a column showing whether extended security has been configured for each user — following the exact same pattern of a text variable, an OnDrillDown trigger, and an IsEmpty() check in the UpdateGlobals procedure.

The Complete Source Code

Here’s the full page object for reference:

page 50100 "User Management"
{
    ApplicationArea = All;
    Caption = 'User Management';
    PageType = List;
    SourceTable = User;
    UsageCategory = Administration;
    Editable = false;

    layout
    {
        area(Content)
        {
            repeater(General)
            {
                field("User Name"; Rec."User Name")
                {
                    Width = 5;
                }
                field("Full Name"; Rec."Full Name")
                {
                    Width = 10;
                }
                field(State; Rec.State)
                {
                    Width = 5;
                }
                field(SettingsCtl; Settings)
                {
                    Editable = false;
                    Caption = 'Settings';
                    Width = 1;
                    trigger OnDrillDown()
                    var
                        UserSettings: Record "User Personalization";
                    begin
                        UserSettings.Setrange("User SID", Rec."User Security ID");
                        Page.RunModal(Page::"User Personalization", UserSettings);
                        CurrPage.Update();
                    end;
                }
                field(AccountingSetupCtl; AccountingSetup)
                {
                    Editable = false;
                    Caption = 'Accounting';
                    Width = 1;
                    trigger OnDrillDown()
                    var
                        U: Record "User Setup";
                    begin
                        U.Setrange("User ID", Rec."User Name");
                        Page.RunModal(Page::"User Setup", U);
                        CurrPage.Update();
                    end;
                }
                field(ApprovalCtl; Approval)
                {
                    Editable = false;
                    Caption = 'Approval';
                    Width = 1;
                    trigger OnDrillDown()
                    var
                        U: Record "User Setup";
                    begin
                        U.Setrange("User ID", Rec."User Name");
                        Page.RunModal(Page::"Approval User Setup", U);
                        CurrPage.Update();
                    end;
                }
                field(WarehouseCtl; Warehouse)
                {
                    Editable = false;
                    Caption = 'Warehouse';
                    Width = 1;
                    trigger OnDrillDown()
                    var
                        U: Record "Warehouse Employee";
                    begin
                        U.Setrange("User ID", Rec."User Name");
                        Page.RunModal(Page::"Warehouse Employees", U);
                        CurrPage.Update();
                    end;
                }
                field(FACtl; FA)
                {
                    Editable = false;
                    Caption = 'Fixed Assets';
                    Width = 1;
                    trigger OnDrillDown()
                    var
                        U: Record "FA Journal Setup";
                    begin
                        U.Setrange("User ID", Rec."User Name");
                        Page.RunModal(Page::"FA Journal Setup", U);
                        CurrPage.Update();
                    end;
                }
                field(ResourceCtl; Resource)
                {
                    Editable = false;
                    Caption = 'Resource';
                    Width = 1;
                    trigger OnDrillDown()
                    var
                        U: Record Resource;
                    begin
                        U.Setrange("No.", Rec."User Name");
                        Page.RunModal(Page::"Resource Card", U);
                        CurrPage.Update();
                    end;
                }
                field(EmployeeCtl; Employee)
                {
                    Editable = false;
                    Caption = 'Employee';
                    Width = 1;
                    trigger OnDrillDown()
                    var
                        U: Record Employee;
                    begin
                        U.Setrange("No.", Rec."User Name");
                        Page.RunModal(Page::"Employee Card", U);
                        CurrPage.Update();
                    end;
                }
            }
        }
    }

    trigger OnAfterGetRecord()
    begin
        UpdateGlobals();
    end;

    trigger OnAfterGetCurrRecord()
    begin
        UpdateGlobals();
    end;

    local procedure UpdateGlobals()
    var
        UserSettings: Record "User Personalization";
        UserSetup: Record "User Setup";
        WarehouseEmployee: Record "Warehouse Employee";
        FASetup: Record "FA Journal Setup";
        ResourceRec: Record Resource;
        EmployeeRec: Record Employee;
    begin
        UserSettings.Setrange("User SID", Rec."User Security ID");
        if UserSettings.IsEmpty() then
            Settings := '❌'
        else
            Settings := '✅';

        UserSetup.Setrange("User ID", Rec."User Name");
        If UserSetup.IsEmpty() then
            AccountingSetup := '❌'
        else
            AccountingSetup := '✅';

        Approval := '❌';
        If UserSetup.FindFirst() then
            if (UserSetup."Sales Amount Approval Limit" <> 0) or
               (UserSetup."Purchase Amount Approval Limit" <> 0) or
               (UserSetup."Unlimited Sales Approval") or
               (UserSetup."Unlimited Purchase Approval") or
               (UserSetup."Unlimited Request Approval") or
               (UserSetup."Unlimited Request Approval") or
               (UserSetup."Approver ID" <> '') then
                Approval := '✅';

        WarehouseEmployee.Setrange("User ID", Rec."User Name");
        if WarehouseEmployee.IsEmpty() then
            Warehouse := '❌'
        else
            Warehouse := '✅';

        FASetup.Setrange("User ID", Rec."User Name");
        if FASetup.IsEmpty() then
            FA := '❌'
        else
            FA := '✅';

        ResourceRec.Setrange("No.", Rec."User Name");
        if ResourceRec.IsEmpty() then
            Resource := '❌'
        else
            Resource := '✅';

        EmployeeRec.Setrange("No.", Rec."User Name");
        if EmployeeRec.IsEmpty() then
            Employee := '❌'
        else
            Employee := '✅';
    end;

    var
        Settings: Text;
        AccountingSetup: Text;
        Approval: Text;
        Warehouse: Text;
        FA: Text;
        Resource: Text;
        Employee: Text;
}

Summary

This is a practical, real-world solution to a genuine pain point in Business Central administration. The key techniques demonstrated are:

  • Using text global variables with Unicode emoji as visual status indicators on list pages
  • Implementing OnDrillDown triggers to make non-database fields behave like clickable flow fields
  • Querying multiple unrelated tables from a single page to create a consolidated view
  • Using Width property to create compact indicator columns
  • Refreshing the page with CurrPage.Update() after modal pages close to reflect changes immediately

The result is a single dashboard where an administrator can see at a glance which users have been configured in each area of the system, and click through to fix anything that’s missing. It’s a simple pattern that’s easy to extend — whether by adding more columns to the base page or by having other apps contribute their own columns via page extensions.