User management & directory verification
The platform stores a user record for everyone who signs in. This article covers the administration features built on top of that store: per-user activity tracking (last seen), verifying users against an external directory (Microsoft Entra ID), listing directory-only users, and deleting users — from the application and, on explicit opt-in, from the directory.
Last seen
The user service stamps LastSeen on the user record whenever the user makes an authenticated request,
throttled to at most one write per interval (default 15 minutes, per process). The stamp happens in
the user-resolve path, so it works whether or not a team is selected.
Tracking is opt-in by entity shape: declare the property on your user entity and the toolkit starts writing it — leave it off and nothing is written.
public record UserEntity : EntityBase, IUser
{
public required string Key { get; init; }
public required string Identity { get; init; }
public required string EMail { get; init; }
public string Name { get; init; }
public DateTime? LastSeen { get; init; } // opt-in: last authenticated activity
public string DirectoryId { get; init; } // opt-in: Entra object id (oid)
}
To change the stamp interval, override the virtual on your user service:
public class UserService : UserServiceRepositoryBase<UserEntity>
{
protected override TimeSpan? LastSeenStampInterval => TimeSpan.FromMinutes(5);
// TimeSpan.Zero stamps every resolve; null disables stamping entirely.
}
The users admin list (<UsersView /> → Users tab) shows a Last seen column. This is distinct from
the per-team-member LastSeen, which tracks when a member last selected that team.
The same list shows a Teams count per user, with the memberships behind it. How much it counts depends
on the caller's own visibility: without the teams:read system scope it counts only teams the caller
belongs to, so a user whose teams the caller shares none of reads as 0. Grant teams:read — see
cross-team visibility — and the count
covers every team, matching the Teams tab of the same component.
Directory linking (DirectoryId)
Directory operations resolve the user by the Entra object id (oid). The toolkit captures it two ways:
New users — populate it in
CreateUserEntityAsyncfrom the sign-in claims:protected override Task<UserEntity> CreateUserEntityAsync(ClaimsPrincipal principal, string identity) => Task.FromResult(new UserEntity { Key = ..., Identity = identity, EMail = principal.GetEmail(), Name = principal.GetDisplayName(), DirectoryId = principal.GetDirectoryId() // the oid claim, either raw or .NET-mapped });Existing users — backfilled automatically from the
oidclaim on their next authenticated visit, and by directory verification: when a user without a stored id is matched by email, the found object id is persisted (relink).
Registering Microsoft Entra ID as the directory
Install Tharga.Team.Entra and register it; configuration is read from the same AzureAd section
the platform sign-in uses:
builder.Services.AddThargaEntraUserDirectory(builder.Configuration);
App-only Graph authentication needs a client secret (or any Azure.Core.TokenCredential — certificate,
managed identity):
// dotnet user-secrets set "AzureAd:ClientSecret" "<secret>"
builder.Services.AddThargaEntraUserDirectory(builder.Configuration, o =>
{
// o.Credential = new ManagedIdentityCredential(); // instead of a secret
});
Grant the app registration application permissions in Entra, with admin consent:
| Feature | Graph permission |
|---|---|
| Verify users, list directory-only users | User.Read.All |
| Delete users from Entra | User.ReadWrite.All |
When no directory service is registered, all directory features (verify actions, the Directory column, the directory-only tab, the delete-from-directory opt-in) are hidden — the rest of user administration still works.
The users:manage scope
All user administration — including viewing the users and teams admin lists — requires the
users:manage system scope (registered automatically, like teams:delete). Grant it by mapping
an app role:
o.ConfigureSystemRoles = roles =>
{
roles.Map("Developer", SystemUserScopes.Manage);
};
Authorization is enforced in the service layer by decorators over IUserManagementService and
IUserService itself, so the same rules protect the Blazor circuit and any consumer REST endpoint:
- Self-service passes for any authenticated caller — resolving the current user, the invitation-accept name seeding, and setting one's own display name.
- Co-members pass for any authenticated caller —
IUserService.GetTeamMemberUsersAsync()returns the users who share at least one team with the caller, plus the caller. It takes no argument, so the visibility set is derived entirely from the caller's own memberships and there is nothing to widen. - Everything cross-user requires
users:manage— enumerating all users, reading a user by key, setting another user's name, activity/directory writes, and deletion.
Team access level never grants users:manage — it is a system scope, mapped from app roles, while
access levels grant only team scopes. The two arrive as different claim types
(TeamClaimTypes.SystemScope and TeamClaimTypes.Scope), so a team-level grant of a scope name cannot
satisfy a system-wide check even where the same name is registered at both levels.
A team owner is therefore an ordinary caller here, and the
co-member projection is what lets <TeamComponent /> show member emails, names and avatars without it.
Without that projection a member row falls back to "Unknown" with no email, because accepting an
invitation clears the per-team name override and promotes it to IUser.Name.
The <UsersView /> tabs check the scope up front and render a notice instead of the lists when the
caller lacks it. Still protect the page that embeds the component with [Authorize] — and note that
Blazor only enforces page-level [Authorize] when your router uses AuthorizeRouteView (an attribute
on a non-page component does nothing).
Where names are edited
A user has one root name (IUser.Name), shared everywhere, and optionally a per-team override
(ITeamMember.Name) that applies only inside one team. Each is edited on exactly one surface:
| Surface | Edits | Who |
|---|---|---|
<UserProfileView /> (profile page) |
root name | the user, for themselves |
<UsersView /> (users page) |
root name | a holder of users:manage |
<TeamComponent /> (team page) |
per-team override only | a holder of member:manage on the selected team |
The team surface never writes the root name. Submitting a member's displayed name unchanged stores no override, so the row keeps tracking that user's later renames rather than pinning a copy of the name.
Accepting an invitation clears the per-team override and promotes the admin-entered name to IUser.Name,
so an accepted member's name and email come from the user record — see the co-member note above.
Verifying users
- Per user — the Verify action on a row checks the directory and shows a badge: Found (exists, enabled), Disabled (exists, account disabled), Not found (the stored directory id no longer exists — the user was deleted in Entra), Not linked (no directory id and no email match).
- Verify all — sweeps every user, updating badges as results stream in.
Verification by a stored directory id deliberately does not fall back to email: a broken link is a finding, not a lookup miss.
Directory-only users
The Users in directory only tab on <UsersView /> lists users that exist in the directory but have no local
user record — matched by directory id with an email fallback, so pre-existing local users without a
stored oid are not falsely reported. Nothing is fetched until you press Load (a tenant's
directory can be large); results stream in page by page.
Deleting users
The Delete action (or IUserManagementService.DeleteUserAsync) always performs the local delete:
- Removes the user from every team (any membership state).
- Deletes the user record.
- Writes audit entries.
Deleting from the directory is an explicit opt-in — a checkbox in the confirm dialog, off by default — because it removes the account organization-wide, not just from your application. Entra performs a soft delete: an administrator can restore the user for 30 days.
A directory failure never rolls back the local delete; it is reported on the result:
var result = await userManagementService.DeleteUserAsync(userKey, deleteFromDirectory: true);
// result.RemovedTeamCount, result.DirectoryDeleted, result.DirectoryError
Audit
User administration is audited under feature user: verify (with the outcome), verify-all (one
summary entry with the processed count), and delete (team count, whether the directory user was
deleted, any directory error). The all-team removal inside a delete is additionally audited under
feature team as remove-member-all. The directory-only listing is a read and is not audited.
Custom directory providers
Entra is an implementation of the IUserDirectoryService abstraction. To back verification against a
different directory, implement the interface (verify a user, delete by directory id, enumerate users)
and register it:
builder.AddThargaTeam(o =>
{
o.AddUserDirectoryService<MyLdapDirectoryService>();
});