Skip to main content

User Profile

Every Oidis application built on the Hub stack comes with a per-user profile system. The User model stores personal information (name, email, phone), security settings (password, 2FA, auth tokens), group memberships, and a profile avatar — all persisted in MongoDB.

The system provides two built-in interfaces for managing profiles:

  • Admin interface (/users) — full user management for administrators
  • Self-service panel (/profile) — users manage their own data

Both are designed for extension — end applications add custom fields and UI controls through inheritance.

note

User Profile is part of io-oidis-hub (backend) and io-oidis-hub-gui (frontend). It is closely related to the Authentication system, which handles login, sessions, and access control.

The User Model​

The frontend User model defines all properties available for profile management:

import { InclusionConverter } from "@io-oidis-commons/Io/Oidis/Commons/DAO/Converters/InclusionConverter.js";
import { Property } from "@io-oidis-commons/Io/Oidis/Commons/Primitives/Decorators.js";
import { BaseModel } from "@io-oidis-commons/Io/Oidis/Commons/Primitives/BaseModel.js";

export class User extends BaseModel {
@Property({type: String})
public declare UserName : string;

@Property({type: String, required: false})
public declare Firstname : string;

@Property({type: String, required: false})
public declare Lastname : string;

@Property({type: String, required: false, validator: new EmailValidator()})
public declare Email : string;

@Property({type: String, required: false, validator: new PhoneValidator()})
public declare Phone : string;

@Property({type: [String], default: []})
public declare AltEmails : string[];

@Property({type: [Group], default: []})
public declare Groups : Group[];

@Property({type: Group})
public declare DefaultGroup : Group;

@Property({type: [AuthToken], default: []})
public declare AuthTokens : AuthToken[];

@Property({type: Buffer, required: false})
public declare Image : Buffer;

@Property({type: UserAuditLog})
public declare AuditLog : UserAuditLog;

@Property({type: Boolean, required: false, default: true})
public declare Activated : boolean;

@Property({type: String, required: false})
public declare TwoFASecret : string;
}

Properties​

PropertyTypePurpose
UserNamestringLogin identifier (unique, no . or @ characters)
Firstname / LastnamestringPersonal name (optional, used for avatar initials)
EmailstringPrimary email, validated by EmailValidator
PhonestringPhone number, validated by PhoneValidator
AltEmailsstring[]Alternative email addresses
PasswordstringHashed password (never sent to frontend in plain text)
GroupsGroup[]Permission groups the user belongs to
DefaultGroupGroupHidden personal group for individual method permissions
AuthTokensAuthToken[]API authentication tokens with optional expiry
ImageBufferProfile avatar image (see Avatar System)
SSOTokensSSOTokensSSO integration tokens
AuditLogUserAuditLogLogin history: LastLogIn (last successful password or SSO sign-in), LastLogInAttempt and LogInAttempts (password attempts driving the singInCheck lockout), activation notices
ActivatedbooleanWhether the account is active
TwoFASecretstringTOTP secret for two-factor authentication
AuthCodeAuthCodeAuthentication code (nested, InclusionConverter)
WhatsNewSeenstringLast seen release notes version

Dormant Accounts​

AuthManager.FindDormantUsers($days = 180) (admin dashboard → Permissions → "Dormant accounts") lists activated, non-deleted users whose AuditLog.LastLogIn is older than the threshold, together with every user that never signed in, however recently it was created. Each record carries NeverLoggedIn; for those users LastLogIn is null and DaysInactive counts from Created. getDormantAccountEmails($days) returns the e-mails of the same set. Independently, CheckActivation only reminds, deactivates or removes users with a pending activation token.

Backend vs Frontend Model​

The backend entity (@Entity("user") in io-oidis-hub) uses the same properties but with additional converters for database references:

PropertyFrontend TypeBackend Converter
ImageBufferFileEntryToRefConverter — stores as FileEntry reference
GroupsGroup[]GroupListToRefConverter — stores as DB references
DefaultGroupGroupGroupToRefConverter — stores as DB reference
AuthTokensAuthToken[]AuthTokenToRefConverter — stores as DB references
AltEmailsstring[]ArrayConverter — explicit array handling

The converters ensure that related entities (groups, tokens, image files) are stored as MongoDB document references on the backend but arrive as fully resolved objects on the frontend.

Avatar System​

User avatars use a three-level fallback chain to ensure every user always has a visible profile image:

1. Custom image     →  User-uploaded JPG/PNG, stored as FileEntry in MongoDB
↓ (not found)
2. Gravatar → Auto-fetched from gravatar.com using MD5(email)
↓ (not found)
3. Generated avatar → CanvasHelper.CreateAvatar() — initials on themed background

Custom Image Upload​

