Skip to content

Profile and settings tabs ​

Use a contributed tab when a module needs another panel in the local profile or settings page. Each panel is a Vue component with its own loading, validation and save workflow.

Add both components ​

Create src/components/ContactProfileTab.vue. This example uses the real Account profile proxy to edit the phone number. It preserves the other profile values, extra properties and concurrency stamp rather than replacing the profile with a partial body.

vue
<template>
  <form @submit.prevent="save">
    <p v-if="refreshFailed" role="alert">
      Saved, but application configuration could not be refreshed.
      <AbpButton variant="secondary" size="sm" @click="refreshConfiguration"
        >Retry refresh</AbpButton
      >
    </p>
    <p v-if="loading" role="status">Loading profile…</p>
    <p v-if="failed" role="alert">
      Profile could not be loaded. <button type="button" @click="load">Retry</button>
    </p>
    <AbpFormField v-slot="{ id, describedBy, invalid }" label="Phone number" :errors="errors">
      <AbpInput
        :id="id"
        v-model="form.controls.phoneNumber.value"
        type="tel"
        :maxlength="32"
        :disabled="loading || failed || saving"
        :invalid="invalid"
        :aria-describedby="describedBy"
      />
    </AbpFormField>
    <AbpButton type="submit" :loading="saving" :disabled="loading || failed">Save</AbpButton>
  </form>
</template>

<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, shallowRef } from 'vue';
import { ConfigStateService, inject } from '@lsw-abpvue/core';
import { ManageProfileStateService } from '@lsw-abpvue/account-core';
import { ProfileService, type ProfileDto } from '@lsw-abpvue/account-core/proxy';
import {
  AbpButton,
  AbpFormField,
  AbpInput,
  useAbpForm,
  useServerValidation,
  useToaster,
  useValidationMessages,
  Validators,
} from '@lsw-abpvue/theme-shared';
const service = inject(ProfileService);
const state = inject(ManageProfileStateService);
const config = inject(ConfigStateService);
const toaster = useToaster();
const original = shallowRef<ProfileDto>();
const form = useAbpForm({ phoneNumber: { value: '', validators: [Validators.maxLength(32)] } });
useServerValidation(form);
const messages = useValidationMessages();
const errors = computed(() =>
  form.controls.phoneNumber.touched ? messages(form.controls.phoneNumber.errors) : [],
);
const loading = ref(true);
const failed = ref(false);
const saving = ref(false);
const refreshFailed = ref(false);
const controller = new AbortController();
onBeforeUnmount(() => controller.abort());
onMounted(load);
async function load(): Promise<void> {
  loading.value = true;
  failed.value = false;
  try {
    original.value = await service.get({ signal: controller.signal });
    form.reset({ phoneNumber: original.value.phoneNumber ?? '' });
  } catch {
    if (!controller.signal.aborted) failed.value = true;
  } finally {
    loading.value = false;
  }
}
async function save(): Promise<void> {
  if (!original.value || saving.value || !form.validate()) return;
  saving.value = true;
  try {
    const result = await service.update(
      {
        userName: original.value.userName,
        email: original.value.email,
        name: original.value.name,
        surname: original.value.surname,
        concurrencyStamp: original.value.concurrencyStamp,
        extraProperties: original.value.extraProperties,
        phoneNumber: form.value.phoneNumber,
      },
      { signal: controller.signal },
    );
    original.value = result;
    state.set(result);
    form.reset({ phoneNumber: result.phoneNumber ?? '' });
    await refreshConfiguration();
    toaster.success('AbpUi::SavedSuccessfully');
  } catch {
    /* Request handlers report errors; keep the entered phone number. */
  } finally {
    saving.value = false;
  }
}
async function refreshConfiguration(): Promise<void> {
  refreshFailed.value = false;
  try {
    await config.refreshAppState();
  } catch {
    refreshFailed.value = true;
  }
}
</script>

Create src/components/PrintingTab.vue. Use the complete PrintingSettingsAppService from the backend examples. It implements GET/PUT /api/app/printing-settings, accepts/returns { copies: number }, persists a per-user setting and requires AbpIdentity.Users for this walkthrough. These are sample application endpoints, not built-in setting-management endpoints.

vue
<template>
  <form @submit.prevent="save">
    <p v-if="refreshFailed" role="alert">
      Saved, but application configuration could not be refreshed.
      <AbpButton variant="secondary" size="sm" @click="refreshConfiguration"
        >Retry refresh</AbpButton
      >
    </p>
    <p v-if="loading" role="status">Loading printing settings…</p>
    <p v-if="failed" role="alert">
      Settings could not be loaded. <button type="button" @click="load">Retry</button>
    </p>
    <AbpFormField
      v-slot="{ id, describedBy, invalid }"
      label="Default copies"
      :errors="errors"
      required
    >
      <AbpInput
        :id="id"
        v-model="form.controls.copies.value"
        type="number"
        :min="1"
        :max="20"
        :disabled="loading || failed || saving"
        :invalid="invalid"
        :aria-describedby="describedBy"
      />
    </AbpFormField>
    <AbpButton type="submit" :loading="saving" :disabled="loading || failed">Save</AbpButton>
  </form>
