Contember ships a full set of UI Components for a tenant management dashboard — sign-in, self-service account settings, project membership, API keys, tenant-wide administration, and project secrets — on top of the Tenant API.

Available since 2.2

Account self-service (profile, e-mail OTP, backup codes, passwordless toggle, sessions), admin person management (PersonDetail, disable/enable/force-sign-out/reset-MFA, global roles), the audit log listing, and project secrets are new in this release. Earlier versions only covered sign-in, invites, project members, and API keys.

Two layers, one pairing

The tenant UI is split across two packages, same as the rest of the interface:

  • @contember/react-client-tenant ships the form providers, triggers, and hooks — the data layer. A provider (e.g. ChangeProfileForm) runs the mutation and exposes a useXForm() context; a trigger (e.g. DisablePersonTrigger) fires a single mutation on click.
  • @contember/react-ui-lib-tenant ships the matching field components (e.g. ChangeProfileFormFields) and listing components (e.g. PersonsList) — pre-styled Tailwind/Shadcn markup with no logic of its own. A field component reads its state through the provider's useXForm() hook, so it only renders correctly nested inside the matching provider.

A host app always pairs the two:

<ChangeProfileForm personId={personId} onSuccess={() => refresh()}>
	<form className="grid gap-4">
		<ChangeProfileFormFields />
	</form>
</ChangeProfileForm>

The provider/trigger/hook half is re-exported through react-identity and then @contember/interface, so it is reachable with a single import next to the rest of the binding API. The field/listing half is not re-exported through interface — pull it from @contember/react-ui-lib-tenant directly, or, in a project scaffolded by @contember/create, from the copy already sitting in your project.

The admin/lib/tenant copy

Like the rest of the UI Components, react-ui-lib-tenant is meant to be owned, not depended on. scripts/assemble-ui-lib.mjs copies its source into admin/lib/tenant at scaffold time (and again on scripts/update-ui-lib.sh), rewriting the @contember/react-ui-lib-tenant imports to ~/lib/tenant. The template imports it as:

import { ApiKeyList, PersonDetail, PersonsList, /* … */ } from '~/lib/tenant'

Open any of those files under admin/lib/tenant and edit freely — styling, markup, added columns — there is nothing to eject from.

Sign-in

ExportWhat it doesFrom
LoginForm / LoginFormFieldsE-mail + password sign-in; walks the otp-required step for TOTP or e-mail OTP, with a (since 2.2) backup-code fallbackclient-tenant / ui-lib-tenant
PasswordlessSignInInitForm / PasswordlessSignInInitFormFieldsRequests a magic-link / one-time-code e-mailclient-tenant / ui-lib-tenant
PasswordlessSignInForm / PasswordlessSignInFormFieldsVerifies the magic-link token or code; same OTP + (since 2.2) backup-code step as LoginFormclient-tenant / ui-lib-tenant
PasswordResetRequestForm / PasswordResetRequestFormFieldsRequests a password-reset e-mailclient-tenant / ui-lib-tenant
PasswordResetForm / PasswordResetFormFieldsSets a new password from a reset tokenclient-tenant / ui-lib-tenant
VerifyEmailForm / VerifyEmailFormFieldsConfirms an e-mail-verification tokenclient-tenant / ui-lib-tenant
ConfirmEmailChangeForm / ConfirmEmailChangeFormFieldsConfirms a pending e-mail address changeclient-tenant / ui-lib-tenant
RequestEmailVerificationForm / RequestEmailVerificationFormFieldsRe-sends the e-mail verification linkclient-tenant / ui-lib-tenant
IDP, IDPInitTriggerRedirect-based sign-in with an external identity provider (OIDC, Google, …)client-tenant
SignUpForm / SignUpFormFields(since 2.2) Open registration. Creates the account only — it does not sign anyone in, so follow a success with LoginForm, or with a "check your inbox" screen when config.signup.requireEmailVerification is onclient-tenant / ui-lib-tenant
IdP buttons are configured, not discovered

IDPInitTrigger needs its provider slug from your own application config. Query.identityProviders requires idp:list, so an unauthenticated login page cannot ask the API which providers exist.

My account