Users upload an avatar through the profile panel (JPG or PNG). The image is sent as a base64 string to the backend, which stores it as a FileEntry in MongoDB. On retrieval, the backend converts it back to a base64 data URL.

Gravatar Fallback​

When no custom image exists, the backend attempts to fetch the user's Gravatar using an MD5 hash of their email address. The request uses ?d=404 to get a 404 response (instead of a default image) when no Gravatar exists, allowing the system to fall through to the next level.

Generated Initials Avatar​

If both custom image and Gravatar are unavailable, the frontend generates an avatar using CanvasHelper.CreateAvatar():

export class CanvasHelper extends BaseObject {

public static CreateAvatar(user : User, $size : number = 256) : string {
let initials = "??";
if (!ObjectValidator.IsEmptyOrNull(user)) {
if (user.Firstname || user.Lastname) {
initials = `${user.Firstname ? user.Firstname.charAt(0) : ""}${user.Lastname ? user.Lastname.charAt(0) : ""}`;
if (initials.length === 1) {
initials = (user.Firstname || user.Lastname).substring(0, 2);
}
} else if (user.UserName) {
initials = user.UserName.substring(0, 2);
} else if (user.Email) {
initials = user.Email.substring(0, 2);
}
}
// Renders uppercase initials on a canvas using Bootstrap theme colors:
// Background: --bs-primary-bg-subtle (#f2dbe7)
// Text: --bs-primary (#bd4987)
// Font: Application's --bs-font-sans-serif
return canvas.toDataURL("image/png");
}
}

Initials priority: Firstname + Lastname → UserName → Email → ??

The generated avatar respects the application's Bootstrap theme — if the app customizes --bs-primary, the avatar colors change automatically.

Display Pattern​

Both the admin panel and the self-service profile use the same pattern for displaying avatars:

instance.profileImg.Fallback(CanvasHelper.CreateAvatar(profile));  // Canvas fallback
instance.profileImg.Source(<any>profile.Image); // Custom/Gravatar image

The Fallback() method sets the image shown when Source() has no data. This means the canvas avatar is always ready as a fallback, and the custom image (or Gravatar data URL from the backend) overrides it when available.

Admin Interface — UsersPanelController​

The admin interface provides full user management capabilities at the /users route.

@Resolver("/users", ["/users/{userId}"])
export class UsersPanelController<Panel extends UsersPanel> extends BasePanelController<Panel> {
protected currentProfile : User;
protected localization : IUsersPanelLocalization;
private auth : AuthManagerConnector;
private persistence : IPersistenceHandler;

constructor($instance : Panel) {
super($instance);
this.auth = AuthManagerConnector.getClient();
this.persistence = PersistenceFactory.getPersistence(this.getClassName());
}
}

Access: Admin and SysAdmin roles only.

Features​

FeatureDescription
User searchAutocomplete search via UserSearchBinding — find any user by name/email
Profile editingName, email, phone, avatar upload (JPG/PNG via FileUploadBinding)
Password managementChange password, generate random password, create reset/invite links (copyable or sent via email)
Groups & permissionsAdd/remove group memberships, manage individual method permissions on DefaultGroup
Auth tokensGenerate, rename, set expiry, reveal (with password verification for non-admins), revoke
Two-factor authGenerate 2FA secret, display QR code (BarCodeEngine), activate/deactivate
Account lifecycleActivate, deactivate, restore deleted, permanently delete
Permissions exportExport user's authorized methods as JSON file
Session persistenceRemembers last-viewed profile via PersistenceFactory

Key Methods​

  • FetchUserProfile($name) — loads a user profile from the backend, populates all panel controls, renders groups/tokens/2FA state
  • FetchData() — entry point: resolves userId from URL params or persistence, calls FetchUserProfile
  • resolver() — binds all button click handlers (save, reset, password, tokens, 2FA, admin actions)
  • processProfileData($profile) — populates basic profile fields (name, email, phone) — override point for extensions
  • getUserProfileData() — reads profile fields from panel controls into IUserProfileOptions — override point for extensions

Localization​

The controller uses IUsersPanelLocalization for UI messages (error toasts, button labels). Override this in your extended controller to localize messages.

Self-Service — MyProfilePanelController​

The self-service profile panel is available to all authenticated users at the /profile route. It uses this.context (UserContext) to check authentication and retrieve the current user's ID:

@Resolver("/profile")
export class MyProfilePanelController<Panel extends MyProfilePanel> extends UsersPanelController<Panel> {

public async AccessValidator() : Promise<boolean> {
return this.context.IsAuthenticated;
}

public async FetchData() : Promise<void> {
await this.FetchUserProfile(this.context.getUserProfile().Id);
}
}

Disabled Admin Features​

MyProfilePanel extends UsersPanel and disables admin-only features in its constructor:

