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:

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 Personalizationfiltered byUser SID, opens the User Personalization page - Accounting — Queries
User Setupfiltered byUser ID, opens the User Setup page - Approval — Queries
User Setupagain but checks for approval-related fields, opens the Approval User Setup page - Warehouse — Queries
Warehouse Employeefiltered byUser ID, opens the Warehouse Employees page - Fixed Assets — Queries
FA Journal Setupfiltered byUser ID, opens the FA Journal Setup page - Resource — Queries the
Resourcetable filtered byNo., opens the Resource Card - Employee — Queries the
Employeetable filtered byNo., 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 Settingsinstead ofUser 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 SIDon User Personalization,User IDon User Setup, andNo.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.