ExportWhat it doesFrom
ChangeMyProfileForm / ChangeMyProfileFormFieldsEdit your own e-mail and nameclient-tenant / ui-lib-tenant
ChangeMyPasswordForm / ChangeMyPasswordFormFieldsChange your own password (current + new)client-tenant / ui-lib-tenant
OtpSetupAuthenticator-app (TOTP) enroll / disable, including the QR codeui-lib-tenant
EmailOtpSetupE-mail one-time-code 2FA enroll / disableui-lib-tenant
BackupCodes / BackupCodesDisplayView the current MFA state and regenerate recovery codes; BackupCodesDisplay renders a fresh code set once, right after it is issuedui-lib-tenant
PasswordlessToggleEnable / disable passwordless sign-in for yourselfui-lib-tenant
SessionList (no personId)Your own active sessions, with a revoke action per rowui-lib-tenant
IdentityProviderConnectionsConnected external identity providers, with a disconnect actionui-lib-tenant

Project members

ExportWhat it doesFrom
InviteForm / InviteFormFieldsInvite a new person by e-mail, with initial project roles. (since 2.2) Pass allowUnmanaged to both to offer a "do not send an invitation e-mail" checkbox, which switches the submit to unmanagedInvite and reveals an optional password field — for seeding, migrations and air-gapped setups. Passing it only to the fields throws on submit instead of quietly mailingclient-tenant / ui-lib-tenant
AddProjectMemberForm / AddProjectMemberFormFieldsAdd an existing identity (by id) to the project, with rolesclient-tenant / ui-lib-tenant
UpdateProjectMemberForm / UpdateProjectMemberFormFieldsChange an existing member's roles and membership variablesclient-tenant / ui-lib-tenant
PersonListMemberList specialized to memberType: 'PERSON' — the project's member table with roles, MFA badges, edit/removeui-lib-tenant
MembershipsControl, useIntrospectionRolesConfigRole/variable picker; roles are introspected from the project's schema unless you pass your own RolesConfigui-lib-tenant

API keys

ExportWhat it doesFrom
CreateApiKeyForm / CreateApiKeyFormFieldsCreate a project-scoped permanent API keyclient-tenant / ui-lib-tenant
ApiKeyListThe project's permanent keys — roles, status, created/last-used/expiry, disable action. Rewritten this release onto useProjectApiKeysQueryui-lib-tenant
CreateGlobalApiKeyForm / CreateGlobalApiKeyFormFieldsCreate a tenant-wide (global) API key with global rolesclient-tenant / ui-lib-tenant
GlobalApiKeyListGlobal keys listing + disable actionui-lib-tenant

Tenant administration

Needs a global role (typically SUPER_ADMIN) or an explicit tenant ACL grant — see Permissions below.

ExportWhat it doesFrom
PersonsListTenant-wide person listing — e-mail filter, roles, MFA badges, (since 2.2) a disabled-state column, row actions, and an onSelectPerson callback for opening PersonDetailui-lib-tenant
PersonDetailAdmin view of one person — profile, password, MFA state + reset, global roles, sessions, connected identity providersui-lib-tenant
DisablePersonAction / EnablePersonAction / ForceSignOutPersonAction / ResetPersonMfaActionConfirm-dialog action buttons (from person-actions.tsx), each wrapping the matching trigger belowui-lib-tenant
DisablePersonTrigger / EnablePersonTrigger / ForceSignOutPersonTrigger / ResetPersonMfaTriggerThe one-shot mutation triggers behind the actions aboveclient-tenant
ChangeProfileForm / ChangeProfileFormFieldsAdmin edits another person's e-mail and nameclient-tenant / ui-lib-tenant
ChangePasswordForm / SetPersonPasswordFormFieldsAdmin sets another person's password directly (no current-password check)client-tenant / ui-lib-tenant
GlobalRolesControlAdd / remove tenant-wide (global) roles on an identityui-lib-tenant
AuthLogListThe authentication audit log — filter by event type(s), success/failure, and person identifier, with paginationui-lib-tenant

Project secrets

ExportWhat it doesFrom
SetProjectSecretForm / SetProjectSecretFormFieldsSet a project secret's value. The value is write-only — it is never returned by the API againclient-tenant / ui-lib-tenant
ProjectSecretListSecret keys and timestamps only, never valuesui-lib-tenant