export class MyProfilePanel extends UsersPanel {
public readonly firstName : TextField;
public readonly lastName : TextField;

constructor() {
super();
this.firstName = new TextField();
this.lastName = new TextField();
this.profileSearch.Enabled(false); // No user search
this.groups.Enabled(false); // No group management
this.profileTokens.Enabled(false); // No token management
}
}

Added Fields​

The self-service panel adds firstName and lastName fields not present in the admin panel. The controller overrides handle these:

protected processProfileData($profile : User) : void {
super.processProfileData($profile);
const instance : MyProfilePanel = this.getInstanceOwner();
instance.firstName.Value($profile.Firstname);
instance.lastName.Value($profile.Lastname);
}

protected getUserProfileData() : IUserProfileOptions {
const instance : MyProfilePanel = this.getInstanceOwner();
return JsonUtils.Extend(super.getUserProfileData(), {
firstname: instance.firstName.Value(),
lastname : instance.lastName.Value()
});
}
tip

Hub GUI's MyProfilePanel ships with Czech labels by default ("Jméno", "Příjmení", "Telefon"). Override getProfile() in your extended panel to localize labels for your application's target language.

Extending the User Profile​

End applications commonly extend the user profile with custom fields. The system supports this through inheritance at every layer — the same pattern used for Application Settings.

Step 1 — Extend the Backend User Entity​

Add custom @Property fields to the backend User model:

import { Entity, Property } from "@io-oidis-commons/Io/Oidis/Commons/Primitives/Decorators.js";
import { User as HubUser } from "@io-oidis-hub/Io/Oidis/Hub/DAO/Models/User.js";

@Entity("user")
export class User extends HubUser {

@Property({type: String, required: false})
public declare Department : string;

@Property({type: String, required: false})
public declare PreferredTheme : string;
}
warning

The extended entity must use the same @Entity("user") collection name as the base entity.

Step 2 — Extend the Frontend User Model​

Mirror the custom properties in the frontend model:

import { Property } from "@io-oidis-commons/Io/Oidis/Commons/Primitives/Decorators.js";
import { User as HubUser } from "@io-oidis-hub-gui/Io/Oidis/Hub/Gui/Models/User.js";

export class User extends HubUser {

@Property({type: String, required: false})
public declare Department : string;

@Property({type: String, required: false})
public declare PreferredTheme : string;
}

Step 3 — Extend the Profile Panel​

Add custom UI controls by extending MyProfilePanel and overriding getProfile():

import { MyProfilePanel as HubPanel } from "@io-oidis-hub-gui/Io/Oidis/Hub/Gui/Panels/MyProfilePanel.js";
import { TextField } from "@io-oidis-usercontrols/Io/Oidis/UserControls/Bootstrap/UserControls/TextField.js";
import { DropDown } from "@io-oidis-usercontrols/Io/Oidis/UserControls/Bootstrap/UserControls/DropDown.js";

export class MyProfilePanel extends HubPanel {
public readonly department : TextField;
public readonly preferredTheme : DropDown;

constructor() {
super();
this.department = new TextField();
this.preferredTheme = new DropDown();
}

protected getProfile() : string {
return `
${super.getProfile()}
<div class="input-group" style="margin-top: 5px;">
<span class="input-group-text" style="width: 20%;">Department</span>
<input class="form-control" type="text" data-oidis-bind="${this.department}" />
</div>
<div class="input-group" style="margin-top: 5px;">
<span class="input-group-text" style="width: 20%;">Theme</span>
<select class="form-select" data-oidis-bind="${this.preferredTheme}"></select>
</div>`;
}
}

Step 4 — Extend the Controller​

Override processProfileData and getUserProfileData to handle the custom fields:

import { Resolver } from "@io-oidis-commons/Io/Oidis/Commons/Primitives/Decorators.js";
import { JsonUtils } from "@io-oidis-commons/Io/Oidis/Commons/Utils/JsonUtils.js";
import { IUserProfileOptions } from "@io-oidis-services/Io/Oidis/Services/Connectors/AuthManagerConnector.js";
import { MyProfilePanelController as HubController } from "@io-oidis-hub-gui/Io/Oidis/Hub/Gui/Controllers/Panels/MyProfilePanelController.js";
import { User } from "../../Models/User.js";
import { MyProfilePanel } from "../../Panels/MyProfilePanel.js";

@Resolver("/profile")
export class MyProfilePanelController extends HubController<MyProfilePanel> {

protected processProfileData($profile : User) : void {
super.processProfileData($profile);
const instance : MyProfilePanel = this.getInstanceOwner();
instance.department.Value($profile.Department);
instance.preferredTheme.Value($profile.PreferredTheme);
}

protected getUserProfileData() : IUserProfileOptions {
const instance : MyProfilePanel = this.getInstanceOwner();
return JsonUtils.Extend(super.getUserProfileData(), {
department : instance.department.Value(),
preferredTheme: instance.preferredTheme.Value()
});
}
}
tip