</template>

<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { ConfigStateService, inject, RestService } from '@lsw-abpvue/core';
import {
  AbpButton,
  AbpFormField,
  AbpInput,
  useAbpForm,
  useServerValidation,
  useToaster,
  useValidationMessages,
  Validators,
} from '@lsw-abpvue/theme-shared';

interface PrintingSettings {
  copies: number;
}
const rest = inject(RestService);
const config = inject(ConfigStateService);
const toaster = useToaster();
const form = useAbpForm<{ copies: number | null }>({
  copies: { value: 1, validators: [Validators.required(), Validators.range(1, 20)] },
});
useServerValidation(form);
const messages = useValidationMessages();
const errors = computed(() =>
  form.controls.copies.touched ? messages(form.controls.copies.errors) : [],
);
const loading = ref(true);
const failed = ref(false);
const saving = ref(false);
const refreshFailed = ref(false);
const controller = new AbortController();
onBeforeUnmount(() => controller.abort());
onMounted(load);
async function load(): Promise<void> {
  loading.value = true;
  failed.value = false;
  try {
    const result = await rest.request<never, PrintingSettings>(
      { method: 'GET', url: '/api/app/printing-settings' },
      { signal: controller.signal },
    );
    form.reset(result);
  } catch {
    if (!controller.signal.aborted) failed.value = true;
  } finally {
    loading.value = false;
  }
}
async function save(): Promise<void> {
  if (saving.value || loading.value || failed.value || !form.validate()) return;
  saving.value = true;
  try {
    await rest.request(
      { method: 'PUT', url: '/api/app/printing-settings', body: form.value },
      { signal: controller.signal },
    );
    form.reset(form.value);
    await refreshConfiguration();
    toaster.success('AbpUi::SavedSuccessfully');
  } catch {
    /* Request handlers report errors; preserve the form. */
  } finally {
    saving.value = false;
  }
}
async function refreshConfiguration(): Promise<void> {
  refreshFailed.value = false;
  try {
    await config.refreshAppState();
  } catch {
    refreshFailed.value = true;
  }
}
</script>

A failed load disables Save and exposes Retry. A failed save keeps the entered value and field errors. An abort signal ends requests when the panel unmounts.

Register the panels ​

Create src/custom-tabs.ts, adjusting the two imports to the components directory:

ts
import { inject, makeEnvironmentProviders, provideAppInitializer } from '@lsw-abpvue/core';
import { ManageProfileTabsService } from '@lsw-abpvue/account-core';
import { SettingTabsService } from '@lsw-abpvue/setting-management/config';
import ContactProfileTab from './ContactProfileTab.vue';
import PrintingTab from './PrintingTab.vue';

export const customTabs = makeEnvironmentProviders([
  provideAppInitializer(() => {
    inject(ManageProfileTabsService).add([
      {
        name: 'BookStore::Contact',
        text: 'BookStore::Contact',
        order: 3,
        component: ContactProfileTab,
      },
    ]);
    inject(SettingTabsService).add([
      {
        name: 'BookStore::Printing',
        order: 5,
        requiredPolicy: 'AbpIdentity.Users',
        component: PrintingTab,
      },
    ]);
  }),
]);

Add customTabs to your existing startup providers after the selected modules' configuration and default-tab registrations. Keep provideAccountConfig() after OAuth for a local My account destination, provideManageProfileTabs() from account for the personal/password defaults, and provideSettingManagementConfig() for settings. Keep their existing account and setting-management lazy routes. Registering a tab does not create a route or install a backend endpoint.

Add BookStore::Contact and BookStore::Printing to the application's localization resources. Open /account/manage or /setting-management and select the new tab.

Stable identity, order and permission ​

PropertyPurpose
nameStable identity, default localization key
textRequired on profile tabs; optional label override on settings tabs
componentPanel to mount when selected
orderSort order, default zero
requiredPolicyPermission required to see the panel
visible / invisibleAdditional condition / explicit hide flag
parentNameParent group identity when grouping

Patch with tabs.patch(name, { order: 10 }); remove with tabs.remove([name]). Removal also removes descendants. Keep the name stable when changing text. A hidden tab does not remove backend capabilities: its endpoint still enforces authorization.

Refresh and unsaved state ​

The profile example writes the returned profile into ManageProfileStateService, so other panels see the latest shared profile. Refresh application configuration after saving an effective setting or current-user field that other UI reads. Saving a setting override and reading its effective value are different requests; retain a clear error if refresh fails after the save succeeded.

Switching panels may unmount their components. Page/tab navigation is not automatically protected by a modal's dirty-close guard. For a panel requiring unsaved-change confirmation, implement a page navigation policy or edit inside a guarded modal rather than claiming the tab list saves it automatically.

Scope and verification ​

These contributions affect the local pages. They do not modify /Account/Manage rendered by a separate authorization server. Check the actual My account provider and destination.

Test first load, load failure/retry, invalid copies, save failure, denied policy, reopen after save, another tab observing updated profile, and tenant-specific effective settings. See authentication, forms and settings.

Unofficial community project. MIT licensed. Not affiliated with Volosoft.