Configuration (read-only)

(since 2.2) These four views display configuration that is written with contember tenant:apply. There is no editing counterpart by design — each view says so, rather than offering an affordance it would have to refuse.

ExportWhat it doesFrom
TenantConfigViewTenant-wide settings — sign-up, e-mail change, password policy, passwordless, login backoff, anomaly detection, captcha, rate limitsui-lib-tenant
AuthPolicyListConfigured per-role MFA / session policies. Without it an enforced MFA requirement is invisible to an administrator — they only meet it as a sign-in promptui-lib-tenant
IdentityProviderListIdentity providers on the tenant, with the public configuration collapsed behind a toggleui-lib-tenant
MailTemplateListConfigured mail templates, body collapsed behind a toggle. A type missing here means the built-in default is in useui-lib-tenant
These queries reject rather than return empty

Unlike the listing queries, all four resolvers throw a ForbiddenError for a caller without the permission. Each one gates on its own action — system:viewConfig for the tenant settings, system:configure for auth policies, idp:list for providers, mailTemplate:list for templates — so a narrow ACL grant has to name the right one. Use isForbiddenError from react-client-tenant to tell that apart from a real failure — the components already do, and render "you do not have permission to view this" instead of an error box.

Policies aggregate, they do not override by specificity: an identity is subject to every policy matching any of its roles, and the strictest value wins. AuthPolicyList states this under the table.

End-to-end example

A provider + fields pairing, from the project template's admin/app/pages/tenant.tsx (~/lib/tenant is the copied react-ui-lib-tenant, see above):

import { ChangeMyProfileForm, useIdentity } from '@contember/react-identity'
import { ChangeMyProfileFormFields } from '~/lib/tenant'

const person = useIdentity()?.person

// keyed on the identity values: the form snapshots initialValues on mount
<ChangeMyProfileForm
	key={`${person?.email ?? ''} ${person?.name ?? ''}`}
	initialValues={{ email: person?.email ?? '', name: person?.name ?? '' }}
	onSuccess={() => showToast(<ToastContent>Profile updated</ToastContent>, { type: 'success' })}
>
	<form className="grid gap-4">
		<ChangeMyProfileFormFields />
	</form>
</ChangeMyProfileForm>

The key matters: the form snapshots initialValues when it mounts, so re-keying on the loaded values is what makes the fields pick up a fresh profile after e.g. a refetch. See the full Security, Members, ApiKeys, Persons, AuditLog, Configuration and ProjectSecrets page components in that same template file for how the pieces above compose into full pages.

Refreshing a listing

Every listing takes an optional controller ref and assigns a refresh handle to it, so a host app can reload the table after a mutation it owns:

const members = useRef<MemberListController>(undefined)

<AddProjectMemberForm projectSlug={slug} onSuccess={() => members.current?.refresh()}>
  <AddProjectMemberFormFields projectSlug={slug} />
</AddProjectMemberForm>
<MemberList controller={members} />

The controller type is named after its listing (MemberListController, PersonsListController, AuthLogListController, …) and the pattern is the same for all of them.

Permissions

Permission failure is not uniform, so these three listings behave differently:

  • GlobalApiKeyList reads globalApiKeys, which checks server-side and returns an empty array when the grant is missing — an under-privileged caller just sees an empty table.
  • PersonsList reads persons, which narrows rather than empties: without person:list the caller still gets the members of the projects they administer, and an empty table only means there were none.
  • AuthLogList reads authLog, which requires system:viewAuthLog and throws. The list detects that with isForbiddenError and renders a "no permission" notice, keeping its failed-to-load state for genuine failures.

None of the three crashes the page. The read-only configuration views above throw in the same way as authLog and render the same kind of notice. See permission introspection for asking up front what the caller may do, and Tenant ACL permissions for granting these.

Not covered here

Writing tenant configuration, auth policies, mail templates and identity providers stays code/IaC-driven — read-only views exist (above), but the write path is contember tenant:apply. Project lifecycle (createProject / updateProject) has no UI either.

createSessionToken — impersonation / support login — has a hook and a CreateSessionTokenForm provider in react-client-tenant but deliberately no styled component: it is the most sensitive operation in the API, so a host app has to build the surface it wants around it.