Both UsersPanelController and MyProfilePanelController use the generic type parameter <Panel extends ...>, specifically designed for this extension pattern. Your extended controller gets full type safety for the custom panel controls.

Step 5 — Map the Options on the Backend​

setUserProfile() and Register() copy only the Hub fields (name, e-mail, phone, activation, 2FA, image) from the options to the entity. Custom fields need an override of processUserData() in the application AuthManager — an option that is not mapped there is silently dropped, although the panel sends it:

import { ObjectValidator } from "@io-oidis-commons/Io/Oidis/Commons/Utils/ObjectValidator.js";
import { UsersManager as HubUsersManager } from "@io-oidis-hub/Io/Oidis/Hub/AuthProcessor/UsersManager.js";
import { AuthManager as Parent } from "@io-oidis-hub/Io/Oidis/Hub/Utils/AuthManager.js";
import { IUserProfileOptions } from "@io-oidis-services/Io/Oidis/Services/Connectors/AuthManagerConnector.js";
import { User } from "../Models/User.js";

export abstract class UsersManager extends HubUsersManager {

protected getUserInstance(this : AuthManager) : User {
return new User();
}

protected async processUserData(this : AuthManager, $user : User, $options : IProfileOptions) : Promise<void> {
await super.processUserData.apply(this, [$user, $options]);
if (ObjectValidator.IsSet($options.department)) {
$user.Department = $options.department;
}
if (ObjectValidator.IsSet($options.preferredTheme)) {
$user.PreferredTheme = $options.preferredTheme;
}
}
}

export class AuthManager extends Parent {
// @ts-expect-error: override of partial protected API
protected getUserInstance = UsersManager.prototype.getUserInstance;
// @ts-expect-error: override of partial protected API
protected processUserData = UsersManager.prototype.processUserData;
}

export interface IProfileOptions extends IUserProfileOptions {
department? : string;
preferredTheme? : string;
}

Creating Users on the Server​

Seeds, imports and admin tools create accounts through the backend AuthManager (Loader.getInstance().getAuthManager()), the same way the registration form does:

  • Register({email, name, password, withActivation: false, withInvitation: false, throwExceptions: true, ...}) creates the account with its hidden default group and sends no e-mail. The password option is hashed — pass a random value for accounts that must not sign in.
  • Register() returns boolean, not the user — resolve the new account by its e-mail afterwards.
  • Activated is set from AppSettings.AutoActivateRegistered after the options are applied, so an activated option has no effect there. Set Activated on the loaded user and save it again.
  • RemoveUser(id) deletes the account for good, together with its default group and tokens; DeleteUser(id) only deactivates and soft-deletes it.
  • Without strictEmailCheck, e-mail lookups — including the duplicate check of Register() — ignore punctuation (., -, _, …) and letter case and match a part of the stored address.

User Profile vs ApplicationSettings​

AspectUser ProfileApplicationSettings
ScopePer-userApp-wide (all users)
StorageMongoDB user collectionMongoDB appSettings collection
AccessSelf-service (/profile) + Admin (/users)Admin-only panel
Use casePersonal data, avatar, preferences, 2FAMail engine, default permissions, feature flags
JSONP syncNo — created at registrationYes — initialized from commons.jsonp
ExtensibleYes — entity + panel + controller + processUserData()Yes — entity + panel + controller

For app-wide settings that all users share, use Application Settings. For per-user preferences and personal data, use the User Profile system.

Quick Reference​

WhatLocationPackage
User model (FE)Hub/Gui/Models/User.tsio-oidis-hub-gui
User entity (BE)Hub/DAO/Models/User.tsio-oidis-hub
UsersPanelControllerHub/Gui/Controllers/Panels/UsersPanelController.tsio-oidis-hub-gui
MyProfilePanelControllerHub/Gui/Controllers/Panels/MyProfilePanelController.tsio-oidis-hub-gui
UsersPanelHub/Gui/Panels/Dashboard/UsersPanel.tsio-oidis-hub-gui
MyProfilePanelHub/Gui/Panels/MyProfilePanel.tsio-oidis-hub-gui
CanvasHelper (avatar)Hub/Gui/Utils/CanvasHelper.tsio-oidis-hub-gui
UsersManager (BE CRUD)Hub/AuthProcessor/UsersManager.tsio-oidis-hub
AuthManagerConnector (FE)Hub/Gui/Connectors/AuthManagerConnector.tsio-oidis-hub-gui

See also: Authentication | Application Settings | Panels and Controllers | Entity and Property | Events System