Replace components
Replace a whole page when contributors cannot express the required UI. The public key is stable, for example Identity.UsersComponent.
Wrap the existing users page
Create src/components/UsersPageWithNotice.vue:
<template>
<section>
<p class="alert alert-info" role="status">{{ localization.t('BookStore::UsersNotice') }}</p>
<UsersPage />
</section>
</template>
<script setup lang="ts">
import { useLocalization } from '@lsw-abpvue/core';
import { UsersPage } from '@lsw-abpvue/identity';
const localization = useLocalization();
</script>This component imports the original UsersPage directly. It adds a notice while preserving the built-in query, CRUD, command tokens, contributors and modal behavior. Rendering the imported original component does not recurse through the replacement registry.
Add BookStore::UsersNotice to your localization resource, then register the wrapper:
import { inject, provideAppInitializer, ReplaceableComponentsService } from '@lsw-abpvue/core';
import { IdentityComponents } from '@lsw-abpvue/identity';
import UsersPageWithNotice from './components/UsersPageWithNotice.vue';
export const replaceUsers = provideAppInitializer(() => {
inject(ReplaceableComponentsService).add({
key: IdentityComponents.Users,
component: UsersPageWithNotice,
});
});Add replaceUsers to the existing startup providers after module registrations. Keep the existing Identity routes. The route's AbpReplaceableRouteContainer picks the registry component or falls back to the module's default.
Rebuild the page instead
A replacement remains under the module route injector: guards, metadata and assembled extension registries remain available. The original page's local state and commands are not automatically instantiated.
If you use AbpExtensibleTable or AbpPageToolbar, default action callbacks may resolve USERS_PAGE. Provide that token with your own add/edit/remove/managePermissions commands using provideAbp. The replacement must also own list refresh, validation, concurrency and dialog state. Alternatively render explicit application columns/actions instead of module extensions.
Changing a registered component at runtime can remount it and discard its local form state. Prefer startup registration; coordinate state explicitly if runtime switching is required.
Replace a theme control
import { provideThemeComponents } from '@lsw-abpvue/theme-shared';
import MyDatePicker from './components/MyDatePicker.vue';
const datePickerOverride = provideThemeComponents({ AbpDatePicker: MyDatePicker });Place the override after the selected theme provider. These twelve controls use the theme contract registry, whereas page/layout components use ReplaceableComponentsService. Implement the documented model, props, events and slots and run the theme contract tests.
Verify
Open users, confirm the notice and existing columns, create/edit/cancel a record, and repeat with a denied route policy. Also check host contributors still apply. For a full rebuild, exercise every command referenced by the default actions.
See extension callbacks, theme contracts and profile/settings tabs.
Complete replacement with module extensions
Create src/components/UsersReplacement.vue from this example:
<template>
<AbpPage title="AbpIdentity::Users">
<template #toolbar><AbpPageToolbar :data="items" /></template>
<p class="alert alert-info">This directory uses the module's contributed columns and fields.</p>
<p v-if="preparing" role="status">Preparing the selected user…</p>
<AbpExtensibleTable :data="items" :list="list" record-key="id" searchable />
<AbpRecordModal
:editor="editor"
label="AbpIdentity::Users"
create-title="AbpIdentity::NewUser"
:save="save"
>
<AbpExtensibleForm
v-if="editor.form.value"
:form="editor.form.value"
:record="editor.editing.value"
/>
<fieldset :disabled="editor.busy.value">
<legend class="h6">Roles</legend>
<AbpToggle
v-for="role in roles"
:key="role.id ?? role.name ?? ''"
:label="role.name"
:model-value="chosenRoles.includes(role.name ?? '')"
@update:model-value="toggleRole(role.name ?? '', $event === true)"
/>
</fieldset>
</AbpRecordModal>
<AbpPermissionManagement
v-if="permissionUser"
v-model:visible="permissionsVisible"
provider-name="U"
:provider-key="permissionUser.id ?? ''"
:entity-display-name="permissionUser.userName ?? ''"
/>
</AbpPage>
</template>
<script setup lang="ts">
import { onBeforeUnmount, ref, shallowRef } from 'vue';
import { inject, useListService } from '@lsw-abpvue/core';
import {
AbpExtensibleForm,
AbpExtensibleTable,
AbpPage,
AbpPageToolbar,
AbpRecordModal,
useRecordEditor,
} from '@lsw-abpvue/components';
import { IdentityComponents, USERS_PAGE } from '@lsw-abpvue/identity';
import {
IdentityUserService,
type IdentityRoleDto,
type IdentityUserCreateDto,
type IdentityUserDto,
type IdentityUserUpdateDto,
} from '@lsw-abpvue/identity/proxy';
import { AbpPermissionManagement } from '@lsw-abpvue/permission-management';
import { AbpToggle } from '@lsw-abpvue/theme-shared';
const users = inject(IdentityUserService);
const list = useListService({ persistKey: IdentityComponents.Users });
const { items } = list.hookToQuery((query, signal) => users.getList(query, { signal }));
const preparing = ref(false);
const roles = shallowRef<IdentityRoleDto[]>([]);
const chosenRoles = ref<string[]>([]);
const permissionUser = shallowRef<IdentityUserDto>();
const permissionsVisible = ref(false);
const controller = new AbortController();
onBeforeUnmount(() => controller.abort());
const editor = useRecordEditor<IdentityUserDto>({
identifier: IdentityComponents.Users,
reload: () => list.get(),
create: body =>
users.create(body as unknown as IdentityUserCreateDto, { signal: controller.signal }),
update: (id, body) =>
users.update(id, body as unknown as IdentityUserUpdateDto, { signal: controller.signal }),
delete: id => users.delete(id, { signal: controller.signal }),
idOf: record => record.id,
nameOf: record => record.userName ?? '',
stampOf: record => record.concurrencyStamp,
deletionMessage: 'AbpIdentity::UserDeletionConfirmationMessage',
providers: [
{
provide: USERS_PAGE,
useValue: {
add: () => void prepare(),
edit: (record: IdentityUserDto) => prepare(record),
remove,
managePermissions,
},
},
],
});
async function prepare(record?: IdentityUserDto): Promise<void> {
if (preparing.value || editor.busy.value) return;
preparing.value = true;
try {
const available = await users.getAssignableRoles({ signal: controller.signal });
roles.value = available.items;
if (record?.id) {
const [detail, assigned] = await Promise.all([
users.get(record.id, { signal: controller.signal }),
users.getRoles(record.id, { signal: controller.signal }),
]);
chosenRoles.value = assigned.items.map(role => role.name ?? '');
editor.show(detail);
} else {
chosenRoles.value = available.items
.filter(role => role.isDefault)
.map(role => role.name ?? '');
editor.show();
}
} catch {
/* Keep the directory usable; framework handlers report the failure. */
} finally {
preparing.value = false;
}
}
async function remove(record: IdentityUserDto): Promise<void> {
if (preparing.value || editor.busy.value) return;
preparing.value = true;
try {
await editor.remove(record);
} finally {
preparing.value = false;
}
}
function managePermissions(record: IdentityUserDto): void {
permissionUser.value = record;
permissionsVisible.value = true;
}
function toggleRole(name: string, checked: boolean): void {
chosenRoles.value = checked
? [...new Set([...chosenRoles.value, name])]
: chosenRoles.value.filter(value => value !== name);
}
function save(): void {
if (!editor.busy.value) void editor.save({ roleNames: [...chosenRoles.value] });
}
</script>It keeps the route's resolved contributor registries and supplies every command that the default Users actions require. It owns the list, loads full details/roles before editing, includes roleNames and concurrencyStamp on save, and opens packaged permission management with provider U and the user id. The casts at the extensible DTO boundary reflect runtime field assembly; they do not validate a body on the client.
Register it with the same replacement initializer used above, changing the imported component to UsersReplacement. Keep the existing Identity config and lazy route, and install/register permission management as in the generated template. This page intentionally does not clear or re-register module defaults. Host column/form/action contributors continue to work.
Choose a level of customization
| Need | Use |
|---|---|
| Add a notice or surrounding content | Wrapper importing the original page |
| Add/reorder a column, field or command | Contributor |
| Change all interaction and layout while retaining module extension contracts | Full replacement with command providers |
| Change one themed input everywhere | Theme component override |
A route component replacement does not change the backend endpoint or its permission requirements. A read-only replacement can use explicit columns instead, but should then document which built-in actions it intentionally removes.
Diagnose a missing command
If a contributed action throws NullInjectorError for USERS_PAGE, check that the replacement creates the page injector before its toolbar/table children mount. Resolving the token from the route alone cannot supply an arbitrary page's local functions. If the replacement recursively renders itself, import the original page directly for wrapping rather than another replaceable container with the same key.