src/app/core/user/user-details/user-details.component.ts

Description

Reusable user account details form component.

Example

Metadata

Relationships

Depends on

Index

Properties
Methods
Inputs
Outputs

Constructor

constructor()

Inputs

isInDialog
Type : boolean
Default value : !!this._dialogData || false
isProfileMode
Type : boolean
Default value : false
userAccount
Type : UserAccount | null
Default value : this._dialogData?.userAccount

Outputs

userAccount
Type : UserAccount | null

Methods

cancel
cancel()
Returns : void
changePassword
changePassword()
Returns : void
Async deleteAccount
deleteAccount()
Returns : any
editMode
editMode()
Returns : void
Async enableAccount
enableAccount(enabled: boolean)
Parameters :
Name Type Optional
enabled boolean No
Returns : any
getFormError
getFormError(field: string, errorType: string)
Parameters :
Name Type Optional
field string No
errorType string No
Returns : boolean
getGlobalError
getGlobalError()
Returns : string | null
Async resendInvitation
resendInvitation()
Returns : any
Async save
save()
Returns : any

Properties

availableRoles
Type : unknown
Default value : resource<Role[], unknown>({ loader: async () => { if (this.isProfileMode()) { return this.userAccount()?.roles ?? []; } try { return await lastValueFrom(this.userAdminService.getAllRoles()); } catch (err) { // in profile view, this may be expected Logging.debug("Failed to load available roles:", err); return []; } }, defaultValue: [], })
creatingNewAccount
Type : unknown
Default value : computed(() => { return !this.userAccount()?.id && !this.isProfileMode(); })
Protected currentUser
Type : unknown
Default value : inject(CurrentUserSubject, { optional: true })
form
Type : FormGroup
formDisabled
Type : unknown
Default value : signal(true)

Signal tracking whether the form is disabled (view mode) or enabled (edit mode). This is automatically updating the form.disabled also.

invitationPending
Type : unknown
Default value : computed(() => { return ( !this.isProfileMode() && !this.creatingNewAccount() && this.userAccount()?.emailVerified === false ); })

Whether the account was created but the user has not yet completed the invitation email (verified email + set initial password).

passwordChangeDisabled
Type : unknown
Default value : computed(() => { if (!this.isProfileMode()) return false; if (!hasRemoteSession(environment.session_type)) { return true; // Disabled in demo mode } if (typeof navigator !== "undefined" && !navigator.onLine) { return true; // Disabled when offline } return false; })
resendingInvitation
Type : unknown
Default value : signal(false)

Whether a resend invitation request is currently in progress (disables the button to prevent duplicate emails).

showPasswordChange
Type : unknown
Default value : computed(() => this.isProfileMode())
userAccountEntityTypes
Type : unknown
Default value : computed(() => this.entityRegistry .getEntityTypes() .filter(({ value }) => value.enableUserAccounts) .map(({ key }) => key), )
import {
  ChangeDetectionStrategy,
  Component,
  computed,
  effect,
  inject,
  input,
  model,
  resource,
  signal,
} from "@angular/core";
import { HttpClient } from "@angular/common/http";
import {
  FormBuilder,
  FormControl,
  FormGroup,
  ReactiveFormsModule,
  Validators,
} from "@angular/forms";
import { MatFormFieldModule } from "@angular/material/form-field";
import { MatInputModule } from "@angular/material/input";
import { MatSelectModule } from "@angular/material/select";
import { MatTooltipModule } from "@angular/material/tooltip";
import { MatButtonModule } from "@angular/material/button";
import { MatMenuModule } from "@angular/material/menu";
import { FontAwesomeModule } from "@fortawesome/angular-fontawesome";
import {
  MAT_DIALOG_DATA,
  MatDialogModule,
  MatDialogRef,
} from "@angular/material/dialog";
import { Role, UserAccount } from "../user-admin-service/user-account";
import { UserAdminService } from "../user-admin-service/user-admin.service";
import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
import { DialogCloseComponent } from "../../common-components/dialog-close/dialog-close.component";
import { AlertService } from "../../alerts/alert.service";
import { KeycloakAuthService } from "../../session/auth/keycloak/keycloak-auth.service";
import { CurrentUserSubject } from "../../session/current-user-subject";
import { ConfirmationDialogService } from "../../common-components/confirmation-dialog/confirmation-dialog.service";
import { Angulartics2Module } from "angulartics2";
import { environment } from "../../../../environments/environment";
import { hasRemoteSession } from "../../session/session-type";
import { EditEntityComponent } from "../../basic-datatypes/entity/edit-entity/edit-entity.component";
import { lastValueFrom, of, firstValueFrom } from "rxjs";
import { Entity } from "../../entity/model/entity";
import { EntityRegistry } from "../../entity/database-entity.decorator";
import { Logging } from "#src/app/core/logging/logging.service";
import { catchError, map } from "rxjs/operators";
import {
  entityIdsMatch,
  UserAccountActionGuardService,
} from "../user-admin-service/user-account-action-guard.service";

/**
 * Options as input to the UserDetailsComponent when it is opened in a dialog.
 */
export interface UserDetailsDialogData {
  userAccount: UserAccount | null;
}

/**
 * Return value of the UserDetailsComponent after a user interacts with it.
 */
export interface UserDetailsAction {
  type: "formCancel" | "editRequested" | "accountCreated" | "accountUpdated";
  data?: any;
}

/**
 * Reusable user account details form component.
 */
@UntilDestroy()
@Component({
  selector: "app-user-details",
  templateUrl: "./user-details.component.html",
  styleUrls: ["./user-details.component.scss"],
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [
    ReactiveFormsModule,
    MatFormFieldModule,
    MatInputModule,
    MatSelectModule,
    MatTooltipModule,
    MatButtonModule,
    MatMenuModule,
    FontAwesomeModule,
    MatDialogModule,
    DialogCloseComponent,
    Angulartics2Module,
    EditEntityComponent,
  ],
})
export class UserDetailsComponent {
  private fb = inject(FormBuilder);
  private readonly userAdminService = inject(UserAdminService);
  private readonly alertService = inject(AlertService);
  private readonly http = inject(HttpClient);
  private readonly _dialogData: UserDetailsDialogData = inject(
    MAT_DIALOG_DATA,
    { optional: true },
  );
  private readonly dialogRef = inject(
    MatDialogRef<UserDetailsComponent, UserDetailsAction>,
    { optional: true },
  );
  private readonly authService = inject(KeycloakAuthService, {
    optional: true,
  });
  protected currentUser = inject(CurrentUserSubject, { optional: true });
  private readonly accountActionGuard = inject(UserAccountActionGuardService);
  private readonly confirmationDialog = inject(ConfirmationDialogService);
  private readonly entityRegistry = inject(EntityRegistry);

  userAccount = model<UserAccount | null>(this._dialogData?.userAccount);
  isInDialog = input<boolean>(!!this._dialogData || false);
  isProfileMode = input<boolean>(false);

  userAccountEntityTypes = computed(() =>
    this.entityRegistry
      .getEntityTypes()
      .filter(({ value }) => value.enableUserAccounts)
      .map(({ key }) => key),
  );

  /**
   * Signal tracking whether the form is disabled (view mode) or enabled (edit mode).
   * This is automatically updating the `form.disabled` also.
   */
  formDisabled = signal(true);
  private readonly formDisabledEffect = effect(() => {
    if (!this.form) return;

    // special form rules:
    if (this.isProfileMode() && !this.formDisabled()) {
      this.formDisabled.set(true);
    }

    if (this.formDisabled()) {
      this.form.disable();
    } else {
      this.form.enable();
    }
  });

  showPasswordChange = computed(() => this.isProfileMode());
  passwordChangeDisabled = computed(() => {
    if (!this.isProfileMode()) return false;

    if (!hasRemoteSession(environment.session_type)) {
      return true; // Disabled in demo mode
    }
    if (typeof navigator !== "undefined" && !navigator.onLine) {
      return true; // Disabled when offline
    }
    return false;
  });

  creatingNewAccount = computed(() => {
    return !this.userAccount()?.id && !this.isProfileMode();
  });

  /**
   * Whether the account was created but the user has not yet completed
   * the invitation email (verified email + set initial password).
   */
  invitationPending = computed(() => {
    return (
      !this.isProfileMode() &&
      !this.creatingNewAccount() &&
      this.userAccount()?.emailVerified === false
    );
  });

  availableRoles = resource<Role[], unknown>({
    loader: async () => {
      if (this.isProfileMode()) {
        return this.userAccount()?.roles ?? [];
      }

      try {
        return await lastValueFrom(this.userAdminService.getAllRoles());
      } catch (err) {
        // in profile view, this may be expected
        Logging.debug("Failed to load available roles:", err);
        return [];
      }
    },
    defaultValue: [],
  });

  form: FormGroup;

  constructor() {
    this.initForm();

    // Add roles validation only when not in profile mode
    effect(() => {
      const isProfileMode = this.isProfileMode();
      const rolesControl = this.form.get("roles");
      if (rolesControl) {
        if (isProfileMode) {
          rolesControl.clearValidators();
        } else {
          rolesControl.setValidators([Validators.required]);
        }
        rolesControl.updateValueAndValidity();
      }
    });

    // Once an account already has a linked profile, the field can no longer be cleared here
    // (the control used to be readonly for this exact reason) - only re-linked to a different
    // profile via `updateAccount()`. An account with no profile at all stays optional to edit,
    // since #3087 (system-init assistant) relies on that being a valid, silent state.
    effect(() => {
      const currentUserEntityId = this.userAccount()?.userEntityId;
      const profileControl = this.form.get("userEntityId");
      if (profileControl) {
        if (!this.creatingNewAccount() && currentUserEntityId) {
          profileControl.setValidators([Validators.required]);
        } else {
          profileControl.clearValidators();
        }
        profileControl.updateValueAndValidity();
      }
    });

    // Auto-trim whitespace from email
    this.form.valueChanges.pipe(untilDestroyed(this)).subscribe((next) => {
      if (next.email?.startsWith(" ") || next.email?.endsWith(" ")) {
        this.form
          .get("email")
          ?.setValue(next.email.trim(), { emitEvent: false });
      }
    });

    effect(() => {
      const user = this.userAccount();
      const roleOptions = this.availableRoles.value(); // make effect dependent on roles also (which are used in updateFormFromUser)
      if (user) {
        this.updateFormFromUser(user);
      }
    });
  }

  private initForm() {
    this.form = this.fb.group({
      email: ["", [Validators.required, Validators.email]],
      roles: new FormControl<Role[]>([]),
      userEntityId: new FormControl<string | null>(null),
    });

    // Initialize form as disabled
    if (!this.creatingNewAccount()) {
      this.formDisabled.set(true);
    } else {
      this.formDisabled.set(false);
    }

    this.form.valueChanges.pipe(untilDestroyed(this)).subscribe(() => {
      this.form.markAllAsTouched();
      this.form.markAsDirty();
    });
  }

  private updateFormFromUser(user: UserAccount) {
    this.form.patchValue(
      {
        email: user.email,
        roles: (user.roles ?? [])
          .map((role) =>
            this.availableRoles.value()?.find((r) => r.id === role.id),
          )
          .filter((role): role is Role => role !== undefined), // Filter out undefined roles
        userEntityId: !user.userEntityId
          ? null
          : user.userEntityId.includes(":")
            ? user.userEntityId
            : "User:" + user.userEntityId,
      },
      { emitEvent: false },
    );

    this.form.markAsPristine();
  }

  async save() {
    if (this.form.invalid) {
      this.form.markAllAsTouched();
      return;
    }

    const formValue = this.form.getRawValue();

    if (this.creatingNewAccount()) {
      await this.createAccount(formValue);
    } else {
      await this.updateAccount(formValue);
    }
  }

  private async createAccount(formData: Partial<UserAccount>) {
    const userEntityId = formData.userEntityId;
    if (!userEntityId) {
      this.alertService.addDanger(
        $localize`:Error message:No profile ID available for user creation`,
      );
      return;
    }

    if (!formData.email || !formData.roles) {
      return;
    }

    const existingAccount = await firstValueFrom(
      this.userAdminService.getUser(userEntityId),
    ).catch(() => null);
    if (existingAccount) {
      this.alertService.addDanger(
        $localize`:Error message:A user account already exists for this profile. Each profile can only be linked to one account.`,
      );
      return;
    }

    this.userAdminService
      .createUser(userEntityId, formData.email, formData.roles)
      .subscribe({
        next: (createdUser) => {
          this.alertService.addInfo(
            $localize`:Snackbar message:Account created. An email has been sent to ${formData.email}`,
          );
          this.formDisabled.set(true);
          this.closeDialog({
            type: "accountCreated",
            data: {
              ...formData,
              userEntityId: userEntityId,
              enabled: true,
              id: createdUser.id,
            } as UserAccount,
          });
        },
        error: (err) => {
          this.alertService.addDanger(
            err?.error?.message ||
              err?.message ||
              $localize`:Error message:Failed to create account`,
          );
        },
      });
  }

  private async updateAccount(formData: Partial<UserAccount>) {
    const currentUser = this.userAccount();
    if (!currentUser) {
      return;
    }

    const update: Partial<UserAccount> = {};
    if (formData.email !== currentUser.email) {
      update.email = formData.email;
    }
    if (JSON.stringify(formData.roles) !== JSON.stringify(currentUser.roles)) {
      update.roles = formData.roles;
    }

    const profileChanged =
      !!formData.userEntityId &&
      !entityIdsMatch(formData.userEntityId, currentUser.userEntityId);
    if (profileChanged) {
      update.userEntityId = formData.userEntityId;
    }

    if (Object.keys(update).length === 0) {
      this.closeDialog({ type: "formCancel" });
      return;
    }

    if (
      profileChanged &&
      !(await this.confirmProfileChange(currentUser, formData.userEntityId))
    ) {
      return;
    }

    // capture before the update overwrites `userAccount()` with the new profile
    const isOwnAccountRelink =
      profileChanged &&
      this.accountActionGuard.isOwnAccount({
        userAccountId: currentUser.id,
        userEntityId: currentUser.userEntityId,
      });

    const result = await this.updateUserAccount(
      update,
      $localize`:Snackbar message:Successfully updated user`,
    );

    if (result && isOwnAccountRelink) {
      this.alertService.addWarning(
        $localize`:own account relink notice:Your account is now linked to a different profile. You need to log out and log back in for this to take effect in your current session.`,
      );
    }

    this.formDisabled.set(true);
    this.closeDialog({
      type: "accountUpdated",
      data: {
        user: result,
      },
    });
  }

  /**
   * Checks the one-account-per-profile rule and asks the admin to confirm re-linking the
   * account, since the previous profile record stays in the database but stops being anyone's
   * login profile.
   */
  private async confirmProfileChange(
    currentUser: UserAccount,
    newEntityId: string,
  ): Promise<boolean> {
    let conflictingAccount: UserAccount | null;
    try {
      // getUser resolves to null when no account is linked to that profile,
      // and only throws when the lookup itself failed
      conflictingAccount = await firstValueFrom(
        this.userAdminService.getUser(newEntityId),
      );
    } catch (error) {
      // a failed lookup is not evidence that the profile is free - refuse the change rather
      // than risk linking a second account to a profile that already has one
      Logging.error("Failed to check for an existing user account", error);
      this.alertService.addDanger(
        $localize`:Error message:Could not check whether this profile already has a user account. Please try again.`,
      );
      return false;
    }

    // getUser looks up by the new profile's exact_username, so on an unchanged save it resolves
    // back to this very account - only a *different* account's id is an actual conflict.
    if (conflictingAccount && conflictingAccount.id !== currentUser.id) {
      this.alertService.addDanger(
        $localize`:Error message:A user account already exists for this profile. Each profile can only be linked to one account.`,
      );
      return false;
    }

    const confirmed = await this.confirmationDialog.getConfirmation(
      $localize`:confirm changing linked profile title:Change linked profile?`,
      $localize`:confirm changing linked profile message:This account will be linked to the newly selected profile record instead. The previous profile record stays in the database, but will no longer be linked to a login account. Permission rules based on this user's identity, as well as records created, updated or assigned to this user going forward, will use the new profile record instead.`,
    );
    return confirmed === true;
  }

  private async updateUserAccount(
    update: Partial<UserAccount>,
    message: string,
  ): Promise<UserAccount | null> {
    const currentUser = this.userAccount();
    if (!currentUser) {
      return null;
    }

    return lastValueFrom(
      this.userAdminService.updateUser(currentUser.id, update).pipe(
        map((result) => {
          if (!result.userUpdated) {
            Logging.warn("User account not updated");
            return null;
          }

          this.alertService.addInfo(message);
          const updatedUser = { ...currentUser, ...update };
          this.userAccount.set(updatedUser);

          if (update.roles?.length > 0 || update.userEntityId) {
            // roles and the linked profile both affect which documents the permission backend
            // replicates to this user (rules keyed on `${user.roles}` / `${user.entityId}`)
            this.triggerSyncReset();
          }

          return updatedUser;
        }),
        catchError((error) => {
          this.alertService.addDanger(
            error?.error?.message ||
              error?.message ||
              $localize`:Error message:Failed to update user account`,
          );
          Logging.error("Failed to update user account", error);
          return of(null);
        }),
      ),
    );
  }

  async deleteAccount() {
    const currentAccount = this.userAccount();
    if (!currentAccount?.userEntityId) {
      return;
    }

    if (
      this.accountActionGuard.isOwnAccount({
        userAccountId: currentAccount.id,
        userEntityId: currentAccount.userEntityId,
      })
    ) {
      await this.accountActionGuard.showSelfAccountActionBlockedWarning(
        "delete",
      );
      return;
    }

    const confirmed = await this.confirmationDialog.getConfirmation(
      $localize`:delete account confirmation title:Delete user account?`,
      $localize`:delete account confirmation dialog:Are you sure you want to permanently delete this user account? This cannot be undone.`,
    );
    if (!confirmed) return;

    try {
      await firstValueFrom(
        this.userAdminService.deleteUser(currentAccount.userEntityId),
      );
      this.alertService.addInfo(
        $localize`:delete account success message:User account has been deleted.`,
      );
      this.userAccount.set(null);
    } catch (err) {
      this.alertService.addDanger(
        err?.["error"]?.message ||
          err?.["message"] ||
          $localize`:Error message:Failed to delete user account`,
      );
    }
  }

  async enableAccount(enabled: boolean) {
    if (
      !enabled &&
      this.accountActionGuard.isOwnAccount({
        userAccountId: this.userAccount()?.id,
        userEntityId: this.userAccount()?.userEntityId,
      })
    ) {
      await this.accountActionGuard.showSelfAccountActionBlockedWarning(
        "deactivate",
      );
      return;
    }

    const message = enabled
      ? $localize`:Snackbar message:Account has been activated, user can login again.`
      : $localize`:Snackbar message:Account has been disabled, user will not be able to login anymore.`;

    await this.updateUserAccount({ enabled }, message);
  }

  /**
   * Whether a resend invitation request is currently in progress
   * (disables the button to prevent duplicate emails).
   */
  resendingInvitation = signal(false);

  async resendInvitation() {
    const currentUser = this.userAccount();
    if (!currentUser?.id || this.resendingInvitation()) {
      return;
    }

    this.resendingInvitation.set(true);
    try {
      await firstValueFrom(
        this.userAdminService.resendInvitation(currentUser.id),
      );
      this.alertService.addInfo(
        $localize`:Snackbar message:Invitation email has been sent to ${currentUser.email}`,
      );
    } catch (err) {
      this.alertService.addDanger(
        err?.["error"]?.message ||
          err?.["message"] ||
          $localize`:Error message:Failed to send invitation email`,
      );
    } finally {
      this.resendingInvitation.set(false);
    }
  }

  editMode() {
    this.formDisabled.set(false);
  }

  cancel() {
    this.form.reset();
    const user = this.userAccount();
    if (user) {
      this.updateFormFromUser(user);
    }

    this.formDisabled.set(true);
    this.closeDialog({ type: "formCancel" });
  }

  private closeDialog(result: UserDetailsAction) {
    if (this.dialogRef) {
      this.dialogRef.close(result);
    }
  }

  getFormError(field: string, errorType: string): boolean {
    return this.form.get(field)?.hasError(errorType) ?? false;
  }

  getGlobalError(): string | null {
    return this.form.getError("failed");
  }

  changePassword() {
    if (this.authService) {
      this.authService.changePassword();
    }
  }

  /**
   * Reset server DB sync state to ensure previously hidden docs are re-synced
   * after an account has gained more access permissions.
   *
   * see https://github.com/Aam-Digital/replication-backend/blob/master/src/admin/admin.controller.ts
   * @private
   */
  private triggerSyncReset() {
    this.http
      .post(
        `${environment.DB_PROXY_PREFIX}/admin/clear_local/${Entity.DATABASE}`,
        undefined,
      )
      .subscribe({
        next: () => undefined,
        // request fails if no permission backend is used - this is fine
        error: () => undefined,
      });
  }
}
@if (isInDialog()) {
  <div>
    @if (creatingNewAccount()) {
      <h2 mat-dialog-title i18n>Create User Account</h2>
    } @else {
      <h2 mat-dialog-title i18n>Edit User Account</h2>
    }
    <app-dialog-close></app-dialog-close>
  </div>
}

<div class="flex-column padding-right-regular padding-left-regular">
  @if (!isProfileMode()) {
    <!-- Creating a new account -->
    @if (creatingNewAccount()) {
      <div class="align-self-start padding-bottom-small">
        @if (userAccount()?.userEntityId) {
          <p i18n class="field-hint field-warning">
            There is no user account set up for this user yet. Enable this user
            to log into the app by filling the details below.
          </p>
        }
        @if (!formDisabled()) {
          <button
            mat-raised-button
            type="submit"
            (click)="save()"
            class="invite-button"
            color="accent"
            [class.invite-button-animate]="true"
            i18n
          >
            Create account & send invitation
          </button>
        }
      </div>
    } @else {
      <!-- Actions editing existing account -->
      <div class="user-account-actions padding-bottom-small padding-top-small">
        @if (formDisabled()) {
          <button
            mat-raised-button
            class="action-button"
            (click)="editMode()"
            i18n="Edit button for forms"
          >
            Edit
          </button>
          @if (invitationPending()) {
            <button
              mat-stroked-button
              class="action-button"
              (click)="resendInvitation()"
              [disabled]="resendingInvitation()"
              i18n="button to resend the account invitation email"
            >
              Resend invitation
            </button>
          }
        } @else {
          @if (userAccount()?.enabled) {
            <button
              mat-stroked-button
              class="action-button"
              (click)="enableAccount(false)"
              i18n="button to disable a user account"
            >
              Deactivate account
            </button>
          } @else {
            <button
              mat-stroked-button
              class="action-button"
              (click)="enableAccount(true)"
              i18n="button to enable a user account"
            >
              Activate account
            </button>
          }

          <button
            mat-raised-button
            color="accent"
            class="action-button"
            [disabled]="!form.valid || !form.dirty"
            (click)="save()"
            i18n="Save button for forms"
          >
            Save
          </button>
          <button
            mat-stroked-button
            class="action-button"
            (click)="cancel()"
            i18n="Cancel button for forms"
          >
            Cancel
          </button>
          <!-- Three-dots overflow menu for secondary/destructive actions -->
          <button
            mat-icon-button
            color="warn"
            class="more-actions-button"
            [matMenuTriggerFor]="userAccountActionsMenu"
            matTooltip="More actions"
            i18n-matTooltip
          >
            <fa-icon icon="ellipsis-v" class="standard-icon"></fa-icon>
          </button>
          <mat-menu #userAccountActionsMenu>
            <button
              mat-menu-item
              (click)="deleteAccount()"
              i18n="button to delete user account"
            >
              Delete account
            </button>
          </mat-menu>
        }
      </div>
    }
  }

  @if (userAccount() && !userAccount()?.enabled) {
    <div class="flex-row align-center gap-small margin-bottom-regular">
      <fa-icon class="color-error" icon="triangle-exclamation"></fa-icon>
      <span i18n="Hint in user account page" class="color-error">
        User is currently disabled and will not be able to login to the app
      </span>
    </div>
  }

  @if (invitationPending()) {
    <div class="flex-row align-center gap-small margin-bottom-regular">
      <fa-icon class="color-error" icon="triangle-exclamation"></fa-icon>
      <span i18n="Hint in user account page" class="color-error">
        User has not yet accepted the invitation email and cannot log in yet.
      </span>
    </div>
  }

  <div class="user-details-form">
    <form [formGroup]="form">
      <mat-form-field class="full-width">
        <mat-label i18n="label of email input">Email</mat-label>
        <input matInput type="text" formControlName="email" />
        @if (getFormError("email", "email")) {
          <mat-error i18n> Please enter a valid email</mat-error>
        }
        @if (getFormError("email", "required")) {
          <mat-error i18n> This field is required</mat-error>
        }
        @if (isInDialog() && userAccount()?.userEntityId) {
          <mat-hint i18n="hint showing username/entity ID in dialog">
            Username: {{ userAccount()?.userEntityId }}
          </mat-hint>
        }
        @if (isProfileMode()) {
          <mat-hint i18n>
            Changing your email is temporarily only possible for administrators.
            Please contact your project coordinator or the support team.
          </mat-hint>
        }
      </mat-form-field>

      <mat-form-field class="full-width">
        <mat-label i18n="label of roles input">Roles</mat-label>
        <mat-select formControlName="roles" multiple>
          <mat-select-trigger>
            @for (role of form.get("roles")?.value; track role.id) {
              <span>{{ role.name }} </span>
            }
          </mat-select-trigger>
          @for (role of availableRoles.value(); track role.id) {
            <mat-option [value]="role" [matTooltip]="role.description">
              {{ role.description }}
              <em class="role-name">{{ role.name }}</em>
            </mat-option>
          }
        </mat-select>
        @if (getFormError("roles", "required")) {
          <mat-error i18n> This field is required</mat-error>
        }
        @if (!isInDialog()) {
          <mat-hint i18n>
            You should select at least one user role. Otherwise this user will
            not even be able to access the basic app layout like the menu.
          </mat-hint>
        }
      </mat-form-field>

      @if (showPasswordChange()) {
        <div class="field-row">
          <button
            mat-stroked-button
            type="button"
            color="primary"
            (click)="changePassword()"
            angulartics2On="click"
            angularticsCategory="User"
            angularticsAction="password_update"
            [disabled]="passwordChangeDisabled()"
            i18n="button to change password"
          >
            Change Password
          </button>
        </div>
      }

      <mat-form-field class="full-width" floatLabel="always">
        <mat-label i18n>Profile</mat-label>
        <app-edit-entity
          formControlName="userEntityId"
          [entityType]="userAccountEntityTypes()"
        ></app-edit-entity>
        @if (getFormError("userEntityId", "required")) {
          <mat-error i18n>This field is required</mat-error>
        }
        @if (!creatingNewAccount() && !formDisabled()) {
          <mat-hint
            i18n="
              hint explaining the consequence of changing the linked profile
            "
          >
            Changing this links the account to a different profile record. The
            previous record stays in the database but will no longer have a
            login account.
          </mat-hint>
        }
      </mat-form-field>

      @if (getGlobalError()) {
        <mat-error class="global-error">{{ getGlobalError() }}</mat-error>
      }
    </form>
  </div>
</div>

./user-details.component.scss

@use "variables/colors";

.user-details-form {
  display: flex;
  flex-direction: column;
  gap: 1rem;
}

.user-account-actions {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 0.5rem;
  flex-wrap: wrap;
}

.more-actions-button {
  align-self: center;
}

.field-row {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  padding: 0.5rem 0;
}

.field-label {
  font-weight: 500;
  color: colors.$muted;
}

.field-value {
  color: colors.$primary;
}

.role-name {
  font-size: 0.75rem;
  margin-left: 0.5rem;
}

.global-error {
  margin-top: 1rem;
  padding: 0.5rem;
  background-color: rgba(244, 67, 54, 0.1);
  border-radius: 4px;
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""