From 68e6ac1769a1693311637af55684a18d80a995b7 Mon Sep 17 00:00:00 2001
From: sebo <sebastian.bergandy@external.mgm-cp.com>
Date: Mon, 3 Mar 2025 22:05:36 +0100
Subject: [PATCH 01/22] OZG-7591 define keycloak errors and form service

---
 .../src/lib/keycloak-error-handler.ts         | 21 +++++++++++++++++++
 .../src/lib/keycloak-formservice.ts           | 17 ++++++++++++---
 .../src/lib/user.repository.ts                |  7 +++----
 3 files changed, 38 insertions(+), 7 deletions(-)
 create mode 100644 alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error-handler.ts

diff --git a/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error-handler.ts b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error-handler.ts
new file mode 100644
index 0000000000..fd424cb003
--- /dev/null
+++ b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error-handler.ts
@@ -0,0 +1,21 @@
+import { ProblemDetail } from '@alfa-client/tech-shared';
+
+export type KeycloakErrorMapping = { [key: string]: { [key: string]: any } };
+
+export type KeycloakError = ErrorMessage | ErrorRepresentation;
+
+export type ErrorMessage = 'User name is missing' | 'error-invalid-length' | 'error-invalid-email';
+
+export interface ErrorRepresentation {
+  field: string;
+  errorMessage: ErrorMessage;
+  params: [string, ...any];
+}
+
+export function convertKeycloakErrorToProblemDetail(keycloakError: KeycloakError): ProblemDetail {
+  return null;
+}
+
+export function isKeycloakError(err: any): boolean {
+  return err?.responseData?.errorMessage;
+}
diff --git a/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-formservice.ts b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-formservice.ts
index 49a2e6c84e..0d285b15c6 100644
--- a/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-formservice.ts
+++ b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-formservice.ts
@@ -21,13 +21,14 @@
  * Die sprachspezifischen Genehmigungen und Beschränkungen
  * unter der Lizenz sind dem Lizenztext zu entnehmen.
  */
-import { createEmptyStateResource, isLoaded, StateResource } from '@alfa-client/tech-shared';
+import { createEmptyStateResource, createErrorStateResource, isLoaded, StateResource } from '@alfa-client/tech-shared';
 import { inject, Injectable } from '@angular/core';
 import { FormBuilder, FormGroup } from '@angular/forms';
 import { ActivatedRoute, UrlSegment } from '@angular/router';
-import { first, Observable, of, tap } from 'rxjs';
+import { catchError, first, Observable, of, tap, throwError } from 'rxjs';
 
 import * as FormUtil from './form.util';
+import { isKeycloakError, KeycloakError } from './keycloak-error-handler';
 
 @Injectable()
 export abstract class KeycloakFormService<T> {
@@ -70,9 +71,19 @@ export abstract class KeycloakFormService<T> {
   }
 
   public submit(): Observable<StateResource<T>> {
-    return this._doSubmit();
+    return this._doSubmit().pipe(
+      catchError((err: any) => {
+        if (isKeycloakError(err)) {
+          this._handleKeycloakError(err);
+          return createErrorStateResource(err);
+        }
+        return throwError(() => 'Unknown Keycloak error. Can not handle it.');
+      }),
+    );
   }
 
+  _handleKeycloakError(err: KeycloakError): void {}
+
   abstract _doSubmit(): Observable<StateResource<T>>;
 
   _patch(valueToPatch: T): void {
diff --git a/alfa-client/libs/admin/keycloak-shared/src/lib/user.repository.ts b/alfa-client/libs/admin/keycloak-shared/src/lib/user.repository.ts
index e5eca5be54..4072c1ab20 100644
--- a/alfa-client/libs/admin/keycloak-shared/src/lib/user.repository.ts
+++ b/alfa-client/libs/admin/keycloak-shared/src/lib/user.repository.ts
@@ -22,7 +22,7 @@
  * unter der Lizenz sind dem Lizenztext zu entnehmen.
  */
 import { ClientMapping, ClientRoles, RoleMappings, User } from '@admin-client/user-shared';
-import { createStateResource, StateResource } from '@alfa-client/tech-shared';
+import { createStateResource, ProblemDetail, StateResource } from '@alfa-client/tech-shared';
 import { inject, Injectable } from '@angular/core';
 import KcAdminClient from '@keycloak/keycloak-admin-client';
 import ClientRepresentation from '@keycloak/keycloak-admin-client/lib/defs/clientRepresentation';
@@ -30,11 +30,10 @@ import GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupR
 import MappingsRepresentation from '@keycloak/keycloak-admin-client/lib/defs/mappingsRepresentation';
 import { RoleMappingPayload } from '@keycloak/keycloak-admin-client/lib/defs/roleRepresentation';
 import UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation';
+import * as _ from 'lodash-es';
 import { isNil, omit } from 'lodash-es';
 import { catchError, concatMap, forkJoin, from, map, mergeMap, Observable, tap, throwError } from 'rxjs';
 
-import * as _ from 'lodash-es';
-
 @Injectable({
   providedIn: 'root',
 })
@@ -44,7 +43,7 @@ export class UserRepository {
   public static readonly ALFA_CLIENT_NAME: string = 'alfa';
   public static readonly ADMIN_CLIENT_NAME: string = 'admin';
 
-  public createInKeycloak(user: User): Observable<User> {
+  public createInKeycloak(user: User): Observable<User | ProblemDetail> {
     return from(this.kcAdminClient.users.create(omit(user, 'groupIds'))).pipe(
       concatMap(async (response: { id: string }): Promise<{ id: string }> => {
         await this._updateUserRoles(response.id, user.clientRoles);
-- 
GitLab


From a6cee0963a04fe6fa511714ceee365ddc29a43b8 Mon Sep 17 00:00:00 2001
From: sebo <sebastian.bergandy@external.mgm-cp.com>
Date: Sat, 8 Mar 2025 16:30:27 +0100
Subject: [PATCH 02/22] OZG-7591 handle keycloak api errors

Handle keycloak api errors on user creation.

Sub task: OZG-7742
---
 .../libs/admin/keycloak-shared/src/index.ts   |   1 +
 .../src/lib/keycloak-error-handler.ts         |  21 -
 .../src/lib/keycloak-error.model.spec.ts      | 251 ++++++++++++
 .../src/lib/keycloak-error.model.ts           |  66 ++++
 .../src/lib/keycloak-formservice.spec.ts      | 365 +++++++++++++++++-
 .../src/lib/keycloak-formservice.ts           | 110 +++++-
 .../src/lib/user.repository.spec.ts           |  12 +-
 .../src/lib/user.repository.ts                |   5 +-
 .../keycloak-shared/src/test/keycloak.ts      |  23 ++
 .../lib/user-form/user.formservice.spec.ts    | 237 ++++++------
 .../src/lib/user-form/user.formservice.ts     |  54 +--
 .../validation/tech.validation.messages.ts    |  11 +-
 12 files changed, 964 insertions(+), 192 deletions(-)
 delete mode 100644 alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error-handler.ts
 create mode 100644 alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error.model.spec.ts
 create mode 100644 alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error.model.ts
 create mode 100644 alfa-client/libs/admin/keycloak-shared/src/test/keycloak.ts

diff --git a/alfa-client/libs/admin/keycloak-shared/src/index.ts b/alfa-client/libs/admin/keycloak-shared/src/index.ts
index 4197ca2f1a..06df4b1a3e 100644
--- a/alfa-client/libs/admin/keycloak-shared/src/index.ts
+++ b/alfa-client/libs/admin/keycloak-shared/src/index.ts
@@ -1,3 +1,4 @@
+export * from './lib/keycloak-error.model';
 export * from './lib/keycloak-formservice';
 export * from './lib/keycloak-token.service';
 export * from './lib/keycloak.resource.service';
diff --git a/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error-handler.ts b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error-handler.ts
deleted file mode 100644
index fd424cb003..0000000000
--- a/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error-handler.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { ProblemDetail } from '@alfa-client/tech-shared';
-
-export type KeycloakErrorMapping = { [key: string]: { [key: string]: any } };
-
-export type KeycloakError = ErrorMessage | ErrorRepresentation;
-
-export type ErrorMessage = 'User name is missing' | 'error-invalid-length' | 'error-invalid-email';
-
-export interface ErrorRepresentation {
-  field: string;
-  errorMessage: ErrorMessage;
-  params: [string, ...any];
-}
-
-export function convertKeycloakErrorToProblemDetail(keycloakError: KeycloakError): ProblemDetail {
-  return null;
-}
-
-export function isKeycloakError(err: any): boolean {
-  return err?.responseData?.errorMessage;
-}
diff --git a/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error.model.spec.ts b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error.model.spec.ts
new file mode 100644
index 0000000000..9c0d629e95
--- /dev/null
+++ b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error.model.spec.ts
@@ -0,0 +1,251 @@
+import { describe, expect, it } from '@jest/globals';
+import {
+  canHandleKeycloakError,
+  ErrorRepresentation,
+  extractErrorRepresentations,
+  isFieldErrorMessage,
+  isSingleErrorMessage,
+  KeycloakErrorMessage,
+  KeycloakErrorResponseData,
+  KeycloakFieldName,
+  KeycloakHttpErrorResponse,
+} from './keycloak-error.model';
+
+describe('keycloak error model', () => {
+  describe('extract error representations', () => {
+    it('should throw error', () => {
+      expect(() => extractErrorRepresentations({} as any)).toThrowError();
+    });
+
+    it('should return errors', () => {
+      const keycloakError: KeycloakHttpErrorResponse = {
+        response: {
+          status: 400,
+        },
+        responseData: {
+          errors: [
+            {
+              field: KeycloakFieldName.USERNAME,
+              errorMessage: KeycloakErrorMessage.INVALID_LENGTH,
+              params: ['username', 3, 255],
+            },
+            {
+              field: KeycloakFieldName.EMAIL,
+              errorMessage: KeycloakErrorMessage.INVALID_EMAIL,
+              params: ['email', 'f'],
+            },
+          ],
+        },
+      };
+
+      expect(extractErrorRepresentations(keycloakError)).toStrictEqual([
+        {
+          errorMessage: KeycloakErrorMessage.INVALID_LENGTH,
+          params: ['username', 3, 255],
+          field: KeycloakFieldName.USERNAME,
+        } as ErrorRepresentation,
+
+        {
+          errorMessage: KeycloakErrorMessage.INVALID_EMAIL,
+          params: ['email', 'f'],
+          field: KeycloakFieldName.EMAIL,
+        } as ErrorRepresentation,
+      ]);
+    });
+
+    it('should error response data', () => {
+      const keycloakError: KeycloakHttpErrorResponse = {
+        response: {
+          status: 400,
+        },
+        responseData: {
+          field: KeycloakFieldName.USERNAME,
+          errorMessage: KeycloakErrorMessage.INVALID_LENGTH,
+          params: ['username', 3, 255],
+        },
+      };
+
+      expect(extractErrorRepresentations(keycloakError)).toStrictEqual([
+        {
+          errorMessage: KeycloakErrorMessage.INVALID_LENGTH,
+          params: ['username', 3, 255],
+          field: KeycloakFieldName.USERNAME,
+        } as ErrorRepresentation,
+      ]);
+    });
+  });
+
+  describe('can handle keycloak error', () => {
+    const errorWithMessage: any = {
+      response: {
+        status: 400,
+      },
+      responseData: {
+        errorMessage: 'User name is missing',
+      },
+    };
+
+    const errorWithObject: any = {
+      response: {
+        status: 400,
+      },
+      responseData: {
+        field: 'username',
+        errorMessage: 'error-invalid-length',
+        params: ['username', 3, 255],
+      },
+    };
+
+    const errorWithArray: any = {
+      response: {
+        status: 400,
+      },
+      responseData: {
+        errors: [
+          {
+            field: 'username',
+            errorMessage: 'error-invalid-length',
+            params: ['username', 3, 255],
+          },
+          {
+            field: 'email',
+            errorMessage: 'error-invalid-email',
+            params: ['email', 'f'],
+          },
+        ],
+      },
+    };
+
+    const errorWithoutErrorMessage: any = {
+      response: {},
+      responseData: {},
+    };
+
+    const errorWithoutResponseData: any = {
+      response: {},
+      responseData: {},
+    };
+
+    const error500: any = {
+      response: {
+        status: 500,
+      },
+      responseData: {
+        errorMessage: 'internal error',
+      },
+    };
+
+    it('should return true for text error message', () => {
+      expect(canHandleKeycloakError(errorWithMessage)).toBe(true);
+    });
+
+    it('should return true for error object', () => {
+      expect(canHandleKeycloakError(errorWithObject)).toBe(true);
+    });
+
+    it('should return true for error array', () => {
+      expect(canHandleKeycloakError(errorWithArray)).toBe(true);
+    });
+
+    it('should return false for error without error message', () => {
+      expect(canHandleKeycloakError(errorWithoutErrorMessage)).toBe(false);
+    });
+
+    it('should return false for error without response data', () => {
+      expect(canHandleKeycloakError(errorWithoutResponseData)).toBe(false);
+    });
+
+    it('should return false for 500', () => {
+      expect(canHandleKeycloakError(error500)).toBe(false);
+    });
+
+    describe('evaluate validation errors', () => {});
+  });
+
+  describe('isSingleErrorMessage', () => {
+    it('should return false if contains field', () => {
+      const error: KeycloakErrorResponseData = {
+        errorMessage: KeycloakErrorMessage.USERNAME_IS_MISSING,
+        field: KeycloakFieldName.USERNAME,
+      };
+
+      expect(isSingleErrorMessage(error)).toBe(false);
+    });
+
+    it('should return false if contains params', () => {
+      const error: KeycloakErrorResponseData = {
+        errorMessage: KeycloakErrorMessage.USERNAME_IS_MISSING,
+        params: [],
+      };
+
+      expect(isSingleErrorMessage(error)).toBe(false);
+    });
+
+    it('should return false if contains errors', () => {
+      const error: KeycloakErrorResponseData = {
+        errorMessage: KeycloakErrorMessage.USERNAME_IS_MISSING,
+        errors: [],
+      };
+
+      expect(isSingleErrorMessage(error)).toBe(false);
+    });
+
+    it('should return true', () => {
+      const error: KeycloakErrorResponseData = {
+        errorMessage: KeycloakErrorMessage.USERNAME_IS_MISSING,
+      };
+
+      expect(isSingleErrorMessage(error)).toBe(true);
+    });
+  });
+
+  describe('isFieldErrorMessage', () => {
+    it('should return false if error message missing', () => {
+      const error: KeycloakErrorResponseData = {
+        params: [],
+        field: KeycloakFieldName.USERNAME,
+      };
+
+      expect(isFieldErrorMessage(error)).toBe(false);
+    });
+
+    it('should return false if field missing', () => {
+      const error: KeycloakErrorResponseData = {
+        errorMessage: KeycloakErrorMessage.USERNAME_IS_MISSING,
+        params: [],
+      };
+
+      expect(isFieldErrorMessage(error)).toBe(false);
+    });
+
+    it('should return false if params missing', () => {
+      const error: KeycloakErrorResponseData = {
+        errorMessage: KeycloakErrorMessage.USERNAME_IS_MISSING,
+        field: KeycloakFieldName.USERNAME,
+      };
+
+      expect(isFieldErrorMessage(error)).toBe(false);
+    });
+
+    it('should return false if contains errors', () => {
+      const error: KeycloakErrorResponseData = {
+        errorMessage: KeycloakErrorMessage.USERNAME_IS_MISSING,
+        field: KeycloakFieldName.USERNAME,
+        params: [],
+        errors: [],
+      };
+
+      expect(isFieldErrorMessage(error)).toBe(false);
+    });
+
+    it('should return true', () => {
+      const error: KeycloakErrorResponseData = {
+        errorMessage: KeycloakErrorMessage.USERNAME_IS_MISSING,
+        field: KeycloakFieldName.USERNAME,
+        params: [],
+      };
+
+      expect(isFieldErrorMessage(error)).toBe(true);
+    });
+  });
+});
diff --git a/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error.model.ts b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error.model.ts
new file mode 100644
index 0000000000..356ed67c26
--- /dev/null
+++ b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error.model.ts
@@ -0,0 +1,66 @@
+import { isNotNil } from '@alfa-client/tech-shared';
+import { isNil } from 'lodash-es';
+
+const validationErrorStatusCodes: number[] = [400, 409];
+
+export enum KeycloakFieldName {
+  USERNAME = 'username',
+  EMAIL = 'email',
+}
+
+export enum KeycloakErrorMessage {
+  USERNAME_IS_MISSING = 'User name is missing',
+  EMAIL_EXISTS = 'User exists with same email',
+  USERNAME_EXISTS = 'User exists with same username',
+  INVALID_LENGTH = 'error-invalid-length',
+  INVALID_EMAIL = 'error-invalid-email',
+}
+
+export interface ErrorRepresentation {
+  errorMessage?: KeycloakErrorMessage;
+  field?: KeycloakFieldName;
+  params?: any[];
+}
+
+export interface KeycloakHttpErrorResponse {
+  response: {
+    status: number;
+  };
+  responseData: KeycloakErrorResponseData;
+}
+
+export interface KeycloakErrorResponseData extends ErrorRepresentation {
+  errors?: ErrorRepresentation[];
+}
+
+export function extractErrorRepresentations(error: KeycloakHttpErrorResponse): ErrorRepresentation[] {
+  if (!canHandleKeycloakError(error)) {
+    throw new Error(`Cannot handle keycloak error: ${error}`);
+  }
+  const errorResponseData: KeycloakErrorResponseData = error.responseData;
+  return error.responseData.errors ?? [errorResponseData];
+}
+
+export function canHandleKeycloakError(err: KeycloakHttpErrorResponse): boolean {
+  if (!validationErrorStatusCodes.includes(err?.response?.status)) return false;
+  if (isNil(err?.responseData)) return false;
+
+  if (containsErrorMessage(err)) return true;
+  return containsErrorArray(err) && isNotNil(err.responseData.errors[0]?.errorMessage);
+}
+
+function containsErrorMessage(err: any): boolean {
+  return isNotNil(err?.responseData?.errorMessage);
+}
+
+function containsErrorArray(err: any): boolean {
+  return isNotNil(err?.responseData?.errors) && err.responseData.errors.length > 0;
+}
+
+export function isSingleErrorMessage(err: KeycloakErrorResponseData): boolean {
+  return isNotNil(err.errorMessage) && isNil(err.field) && isNil(err.params) && isNil(err.errors);
+}
+
+export function isFieldErrorMessage(err: KeycloakErrorResponseData): boolean {
+  return isNotNil(err.errorMessage) && isNotNil(err.field) && isNotNil(err.params) && isNil(err.errors);
+}
diff --git a/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-formservice.spec.ts b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-formservice.spec.ts
index 6fe0a332ba..87d290d1c0 100644
--- a/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-formservice.spec.ts
+++ b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-formservice.spec.ts
@@ -21,20 +21,42 @@
  * Die sprachspezifischen Genehmigungen und Beschränkungen
  * unter der Lizenz sind dem Lizenztext zu entnehmen.
  */
-import { createEmptyStateResource, createStateResource, EMPTY_STRING, StateResource } from '@alfa-client/tech-shared';
+import { createEmptyStateResource, createStateResource, EMPTY_STRING, InvalidParam, setInvalidParamValidationError, StateResource, } from '@alfa-client/tech-shared';
 import { Injectable } from '@angular/core';
 import { TestBed } from '@angular/core/testing';
 import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms';
 import { ActivatedRoute, UrlSegment } from '@angular/router';
 import { faker } from '@faker-js/faker/.';
 import { createDummy, Dummy } from 'libs/tech-shared/test/dummy';
-import { singleCold, singleHot } from 'libs/tech-shared/test/marbles';
+import { singleCold, singleColdCompleted, singleHot } from 'libs/tech-shared/test/marbles';
 import { createSpy, mock, Mock } from 'libs/test-utils/src/lib/mocking';
-import { Observable, of } from 'rxjs';
+import { Observable, of, throwError } from 'rxjs';
 import { createUrlSegment } from '../../../../navigation-shared/test/navigation-test-factory';
 import { KeycloakFormService, PatchConfig } from './keycloak-formservice';
 
+import { expect } from '@jest/globals';
+import { ValidationMessageCode } from '../../../../tech-shared/src/lib/validation/tech.validation.messages';
+import { createInvalidParam } from '../../../../tech-shared/test/error';
+import { createErrorRepresentation, createKeycloakHttpErrorResponse } from '../test/keycloak';
 import * as FormUtil from './form.util';
+import { ErrorRepresentation, extractErrorRepresentations, isFieldErrorMessage, isSingleErrorMessage, KeycloakErrorMessage, KeycloakFieldName, KeycloakHttpErrorResponse, } from './keycloak-error.model';
+
+jest.mock('./keycloak-error.model', () => ({
+  ...jest.requireActual('./keycloak-error.model'),
+  extractErrorRepresentations: jest.fn(),
+  isSingleErrorMessage: jest.fn(),
+  isFieldErrorMessage: jest.fn(),
+}));
+
+jest.mock('@alfa-client/tech-shared', () => ({
+  ...jest.requireActual('@alfa-client/tech-shared'),
+  setInvalidParamValidationError: jest.fn(),
+}));
+
+const setInvalidParamValidationErrorMock: jest.Mock = setInvalidParamValidationError as jest.Mock;
+const extractErrorRepresentationsMock: jest.Mock = extractErrorRepresentations as jest.Mock;
+const isSingleErrorMessageMock: jest.Mock = isSingleErrorMessage as jest.Mock;
+const isFieldErrorMessageMock: jest.Mock = isFieldErrorMessage as jest.Mock;
 
 describe('KeycloakFormService', () => {
   let service: KeycloakFormService<Dummy>;
@@ -53,6 +75,10 @@ describe('KeycloakFormService', () => {
 
     initFormSpy = createSpy(TestKeycloakFormService, '_initForm').mockReturnValue(formGroup);
     evaluateRouteSpy = createSpy(TestKeycloakFormService, '_evaluateRoute');
+    extractErrorRepresentationsMock.mockClear();
+    isSingleErrorMessageMock.mockClear();
+    isFieldErrorMessageMock.mockClear();
+    setInvalidParamValidationErrorMock.mockClear();
 
     TestBed.configureTestingModule({
       providers: [
@@ -186,6 +212,8 @@ describe('KeycloakFormService', () => {
 
     beforeEach(() => {
       service._doSubmit = jest.fn().mockReturnValue(singleHot(dummyStateResource));
+      service._setValidationErrorsOnControls = jest.fn();
+      service._mapKeycloakErrorRepresentationsToInvalidParams = jest.fn();
     });
 
     it('should call do submit', () => {
@@ -199,6 +227,325 @@ describe('KeycloakFormService', () => {
 
       expect(submitResponse).toBeObservable(singleCold(dummyStateResource));
     });
+
+    describe('on keycloak error', () => {
+      const keycloakHttpError: KeycloakHttpErrorResponse = createKeycloakHttpErrorResponse();
+
+      beforeEach(() => {
+        service._doSubmit = jest.fn().mockReturnValue(throwError(() => keycloakHttpError));
+        service._handleUnknownKeycloakError = jest.fn();
+      });
+
+      it('should set validation errors on controls', () => {
+        const invalidParams: InvalidParam[] = [createInvalidParam()];
+        service._mapKeycloakErrorRepresentationsToInvalidParams = jest.fn().mockReturnValue(invalidParams);
+
+        service.submit().subscribe();
+
+        expect(service._setValidationErrorsOnControls).toHaveBeenCalledWith(invalidParams);
+      });
+
+      it('should map keycloak error representations to invalid params', () => {
+        const errorRepresentation: ErrorRepresentation = createErrorRepresentation();
+        extractErrorRepresentationsMock.mockReturnValue([errorRepresentation]);
+
+        service.submit().subscribe();
+
+        expect(service._mapKeycloakErrorRepresentationsToInvalidParams).toHaveBeenCalledWith([errorRepresentation]);
+      });
+
+      it('should extract error representations', () => {
+        service.submit().subscribe();
+
+        expect(extractErrorRepresentationsMock).toHaveBeenCalledWith(keycloakHttpError);
+      });
+
+      it('should emit empty state resource', () => {
+        expect(service.submit()).toBeObservable(singleColdCompleted(createEmptyStateResource()));
+      });
+
+      it('should handle unknown keycloak error', () => {
+        const error: Error = new Error('Fehler');
+        extractErrorRepresentationsMock.mockImplementation(() => {
+          throw error;
+        });
+
+        service.submit().subscribe();
+
+        expect(service._handleUnknownKeycloakError).toHaveBeenCalledWith(keycloakHttpError);
+      });
+
+      it('should emit empty state resource exception', () => {
+        const error: Error = new Error('Fehler');
+        extractErrorRepresentationsMock.mockImplementation(() => {
+          throw error;
+        });
+
+        expect(service.submit()).toBeObservable(singleColdCompleted(createEmptyStateResource()));
+      });
+    });
+  });
+
+  describe('set validation errors on controls', () => {
+    it('should set invalid param validation error', () => {
+      const invalidParam: InvalidParam = createInvalidParam();
+
+      service._setValidationErrorsOnControls([invalidParam]);
+
+      expect(setInvalidParamValidationErrorMock).toHaveBeenCalledWith(service.form, invalidParam);
+    });
+  });
+
+  describe('map keycloak error representations to invalid params', () => {
+    it('should map', () => {
+      service._mapKeycloakErrorRepresentationToInvalidParam = jest.fn();
+      const errorRepresentation: ErrorRepresentation = createErrorRepresentation();
+
+      service._mapKeycloakErrorRepresentationsToInvalidParams([errorRepresentation]);
+
+      expect(service._mapKeycloakErrorRepresentationToInvalidParam).toHaveBeenCalledWith(errorRepresentation);
+    });
+  });
+
+  describe('map keycloak error representation to invalid param', () => {
+    const controlName: string = faker.word.noun();
+    const invalidParam: InvalidParam = createInvalidParam();
+
+    beforeEach(() => {
+      service._evaluateControlName = jest.fn().mockReturnValue(controlName);
+      service._mapInvalidEmail = jest.fn().mockReturnValue(invalidParam);
+      service._mapInvalidLength = jest.fn().mockReturnValue(invalidParam);
+      service._mapEmailExists = jest.fn().mockReturnValue(invalidParam);
+      service._mapUsernameExists = jest.fn().mockReturnValue(invalidParam);
+      service._mapUserNameIsMissing = jest.fn().mockReturnValue(invalidParam);
+    });
+
+    it('should map invalid email', () => {
+      service._mapKeycloakErrorRepresentationToInvalidParam({
+        ...createErrorRepresentation(),
+        errorMessage: KeycloakErrorMessage.INVALID_EMAIL,
+      });
+
+      expect(service._mapInvalidEmail).toHaveBeenCalledWith(controlName);
+    });
+
+    it('should return invalid param for invalid email', () => {
+      const param: InvalidParam = service._mapKeycloakErrorRepresentationToInvalidParam({
+        ...createErrorRepresentation(),
+        errorMessage: KeycloakErrorMessage.INVALID_EMAIL,
+      });
+
+      expect(param).toEqual(invalidParam);
+    });
+
+    it('should map invalid length', () => {
+      const errorRepresentation: ErrorRepresentation = {
+        ...createErrorRepresentation(),
+        errorMessage: KeycloakErrorMessage.INVALID_LENGTH,
+      };
+
+      service._mapKeycloakErrorRepresentationToInvalidParam(errorRepresentation);
+
+      expect(service._mapInvalidLength).toHaveBeenCalledWith(controlName, errorRepresentation);
+    });
+
+    it('should return invalid param for invalid length', () => {
+      const param: InvalidParam = service._mapKeycloakErrorRepresentationToInvalidParam({
+        ...createErrorRepresentation(),
+        errorMessage: KeycloakErrorMessage.INVALID_LENGTH,
+      });
+
+      expect(param).toEqual(invalidParam);
+    });
+
+    it('should map email exists', () => {
+      service._mapKeycloakErrorRepresentationToInvalidParam({
+        ...createErrorRepresentation(),
+        errorMessage: KeycloakErrorMessage.EMAIL_EXISTS,
+      });
+
+      expect(service._mapEmailExists).toHaveBeenCalledWith(controlName);
+    });
+
+    it('should return invalid param for email exists', () => {
+      const param: InvalidParam = service._mapKeycloakErrorRepresentationToInvalidParam({
+        ...createErrorRepresentation(),
+        errorMessage: KeycloakErrorMessage.EMAIL_EXISTS,
+      });
+
+      expect(param).toEqual(invalidParam);
+    });
+
+    it('should map username exists', () => {
+      service._mapKeycloakErrorRepresentationToInvalidParam({
+        ...createErrorRepresentation(),
+        errorMessage: KeycloakErrorMessage.USERNAME_EXISTS,
+      });
+
+      expect(service._mapUsernameExists).toHaveBeenCalledWith(controlName);
+    });
+
+    it('should return invalid param for username exists', () => {
+      const param: InvalidParam = service._mapKeycloakErrorRepresentationToInvalidParam({
+        ...createErrorRepresentation(),
+        errorMessage: KeycloakErrorMessage.USERNAME_EXISTS,
+      });
+
+      expect(param).toEqual(invalidParam);
+    });
+
+    it('should map username is missing', () => {
+      service._mapKeycloakErrorRepresentationToInvalidParam({
+        ...createErrorRepresentation(),
+        errorMessage: KeycloakErrorMessage.USERNAME_IS_MISSING,
+      });
+
+      expect(service._mapUserNameIsMissing).toHaveBeenCalledWith(controlName);
+    });
+
+    it('should return invalid param for username is missing', () => {
+      const param: InvalidParam = service._mapKeycloakErrorRepresentationToInvalidParam({
+        ...createErrorRepresentation(),
+        errorMessage: KeycloakErrorMessage.USERNAME_IS_MISSING,
+      });
+
+      expect(param).toEqual(invalidParam);
+    });
+
+    it('should throw error', () => {
+      expect(() =>
+        service._mapKeycloakErrorRepresentationToInvalidParam({
+          ...createErrorRepresentation(),
+          errorMessage: 'unknown' as KeycloakErrorMessage,
+        }),
+      ).toThrowError();
+    });
+  });
+
+  describe('map invalid email', () => {
+    it('should return invalid param', () => {
+      expect(service._mapInvalidEmail('control')).toEqual({
+        name: 'control',
+        reason: ValidationMessageCode.FIELD_INVALID,
+        constraintParameters: [],
+        value: null,
+      } as InvalidParam);
+    });
+  });
+
+  describe('map invalid length', () => {
+    it('should return invalid param', () => {
+      expect(service._mapInvalidLength('control', { params: ['email', 1, 2] })).toEqual({
+        name: 'control',
+        reason: ValidationMessageCode.FIELD_SIZE,
+        constraintParameters: [
+          { name: 'min', value: '1' },
+          { name: 'max', value: '2' },
+        ],
+        value: null,
+      } as InvalidParam);
+    });
+  });
+
+  describe('map email exists', () => {
+    it('should return invalid param', () => {
+      expect(service._mapEmailExists('control')).toEqual({
+        name: 'control',
+        reason: ValidationMessageCode.FIELD_VALUE_ALREADY_EXISTS,
+        constraintParameters: [{ name: 'value', value: 'Email-Adresse' }],
+        value: null,
+      } as InvalidParam);
+    });
+  });
+
+  describe('map username exists', () => {
+    it('should return invalid param', () => {
+      expect(service._mapUsernameExists('control')).toEqual({
+        name: 'control',
+        reason: ValidationMessageCode.FIELD_VALUE_ALREADY_EXISTS,
+        constraintParameters: [{ name: 'value', value: 'Benutzername' }],
+        value: null,
+      } as InvalidParam);
+    });
+  });
+
+  describe('map username is missing', () => {
+    it('should return invalid param', () => {
+      expect(service._mapUserNameIsMissing('control')).toEqual({
+        name: 'control',
+        reason: ValidationMessageCode.FIELD_EMPTY,
+        constraintParameters: [],
+        value: null,
+      } as InvalidParam);
+    });
+  });
+
+  describe('evaluate control name', () => {
+    const errorRepresentation: ErrorRepresentation = createErrorRepresentation();
+
+    beforeEach(() => {
+      service._mapKeycloakErrorMessageToControlName = jest.fn();
+      service._mapKeycloakFieldNameToControlName = jest.fn();
+    });
+
+    describe('on single error message', () => {
+      beforeEach(() => {
+        isSingleErrorMessageMock.mockReturnValue(true);
+        isFieldErrorMessageMock.mockReturnValue(false);
+      });
+
+      it('should check if single error message', () => {
+        service._evaluateControlName(errorRepresentation);
+
+        expect(isSingleErrorMessageMock).toHaveBeenCalledWith(errorRepresentation);
+      });
+
+      it('should map to control name', () => {
+        service._evaluateControlName(errorRepresentation);
+
+        expect(service._mapKeycloakErrorMessageToControlName).toHaveBeenCalledWith(errorRepresentation.errorMessage);
+      });
+
+      it('should return control name', () => {
+        const controlName: string = faker.word.noun();
+        service._mapKeycloakErrorMessageToControlName = jest.fn().mockReturnValue(controlName);
+
+        expect(service._evaluateControlName(errorRepresentation)).toEqual(controlName);
+      });
+    });
+
+    describe('on field error message', () => {
+      beforeEach(() => {
+        isSingleErrorMessageMock.mockReturnValue(false);
+        isFieldErrorMessageMock.mockReturnValue(true);
+      });
+
+      it('should check if field error message', () => {
+        service._evaluateControlName(errorRepresentation);
+
+        expect(isFieldErrorMessageMock).toHaveBeenCalledWith(errorRepresentation);
+      });
+
+      it('should map to control name', () => {
+        service._evaluateControlName(errorRepresentation);
+
+        expect(service._mapKeycloakFieldNameToControlName).toHaveBeenCalledWith(errorRepresentation.field);
+      });
+
+      it('should return control name', () => {
+        const controlName: string = faker.word.noun();
+        service._mapKeycloakFieldNameToControlName = jest.fn().mockReturnValue(controlName);
+
+        expect(service._evaluateControlName(errorRepresentation)).toEqual(controlName);
+      });
+    });
+
+    it('should throw error', () => {
+      isSingleErrorMessageMock.mockReturnValue(false);
+      isFieldErrorMessageMock.mockReturnValue(false);
+
+      expect(() => service._evaluateControlName(errorRepresentation)).toThrowError();
+    });
   });
 
   describe('patch', () => {
@@ -306,4 +653,16 @@ export class TestKeycloakFormService extends KeycloakFormService<Dummy> {
   _doDelete(): Observable<StateResource<Dummy>> {
     return TestKeycloakFormService.DELETE_OBSERVABLE();
   }
+
+  _mapKeycloakFieldNameToControlName(keycloakFieldName: KeycloakFieldName): string {
+    throw new Error('Method not implemented.');
+  }
+
+  _mapKeycloakErrorMessageToControlName(keycloakErrorMessage: KeycloakErrorMessage): string {
+    throw new Error('Method not implemented.');
+  }
+
+  _handleUnknownKeycloakError(error: KeycloakHttpErrorResponse): void {
+    throw new Error('Method not implemented.');
+  }
 }
diff --git a/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-formservice.ts b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-formservice.ts
index 0d285b15c6..9fa4a9ed9d 100644
--- a/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-formservice.ts
+++ b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-formservice.ts
@@ -21,14 +21,14 @@
  * Die sprachspezifischen Genehmigungen und Beschränkungen
  * unter der Lizenz sind dem Lizenztext zu entnehmen.
  */
-import { createEmptyStateResource, createErrorStateResource, isLoaded, StateResource } from '@alfa-client/tech-shared';
+import { createEmptyStateResource, InvalidParam, isLoaded, setInvalidParamValidationError, StateResource, } from '@alfa-client/tech-shared';
 import { inject, Injectable } from '@angular/core';
 import { FormBuilder, FormGroup } from '@angular/forms';
 import { ActivatedRoute, UrlSegment } from '@angular/router';
-import { catchError, first, Observable, of, tap, throwError } from 'rxjs';
-
+import { catchError, first, Observable, of, tap } from 'rxjs';
+import { ValidationMessageCode } from '../../../../tech-shared/src/lib/validation/tech.validation.messages';
 import * as FormUtil from './form.util';
-import { isKeycloakError, KeycloakError } from './keycloak-error-handler';
+import { ErrorRepresentation, extractErrorRepresentations, isFieldErrorMessage, isSingleErrorMessage, KeycloakErrorMessage, KeycloakFieldName, KeycloakHttpErrorResponse, } from './keycloak-error.model';
 
 @Injectable()
 export abstract class KeycloakFormService<T> {
@@ -72,17 +72,107 @@ export abstract class KeycloakFormService<T> {
 
   public submit(): Observable<StateResource<T>> {
     return this._doSubmit().pipe(
-      catchError((err: any) => {
-        if (isKeycloakError(err)) {
-          this._handleKeycloakError(err);
-          return createErrorStateResource(err);
+      catchError((keycloakError: KeycloakHttpErrorResponse) => {
+        try {
+          this._setValidationErrorsOnControls(
+            this._mapKeycloakErrorRepresentationsToInvalidParams(extractErrorRepresentations(keycloakError)),
+          );
+        } catch (error: any) {
+          this._handleUnknownKeycloakError(keycloakError);
         }
-        return throwError(() => 'Unknown Keycloak error. Can not handle it.');
+        return of(createEmptyStateResource<T>());
       }),
     );
   }
 
-  _handleKeycloakError(err: KeycloakError): void {}
+  _setValidationErrorsOnControls(invalidParams: InvalidParam[]): void {
+    invalidParams.forEach((invalidParam: InvalidParam) => {
+      setInvalidParamValidationError(this.form, invalidParam);
+    });
+  }
+
+  _mapKeycloakErrorRepresentationsToInvalidParams(errorRepresentations: ErrorRepresentation[]): InvalidParam[] {
+    return errorRepresentations.map((errorRepresentation: ErrorRepresentation) =>
+      this._mapKeycloakErrorRepresentationToInvalidParam(errorRepresentation),
+    );
+  }
+
+  _mapKeycloakErrorRepresentationToInvalidParam(errorRepresentation: ErrorRepresentation): InvalidParam {
+    const controlName: string = this._evaluateControlName(errorRepresentation);
+    switch (errorRepresentation.errorMessage) {
+      case KeycloakErrorMessage.INVALID_EMAIL:
+        return this._mapInvalidEmail(controlName);
+      case KeycloakErrorMessage.INVALID_LENGTH:
+        return this._mapInvalidLength(controlName, errorRepresentation);
+      case KeycloakErrorMessage.EMAIL_EXISTS:
+        return this._mapEmailExists(controlName);
+      case KeycloakErrorMessage.USERNAME_EXISTS:
+        return this._mapUsernameExists(controlName);
+      case KeycloakErrorMessage.USERNAME_IS_MISSING:
+        return this._mapUserNameIsMissing(controlName);
+      default:
+        throw new Error(`Unknown keycloak error message ${errorRepresentation.errorMessage}`);
+    }
+  }
+
+  _mapInvalidEmail(controlName: string): InvalidParam {
+    return { name: controlName, reason: ValidationMessageCode.FIELD_INVALID, constraintParameters: [], value: null };
+  }
+
+  _mapInvalidLength(controlName: string, errorRepresentation: ErrorRepresentation): InvalidParam {
+    return {
+      name: controlName,
+      reason: ValidationMessageCode.FIELD_SIZE,
+      constraintParameters: [
+        { name: 'min', value: `${errorRepresentation.params[1]}` },
+        { name: 'max', value: `${errorRepresentation.params[2]}` },
+      ],
+      value: null,
+    };
+  }
+
+  _mapEmailExists(controlName: string): InvalidParam {
+    return {
+      name: controlName,
+      reason: ValidationMessageCode.FIELD_VALUE_ALREADY_EXISTS,
+      constraintParameters: [{ name: 'value', value: 'Email-Adresse' }],
+      value: null,
+    };
+  }
+
+  _mapUsernameExists(controlName: string): InvalidParam {
+    return {
+      name: controlName,
+      reason: ValidationMessageCode.FIELD_VALUE_ALREADY_EXISTS,
+      constraintParameters: [{ name: 'value', value: 'Benutzername' }],
+      value: null,
+    };
+  }
+
+  _mapUserNameIsMissing(controlName: string): InvalidParam {
+    return {
+      name: controlName,
+      reason: ValidationMessageCode.FIELD_EMPTY,
+      constraintParameters: [],
+      value: null,
+    };
+  }
+
+  _evaluateControlName(errorRepresentation: ErrorRepresentation): string {
+    if (isSingleErrorMessage(errorRepresentation)) {
+      return this._mapKeycloakErrorMessageToControlName(errorRepresentation.errorMessage);
+    }
+    if (isFieldErrorMessage(errorRepresentation)) {
+      return this._mapKeycloakFieldNameToControlName(errorRepresentation.field as KeycloakFieldName);
+    }
+    throw new Error(`Cannot evaluate control name for error ${errorRepresentation}`);
+  }
+
+  abstract _mapKeycloakFieldNameToControlName(keycloakFieldName: KeycloakFieldName): string;
+
+  abstract _mapKeycloakErrorMessageToControlName(keycloakErrorMessage: KeycloakErrorMessage): string;
+
+  abstract _handleUnknownKeycloakError(error: KeycloakHttpErrorResponse): void;
 
   abstract _doSubmit(): Observable<StateResource<T>>;
 
diff --git a/alfa-client/libs/admin/keycloak-shared/src/lib/user.repository.spec.ts b/alfa-client/libs/admin/keycloak-shared/src/lib/user.repository.spec.ts
index afbb09bee0..ee91561216 100644
--- a/alfa-client/libs/admin/keycloak-shared/src/lib/user.repository.spec.ts
+++ b/alfa-client/libs/admin/keycloak-shared/src/lib/user.repository.spec.ts
@@ -25,7 +25,7 @@ import { RoleMappings, User } from '@admin-client/user-shared';
 import { UserRepository } from '@admin/keycloak-shared';
 import { StateResource } from '@alfa-client/tech-shared';
 import { Mock, mock } from '@alfa-client/test-utils';
-import { TestBed, fakeAsync, tick } from '@angular/core/testing';
+import { fakeAsync, TestBed, tick } from '@angular/core/testing';
 import { faker } from '@faker-js/faker';
 import KcAdminClient from '@keycloak/keycloak-admin-client';
 import GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation';
@@ -89,16 +89,6 @@ describe('UserRepository', () => {
         done();
       });
     });
-
-    it('should call handleError', fakeAsync(() => {
-      repository._handleCreateError = jest.fn();
-      kcAdminClient.users['create'] = jest.fn().mockReturnValue(throwError(() => new Error('error')));
-
-      repository.createInKeycloak(user).subscribe({ error: () => {} });
-      tick();
-
-      expect(repository._handleCreateError).toHaveBeenCalled();
-    }));
   });
 
   describe('saveInKeycloak', () => {
diff --git a/alfa-client/libs/admin/keycloak-shared/src/lib/user.repository.ts b/alfa-client/libs/admin/keycloak-shared/src/lib/user.repository.ts
index 4072c1ab20..4dd503aadc 100644
--- a/alfa-client/libs/admin/keycloak-shared/src/lib/user.repository.ts
+++ b/alfa-client/libs/admin/keycloak-shared/src/lib/user.repository.ts
@@ -22,7 +22,7 @@
  * unter der Lizenz sind dem Lizenztext zu entnehmen.
  */
 import { ClientMapping, ClientRoles, RoleMappings, User } from '@admin-client/user-shared';
-import { createStateResource, ProblemDetail, StateResource } from '@alfa-client/tech-shared';
+import { createStateResource, StateResource } from '@alfa-client/tech-shared';
 import { inject, Injectable } from '@angular/core';
 import KcAdminClient from '@keycloak/keycloak-admin-client';
 import ClientRepresentation from '@keycloak/keycloak-admin-client/lib/defs/clientRepresentation';
@@ -43,7 +43,7 @@ export class UserRepository {
   public static readonly ALFA_CLIENT_NAME: string = 'alfa';
   public static readonly ADMIN_CLIENT_NAME: string = 'admin';
 
-  public createInKeycloak(user: User): Observable<User | ProblemDetail> {
+  public createInKeycloak(user: User): Observable<User> {
     return from(this.kcAdminClient.users.create(omit(user, 'groupIds'))).pipe(
       concatMap(async (response: { id: string }): Promise<{ id: string }> => {
         await this._updateUserRoles(response.id, user.clientRoles);
@@ -51,7 +51,6 @@ export class UserRepository {
       }),
       tap((response: { id: string }): void => this._sendActivationMail(response.id)),
       map((): User => user),
-      catchError((err: Error): Observable<never> => this._handleCreateError(err)),
     );
   }
 
diff --git a/alfa-client/libs/admin/keycloak-shared/src/test/keycloak.ts b/alfa-client/libs/admin/keycloak-shared/src/test/keycloak.ts
new file mode 100644
index 0000000000..32fdb0792d
--- /dev/null
+++ b/alfa-client/libs/admin/keycloak-shared/src/test/keycloak.ts
@@ -0,0 +1,23 @@
+import {
+  ErrorRepresentation,
+  KeycloakErrorMessage,
+  KeycloakFieldName,
+  KeycloakHttpErrorResponse,
+} from '../lib/keycloak-error.model';
+
+export function createErrorRepresentation(): ErrorRepresentation {
+  return {
+    field: KeycloakFieldName.USERNAME,
+    errorMessage: KeycloakErrorMessage.USERNAME_EXISTS,
+    params: [],
+  };
+}
+
+export function createKeycloakHttpErrorResponse(): KeycloakHttpErrorResponse {
+  return {
+    response: {
+      status: 400,
+    },
+    responseData: createErrorRepresentation(),
+  };
+}
diff --git a/alfa-client/libs/admin/user/src/lib/user-form/user.formservice.spec.ts b/alfa-client/libs/admin/user/src/lib/user-form/user.formservice.spec.ts
index 724564f208..d5340a9bfa 100644
--- a/alfa-client/libs/admin/user/src/lib/user-form/user.formservice.spec.ts
+++ b/alfa-client/libs/admin/user/src/lib/user-form/user.formservice.spec.ts
@@ -26,37 +26,33 @@ import { ROUTES } from '@admin-client/shared';
 import { User, UserService } from '@admin-client/user-shared';
 import { PatchConfig } from '@admin/keycloak-shared';
 import { NavigationService } from '@alfa-client/navigation-shared';
-import {
-  createEmptyStateResource,
-  createLoadingStateResource,
-  createStateResource,
-  StateResource,
-} from '@alfa-client/tech-shared';
+import { createEmptyStateResource, createLoadingStateResource, createStateResource, StateResource, } from '@alfa-client/tech-shared';
 import { Mock, mock, mockWindowError } from '@alfa-client/test-utils';
 import { SnackBarService } from '@alfa-client/ui';
 import { fakeAsync, TestBed, tick } from '@angular/core/testing';
 import { AbstractControl, FormControl, FormGroup, UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
 import { ActivatedRoute, UrlSegment } from '@angular/router';
 import { faker } from '@faker-js/faker/locale/de';
-import { cold } from 'jest-marbles';
+import { expect } from '@jest/globals';
 import { createUser } from 'libs/admin/user-shared/test/user';
-import { Observable, of, throwError } from 'rxjs';
+import { Observable, of } from 'rxjs';
 import { createUrlSegment } from '../../../../../navigation-shared/test/navigation-test-factory';
 import { singleCold, singleColdCompleted, singleHot } from '../../../../../tech-shared/test/marbles';
+import { KeycloakErrorMessage, KeycloakFieldName } from '../../../../keycloak-shared/src/lib/keycloak-error.model';
+import { createKeycloakHttpErrorResponse } from '../../../../keycloak-shared/src/test/keycloak';
 import { createAdminOrganisationsEinheit } from '../../../../organisations-einheit-shared/src/test/organisations-einheit';
 import { UserFormService } from './user.formservice';
-
 import SpyInstance = jest.SpyInstance;
 
 describe('UserFormService', () => {
-  let formService: UserFormService;
+  let service: UserFormService;
 
   let roleGroup: UntypedFormGroup;
   let alfaGroup: UntypedFormGroup;
   let organisationsEinheitenGroup: UntypedFormGroup;
   let administrationGroup: UntypedFormGroup;
 
-  let service: Mock<UserService>;
+  let userService: Mock<UserService>;
   let adminOrganisationsEinheitService: Mock<AdminOrganisationsEinheitService>;
   let navigationService: Mock<NavigationService>;
   let snackBarService: Mock<SnackBarService>;
@@ -68,7 +64,7 @@ describe('UserFormService', () => {
   ]);
 
   beforeEach(() => {
-    service = {
+    userService = {
       ...mock(UserService),
       refresh: jest.fn(),
       create: jest.fn(),
@@ -88,7 +84,7 @@ describe('UserFormService', () => {
       providers: [
         UserFormService,
         UntypedFormBuilder,
-        { provide: UserService, useValue: service },
+        { provide: UserService, useValue: userService },
         { provide: AdminOrganisationsEinheitService, useValue: adminOrganisationsEinheitService },
         { provide: NavigationService, useValue: navigationService },
         { provide: SnackBarService, useValue: snackBarService },
@@ -96,15 +92,15 @@ describe('UserFormService', () => {
       ],
     });
 
-    formService = TestBed.inject(UserFormService);
-    organisationsEinheitenGroup = <UntypedFormGroup>formService.form.get(UserFormService.GROUPS);
-    roleGroup = <UntypedFormGroup>formService.form.get(UserFormService.CLIENT_ROLES);
+    service = TestBed.inject(UserFormService);
+    organisationsEinheitenGroup = <UntypedFormGroup>service.form.get(UserFormService.GROUPS);
+    roleGroup = <UntypedFormGroup>service.form.get(UserFormService.CLIENT_ROLES);
     alfaGroup = <UntypedFormGroup>roleGroup.get(UserFormService.ALFA_GROUP);
     administrationGroup = <UntypedFormGroup>roleGroup.get(UserFormService.ADMINISTRATION_GROUP);
   });
 
   it('should create', () => {
-    expect(formService).toBeTruthy();
+    expect(service).toBeTruthy();
   });
 
   describe('build patch config', () => {
@@ -113,7 +109,7 @@ describe('UserFormService', () => {
         const benutzerUrlSegment: UrlSegment = <UrlSegment>{ ...createUrlSegment(), path: 'benutzer' };
         const idUrlSegment: UrlSegment = <UrlSegment>{ ...createUrlSegment(), path: 'dummyId' };
 
-        const patchConfig: PatchConfig = formService._buildPatchConfig([benutzerUrlSegment, idUrlSegment]);
+        const patchConfig: PatchConfig = service._buildPatchConfig([benutzerUrlSegment, idUrlSegment]);
 
         expect(patchConfig).toEqual({ id: 'dummyId', doPatch: true });
       });
@@ -123,7 +119,7 @@ describe('UserFormService', () => {
       const benutzerUrlSegment: UrlSegment = <UrlSegment>{ ...createUrlSegment(), path: 'benutzer' };
       const idUrlSegment: UrlSegment = <UrlSegment>{ ...createUrlSegment(), path: 'neu' };
 
-      const patchConfig: PatchConfig = formService._buildPatchConfig([benutzerUrlSegment, idUrlSegment]);
+      const patchConfig: PatchConfig = service._buildPatchConfig([benutzerUrlSegment, idUrlSegment]);
 
       expect(patchConfig).toEqual({ doPatch: false });
     });
@@ -135,17 +131,17 @@ describe('UserFormService', () => {
     const loadedUser: StateResource<User> = createStateResource(createUser());
 
     beforeEach(() => {
-      service.getUserById.mockReturnValue(singleHot(loadedUser));
+      userService.getUserById.mockReturnValue(singleHot(loadedUser));
     });
 
     it('should call service to get user by id', () => {
-      formService._load(id);
+      service._load(id);
 
-      expect(service.getUserById).toHaveBeenCalledWith(id);
+      expect(userService.getUserById).toHaveBeenCalledWith(id);
     });
 
     it('should return loaded user', () => {
-      const response: Observable<StateResource<User>> = formService._load(id);
+      const response: Observable<StateResource<User>> = service._load(id);
 
       expect(response).toBeObservable(singleCold(loadedUser));
     });
@@ -153,57 +149,55 @@ describe('UserFormService', () => {
 
   describe('listenToAlfaGroupChanges', () => {
     it('should call handleAlfaGroupChange when value of form element changes', fakeAsync(() => {
-      formService._handleAlfaGroupChange = jest.fn();
+      service._handleAlfaGroupChange = jest.fn();
 
       alfaGroup.get(UserFormService.LOESCHEN).setValue(true);
 
       tick();
 
-      expect(formService._handleAlfaGroupChange).toHaveBeenCalled();
+      expect(service._handleAlfaGroupChange).toHaveBeenCalled();
     }));
   });
 
   describe('initOrganisationsEinheiten', () => {
     beforeEach(() => {
-      formService._addOrganisationsEinheitenToForm = jest.fn();
+      service._addOrganisationsEinheitenToForm = jest.fn();
     });
 
     it('should call adminOrganisationsEinheitService getAll', () => {
-      formService._initOrganisationsEinheiten();
+      service._initOrganisationsEinheiten();
 
       expect(adminOrganisationsEinheitService.getAll).toHaveBeenCalled();
     });
 
     it('should return resource', () => {
-      const result: Observable<AdminOrganisationsEinheit[]> = formService._initOrganisationsEinheiten();
+      const result: Observable<AdminOrganisationsEinheit[]> = service._initOrganisationsEinheiten();
 
       expect(result).toBeObservable(singleColdCompleted(adminOrganisationsEinheitList.resource));
     });
 
     it('should call addOrganisationsEinheitenToForm ', fakeAsync(() => {
-      formService._initOrganisationsEinheiten().subscribe();
+      service._initOrganisationsEinheiten().subscribe();
       tick();
 
-      expect(formService._addOrganisationsEinheitenToForm).toHaveBeenCalled();
+      expect(service._addOrganisationsEinheitenToForm).toHaveBeenCalled();
     }));
 
     it('should save ids in map', () => {
-      formService._initOrganisationsEinheiten().subscribe();
+      service._initOrganisationsEinheiten().subscribe();
 
-      expect(formService._organisationsEinheitToGroupIdMap.get(adminOrganisationsEinheit.name)).toEqual(
-        adminOrganisationsEinheit.id,
-      );
+      expect(service._organisationsEinheitToGroupIdMap.get(adminOrganisationsEinheit.name)).toEqual(adminOrganisationsEinheit.id);
     });
 
     it('should set initOrganisationsEinheiten$', () => {
-      expect(formService['_initOrganisationsEinheiten$']).toBeDefined();
+      expect(service['_initOrganisationsEinheiten$']).toBeDefined();
     });
 
     it('should not throw any exception on loading state resource', () => {
       adminOrganisationsEinheitService.getAll.mockReturnValue(of(createLoadingStateResource()));
       const errorMock: any = mockWindowError();
 
-      formService._initOrganisationsEinheiten();
+      service._initOrganisationsEinheiten();
 
       expect(errorMock).not.toHaveBeenCalled();
     });
@@ -211,9 +205,9 @@ describe('UserFormService', () => {
 
   describe('addOrganisationsEinheitenToForm', () => {
     it('should add organisationsEinheiten to form', () => {
-      const organisationsEinheitenGroup: UntypedFormGroup = <UntypedFormGroup>formService.form.get(UserFormService.GROUPS);
+      const organisationsEinheitenGroup: UntypedFormGroup = <UntypedFormGroup>service.form.get(UserFormService.GROUPS);
 
-      formService._addOrganisationsEinheitenToForm(adminOrganisationsEinheitList.resource, organisationsEinheitenGroup);
+      service._addOrganisationsEinheitenToForm(adminOrganisationsEinheitList.resource, organisationsEinheitenGroup);
 
       expect(organisationsEinheitenGroup.value).toEqual({ [adminOrganisationsEinheit.name]: false });
     });
@@ -221,7 +215,7 @@ describe('UserFormService', () => {
 
   describe('roleValidator', () => {
     it('should return error if no role is selected', () => {
-      const result = formService.roleValidator()(roleGroup);
+      const result = service.roleValidator()(roleGroup);
 
       expect(result).toEqual({ atLeastOneRoleSelected: true });
     });
@@ -229,7 +223,7 @@ describe('UserFormService', () => {
     it('should return null if at least one role is selected', () => {
       alfaGroup.get(UserFormService.LOESCHEN).setValue(true);
 
-      const result = formService.roleValidator()(roleGroup);
+      const result = service.roleValidator()(roleGroup);
 
       expect(result).toBeNull();
     });
@@ -237,27 +231,27 @@ describe('UserFormService', () => {
 
   describe('handleAlfaGroupChange', () => {
     it('should call disableUncheckedCheckboxes if any checkbox is checked', () => {
-      formService._isAnyChecked = jest.fn().mockReturnValue(true);
-      formService._disableUncheckedCheckboxes = jest.fn();
+      service._isAnyChecked = jest.fn().mockReturnValue(true);
+      service._disableUncheckedCheckboxes = jest.fn();
 
-      formService._handleAlfaGroupChange(alfaGroup);
+      service._handleAlfaGroupChange(alfaGroup);
 
-      expect(formService._disableUncheckedCheckboxes).toHaveBeenCalled();
+      expect(service._disableUncheckedCheckboxes).toHaveBeenCalled();
     });
 
     it('should call enableAllCheckboxes if not any checkbox is checked', () => {
-      formService._isAnyChecked = jest.fn().mockReturnValue(false);
-      formService._enableAllCheckboxes = jest.fn();
+      service._isAnyChecked = jest.fn().mockReturnValue(false);
+      service._enableAllCheckboxes = jest.fn();
 
-      formService._handleAlfaGroupChange(alfaGroup);
+      service._handleAlfaGroupChange(alfaGroup);
 
-      expect(formService._enableAllCheckboxes).toHaveBeenCalled();
+      expect(service._enableAllCheckboxes).toHaveBeenCalled();
     });
   });
 
   describe('isAnyChecked', () => {
     it('should return false if no checkbox is checked', () => {
-      const result = formService._isAnyChecked(alfaGroup);
+      const result = service._isAnyChecked(alfaGroup);
 
       expect(result).toBe(false);
     });
@@ -265,7 +259,7 @@ describe('UserFormService', () => {
     it('should return true if any checkbox is checked', () => {
       alfaGroup.get(UserFormService.LOESCHEN).setValue(true);
 
-      const result = formService._isAnyChecked(alfaGroup);
+      const result = service._isAnyChecked(alfaGroup);
 
       expect(result).toBe(true);
     });
@@ -276,7 +270,7 @@ describe('UserFormService', () => {
       const control: AbstractControl = alfaGroup.get(UserFormService.LOESCHEN);
       control.setValue(false);
 
-      formService._disableUncheckedCheckboxes(alfaGroup);
+      service._disableUncheckedCheckboxes(alfaGroup);
 
       expect(control.disabled).toBe(true);
     });
@@ -285,7 +279,7 @@ describe('UserFormService', () => {
       const control: AbstractControl = alfaGroup.get(UserFormService.LOESCHEN);
       control.setValue(true);
 
-      formService._disableUncheckedCheckboxes(alfaGroup);
+      service._disableUncheckedCheckboxes(alfaGroup);
 
       expect(control.disabled).toBe(false);
     });
@@ -296,7 +290,7 @@ describe('UserFormService', () => {
       const control: AbstractControl = alfaGroup.get(UserFormService.LOESCHEN);
       control.setValue(false);
 
-      formService._disableUncheckedCheckboxes(alfaGroup);
+      service._disableUncheckedCheckboxes(alfaGroup);
 
       expect(control.disabled).toBe(true);
     });
@@ -307,7 +301,7 @@ describe('UserFormService', () => {
       const control: AbstractControl = alfaGroup.get(UserFormService.LOESCHEN);
       const enableSpy = jest.spyOn(control, 'enable');
 
-      formService._enableAllCheckboxes(alfaGroup);
+      service._enableAllCheckboxes(alfaGroup);
 
       expect(enableSpy).toHaveBeenCalled();
     });
@@ -317,63 +311,45 @@ describe('UserFormService', () => {
     const user: User = createUser();
 
     beforeEach(() => {
-      formService.isInvalid = jest.fn().mockReturnValue(false);
-      service.create.mockReturnValue(of(createStateResource(user)));
-    });
-
-    it('should return empty stateResource if form is invalid', () => {
-      formService.isInvalid = jest.fn().mockReturnValue(true);
-
-      const result: Observable<StateResource<User>> = formService._doSubmit();
-
-      expect(result).toBeObservable(singleColdCompleted(createEmptyStateResource<User>()));
+      service.isInvalid = jest.fn().mockReturnValue(false);
+      userService.create.mockReturnValue(of(createStateResource(user)));
     });
 
     it('should call createUser', () => {
-      formService._createUser = jest.fn();
+      service._createUser = jest.fn();
 
-      formService.submit();
+      service.submit();
 
-      expect(formService._createUser).toHaveBeenCalled();
+      expect(service._createUser).toHaveBeenCalled();
     });
 
     it('should call _createOrSave', () => {
-      formService._createUser = jest.fn().mockReturnValue(user);
-      formService._createOrSave = jest.fn().mockReturnValue(of(user));
+      service._createUser = jest.fn().mockReturnValue(user);
+      service._createOrSave = jest.fn().mockReturnValue(of(user));
 
-      formService.submit();
+      service.submit();
 
-      expect(formService._createOrSave).toHaveBeenCalledWith(user);
+      expect(service._createOrSave).toHaveBeenCalledWith(user);
     });
 
     it('should call handleOnCreateUserSuccess if not loading', fakeAsync(() => {
-      const handleOnCreateUserSuccessSpy: SpyInstance = jest.spyOn(formService, 'handleOnCreateUserSuccess');
+      const handleOnCreateUserSuccessSpy: SpyInstance = jest.spyOn(service, 'handleOnCreateUserSuccess');
 
-      formService.submit().subscribe();
+      service.submit().subscribe();
       tick();
 
       expect(handleOnCreateUserSuccessSpy).toHaveBeenCalled();
     }));
 
     it('should not call handleOnCreateUserSuccess if loading', fakeAsync(() => {
-      service.create.mockReturnValue(of(createEmptyStateResource(true)));
-      const handleOnCreateUserSuccessSpy: SpyInstance = jest.spyOn(formService, 'handleOnCreateUserSuccess');
+      userService.create.mockReturnValue(of(createEmptyStateResource(true)));
+      const handleOnCreateUserSuccessSpy: SpyInstance = jest.spyOn(service, 'handleOnCreateUserSuccess');
 
-      formService.submit().subscribe();
+      service.submit().subscribe();
       tick();
 
       expect(handleOnCreateUserSuccessSpy).not.toHaveBeenCalled();
     }));
-
-    it('should call handleSubmitError on error', fakeAsync(() => {
-      service.create.mockReturnValue(throwError(() => new Error()));
-      const handleSubmitErrorSpy: SpyInstance = jest.spyOn(formService, 'handleSubmitError');
-
-      formService.submit().subscribe();
-      tick();
-
-      expect(handleSubmitErrorSpy).toHaveBeenCalled();
-    }));
   });
 
   describe('createOrSave', () => {
@@ -384,57 +360,50 @@ describe('UserFormService', () => {
     const patchConfig: PatchConfig = { id, doPatch: true };
 
     it('should call save if patched', () => {
-      formService.isPatch = jest.fn().mockReturnValue(true);
-      formService._patchConfig = patchConfig;
+      service.isPatch = jest.fn().mockReturnValue(true);
+      service._patchConfig = patchConfig;
       user.groups = [adminOrganisationsEinheit.name];
-      formService._organisationsEinheitToGroupIdMap.set(adminOrganisationsEinheit.name, adminOrganisationsEinheit.id);
+      service._organisationsEinheitToGroupIdMap.set(adminOrganisationsEinheit.name, adminOrganisationsEinheit.id);
 
-      formService._createOrSave(user);
+      service._createOrSave(user);
 
-      expect(service.save).toHaveBeenCalledWith({ ...user, id, groupIds: [adminOrganisationsEinheit.id] });
+      expect(userService.save).toHaveBeenCalledWith({ ...user, id, groupIds: [adminOrganisationsEinheit.id] });
     });
 
     it('should call create if not patched', () => {
-      formService.isPatch = jest.fn().mockReturnValue(false);
+      service.isPatch = jest.fn().mockReturnValue(false);
 
-      formService._createOrSave(user);
+      service._createOrSave(user);
 
-      expect(service.create).toHaveBeenCalledWith(user);
+      expect(userService.create).toHaveBeenCalledWith(user);
     });
   });
 
   describe('handleOnCreateUserSuccess', () => {
     it('should show success message for create', () => {
-      formService.handleOnCreateUserSuccess();
+      service.handleOnCreateUserSuccess();
 
       expect(snackBarService.showInfo).toHaveBeenCalledWith('Der Benutzer wurde angelegt.');
     });
 
     it('should show success message for patch', () => {
-      formService.isPatch = jest.fn().mockReturnValue(true);
+      service.isPatch = jest.fn().mockReturnValue(true);
 
-      formService.handleOnCreateUserSuccess();
+      service.handleOnCreateUserSuccess();
 
       expect(snackBarService.showInfo).toHaveBeenCalledWith('Der Benutzer wurde gespeichert.');
     });
 
     it('should navigate back to user list', () => {
-      formService.handleOnCreateUserSuccess();
+      service.handleOnCreateUserSuccess();
 
       expect(navigationService.navigate).toHaveBeenCalledWith(ROUTES.BENUTZER);
     });
   });
 
-  describe('handleSubmitError', () => {
-    it('should return empty stateResource', () => {
-      const result = formService.handleSubmitError();
-
-      expect(result).toBeObservable(cold('(a|)', { a: createEmptyStateResource<User>() }));
-    });
-
+  describe('handle unknown keycloak error', () => {
     it('should show error message', fakeAsync(() => {
-      formService.handleSubmitError().subscribe();
-      tick();
+      service._handleUnknownKeycloakError(createKeycloakHttpErrorResponse());
 
       expect(snackBarService.showError).toHaveBeenCalledWith('Der Benutzer konnte nicht angelegt werden.');
     }));
@@ -442,7 +411,7 @@ describe('UserFormService', () => {
 
   describe('getRoles', () => {
     it('should return no roles when none are active', () => {
-      const result: string[] = formService._getRoles(UserFormService.ALFA_GROUP);
+      const result: string[] = service._getRoles(UserFormService.ALFA_GROUP);
 
       expect(result).toEqual([]);
     });
@@ -450,7 +419,7 @@ describe('UserFormService', () => {
     it('should return poststelle role when active', () => {
       alfaGroup.get(UserFormService.POSTSTELLE).setValue(true);
 
-      const result: string[] = formService._getRoles(UserFormService.ALFA_GROUP);
+      const result: string[] = service._getRoles(UserFormService.ALFA_GROUP);
 
       expect(result).toEqual([UserFormService.POSTSTELLE]);
     });
@@ -458,7 +427,7 @@ describe('UserFormService', () => {
 
   describe('getActiveOrganisationsEinheiten', () => {
     it('should return no groups when none are active', () => {
-      const result: string[] = formService._getActiveOrganisationsEinheiten();
+      const result: string[] = service._getActiveOrganisationsEinheiten();
 
       expect(result).toEqual([]);
     });
@@ -467,7 +436,7 @@ describe('UserFormService', () => {
       const organisationsEinheit: AdminOrganisationsEinheit = createAdminOrganisationsEinheit();
       organisationsEinheitenGroup.addControl(organisationsEinheit.name, new FormControl(true));
 
-      const result: string[] = formService._getActiveOrganisationsEinheiten();
+      const result: string[] = service._getActiveOrganisationsEinheiten();
 
       expect(result).toEqual([organisationsEinheit.name]);
     });
@@ -475,21 +444,57 @@ describe('UserFormService', () => {
 
   describe('ngOnDestroy', () => {
     it('should unsubscribe from initOrganisationsEinheiten$', () => {
-      formService._initOrganisationsEinheiten$.unsubscribe = jest.fn();
+      service._initOrganisationsEinheiten$.unsubscribe = jest.fn();
 
-      formService.ngOnDestroy();
+      service.ngOnDestroy();
 
-      expect(formService._initOrganisationsEinheiten$.unsubscribe).toHaveBeenCalled();
+      expect(service._initOrganisationsEinheiten$.unsubscribe).toHaveBeenCalled();
     });
   });
 
   describe('get userName', () => {
     it('should return form control value of userName', () => {
-      formService.form = new FormGroup({ [UserFormService.USERNAME]: new FormControl('userNameDummy') });
+      service.form = new FormGroup({ [UserFormService.USERNAME]: new FormControl('userNameDummy') });
 
-      const userName: string = formService.getUserName();
+      const userName: string = service.getUserName();
 
       expect(userName).toBe('userNameDummy');
     });
   });
+
+  describe('map keycloak error message to control name', () => {
+    it('should map username is missing error message to username', () => {
+      expect(service._mapKeycloakErrorMessageToControlName(KeycloakErrorMessage.USERNAME_IS_MISSING)).toEqual(
+        UserFormService.USERNAME,
+      );
+    });
+
+    it('should user name already exists error message to username', () => {
+      expect(service._mapKeycloakErrorMessageToControlName(KeycloakErrorMessage.USERNAME_EXISTS)).toEqual(
+        UserFormService.USERNAME,
+      );
+    });
+
+    it('should map email exists error message to email', () => {
+      expect(service._mapKeycloakErrorMessageToControlName(KeycloakErrorMessage.EMAIL_EXISTS)).toEqual(UserFormService.EMAIL);
+    });
+
+    it('should throw error', () => {
+      expect(() => service._mapKeycloakErrorMessageToControlName('unknown' as KeycloakErrorMessage)).toThrowError();
+    });
+  });
+
+  describe('map keycloak field name to control name', () => {
+    it('should map username to username', () => {
+      expect(service._mapKeycloakFieldNameToControlName(KeycloakFieldName.USERNAME)).toEqual(UserFormService.USERNAME);
+    });
+
+    it('should map email to email', () => {
+      expect(service._mapKeycloakFieldNameToControlName(KeycloakFieldName.EMAIL)).toEqual(UserFormService.EMAIL);
+    });
+
+    it('should throw error', () => {
+      expect(() => service._mapKeycloakFieldNameToControlName('unknown' as KeycloakFieldName)).toThrowError();
+    });
+  });
 });
diff --git a/alfa-client/libs/admin/user/src/lib/user-form/user.formservice.ts b/alfa-client/libs/admin/user/src/lib/user-form/user.formservice.ts
index 494673b77c..140881cfe7 100644
--- a/alfa-client/libs/admin/user/src/lib/user-form/user.formservice.ts
+++ b/alfa-client/libs/admin/user/src/lib/user-form/user.formservice.ts
@@ -24,22 +24,15 @@
 import { AdminOrganisationsEinheit, AdminOrganisationsEinheitService } from '@admin-client/organisations-einheit-shared';
 import { ROUTES } from '@admin-client/shared';
 import { User, UserService } from '@admin-client/user-shared';
-import { KeycloakFormService, PatchConfig } from '@admin/keycloak-shared';
+import { KeycloakFormService, KeycloakHttpErrorResponse, PatchConfig } from '@admin/keycloak-shared';
 import { NavigationService } from '@alfa-client/navigation-shared';
-import { createEmptyStateResource, EMPTY_STRING, isLoaded, mapToResource, StateResource } from '@alfa-client/tech-shared';
+import { EMPTY_STRING, isLoaded, mapToResource, StateResource } from '@alfa-client/tech-shared';
 import { SnackBarService } from '@alfa-client/ui';
 import { Injectable, OnDestroy } from '@angular/core';
-import {
-  AbstractControl,
-  FormControl,
-  UntypedFormBuilder,
-  UntypedFormGroup,
-  ValidationErrors,
-  ValidatorFn,
-  Validators,
-} from '@angular/forms';
+import { AbstractControl, FormControl, UntypedFormBuilder, UntypedFormGroup, ValidationErrors, ValidatorFn, Validators, } from '@angular/forms';
 import { UrlSegment } from '@angular/router';
-import { catchError, filter, Observable, of, Subscription, tap } from 'rxjs';
+import { filter, Observable, Subscription, tap } from 'rxjs';
+import { KeycloakErrorMessage, KeycloakFieldName } from '../../../../keycloak-shared/src/lib/keycloak-error.model';
 
 @Injectable()
 export class UserFormService extends KeycloakFormService<User> implements OnDestroy {
@@ -196,16 +189,11 @@ export class UserFormService extends KeycloakFormService<User> implements OnDest
   }
 
   _doSubmit(): Observable<StateResource<User>> {
-    if (this.isInvalid()) {
-      return of(createEmptyStateResource<User>());
-    }
-
     const user: User = this._createUser();
     return this._createOrSave(user).pipe(
       tap((state: StateResource<User>): void => {
         if (!state.loading) this.handleOnCreateUserSuccess();
       }),
-      catchError((): Observable<StateResource<User>> => this.handleSubmitError()),
     );
   }
 
@@ -226,11 +214,6 @@ export class UserFormService extends KeycloakFormService<User> implements OnDest
     this.navigationService.navigate(ROUTES.BENUTZER);
   }
 
-  handleSubmitError(): Observable<StateResource<User>> {
-    this.snackBarService.showError('Der Benutzer konnte nicht angelegt werden.');
-    return of(createEmptyStateResource<User>());
-  }
-
   _createUser(): User {
     return {
       ...this._getFormValue(),
@@ -270,4 +253,31 @@ export class UserFormService extends KeycloakFormService<User> implements OnDest
   public getUserName(): string {
     return this.form.get(UserFormService.USERNAME).value;
   }
+
+  _mapKeycloakErrorMessageToControlName(keycloakErrorMessage: KeycloakErrorMessage): string {
+    switch (keycloakErrorMessage) {
+      case KeycloakErrorMessage.USERNAME_IS_MISSING:
+      case KeycloakErrorMessage.USERNAME_EXISTS:
+        return UserFormService.USERNAME;
+      case KeycloakErrorMessage.EMAIL_EXISTS:
+        return UserFormService.EMAIL;
+      default:
+        throw new Error(`Cannot map keycloak error message ${keycloakErrorMessage} to control name`);
+    }
+  }
+
+  _mapKeycloakFieldNameToControlName(keycloakFieldName: KeycloakFieldName): string {
+    switch (keycloakFieldName) {
+      case KeycloakFieldName.USERNAME:
+        return UserFormService.USERNAME;
+      case KeycloakFieldName.EMAIL:
+        return UserFormService.EMAIL;
+      default:
+        throw new Error(`Cannot map keycloak field name ${keycloakFieldName} to control name`);
+    }
+  }
+
+  _handleUnknownKeycloakError(error: KeycloakHttpErrorResponse): void {
+    this.snackBarService.showError('Der Benutzer konnte nicht angelegt werden.');
+  }
 }
diff --git a/alfa-client/libs/tech-shared/src/lib/validation/tech.validation.messages.ts b/alfa-client/libs/tech-shared/src/lib/validation/tech.validation.messages.ts
index accb4de667..2045e5afa6 100644
--- a/alfa-client/libs/tech-shared/src/lib/validation/tech.validation.messages.ts
+++ b/alfa-client/libs/tech-shared/src/lib/validation/tech.validation.messages.ts
@@ -32,21 +32,20 @@ export enum ValidationMessageCode {
   FIELD_INVALID = 'validation_field_invalid',
   FIELD_DATE_FORMAT_INVALID = 'validation_field_date_format_invalid',
   FIELD_ASSIGN_BEARBEITER_NOT_EXIST = 'fe_only_validation_bearbeiter_not_exist',
+  FIELD_VALUE_ALREADY_EXISTS = 'validation_field_value_already_exists',
 }
 
 export const VALIDATION_MESSAGES: { [code: string]: string } = {
   [ValidationMessageCode.FIELD_EMPTY]: 'Bitte {field} ausfüllen',
   [ValidationMessageCode.FIELD_MAX_SIZE]: '{field} darf höchstens {max} Zeichen enthalten',
   [ValidationMessageCode.FIELD_MIN_SIZE]: '{field} muss aus mindestens {min} Zeichen bestehen',
-  [ValidationMessageCode.FIELD_SIZE]:
-    '{field} muss mindestens {min} und darf höchstens {max} Zeichen enthalten',
+  [ValidationMessageCode.FIELD_SIZE]: '{field} muss mindestens {min} und darf höchstens {max} Zeichen enthalten',
   [ValidationMessageCode.FIELD_DATE_PAST]: 'Das Datum für {field} muss in der Zukunft liegen',
   [ValidationMessageCode.FIELD_INVALID]: 'Bitte {field} korrekt ausfüllen',
-  [ValidationMessageCode.FIELD_FILE_SIZE_EXCEEDED]:
-    'Anhänge größer {max}{unit} können nicht hinzugefügt werden.',
+  [ValidationMessageCode.FIELD_FILE_SIZE_EXCEEDED]: 'Anhänge größer {max}{unit} können nicht hinzugefügt werden.',
 
   fe_only_validation_bearbeiter_not_exist: 'Der Bearbeiter existiert nicht',
   [ValidationMessageCode.FIELD_DATE_FORMAT_INVALID]: 'Geben Sie ein gültiges Datum ein',
-  [ValidationMessageCode.FIELD_FILE_CONTENT_TYPE_INVALID]:
-    'Erlaubte Dateiendungen: pdf, jpg, png, jpeg',
+  [ValidationMessageCode.FIELD_FILE_CONTENT_TYPE_INVALID]: 'Erlaubte Dateiendungen: pdf, jpg, png, jpeg',
+  [ValidationMessageCode.FIELD_VALUE_ALREADY_EXISTS]: '{value} bereits verwendet',
 };
-- 
GitLab


From 8e5b889a7cce2103286871a8e2eac47d917d385c Mon Sep 17 00:00:00 2001
From: sebo <sebastian.bergandy@external.mgm-cp.com>
Date: Sun, 9 Mar 2025 16:12:01 +0100
Subject: [PATCH 03/22] OZG-7591 implement client validation

Sub task: OZG-7743
---
 .../src/lib/keycloak-formservice.spec.ts      | 186 ++++++++++++++----
 .../src/lib/keycloak-formservice.ts           |  40 ++--
 .../src/lib/user.repository.spec.ts           |  11 --
 .../src/lib/user.repository.ts                |   3 +-
 .../user-form-roles.component.html            |   7 +-
 .../user-form-roles.component.spec.ts         |  64 +++++-
 .../user-form-roles.component.ts              |  28 ++-
 .../lib/user-form/user.formservice.spec.ts    |  23 +--
 .../src/lib/user-form/user.formservice.ts     |  49 ++---
 .../formcontrol-editor.abstract.component.ts  |   5 +-
 .../validation-error.component.ts             |   2 +-
 alfa-client/libs/tech-shared/src/index.ts     |   1 +
 .../lib/validation/tech.validators.spec.ts    | 155 +++++++++++++++
 .../src/lib/validation/tech.validators.ts     |  81 ++++++++
 14 files changed, 537 insertions(+), 118 deletions(-)
 create mode 100644 alfa-client/libs/tech-shared/src/lib/validation/tech.validators.spec.ts
 create mode 100644 alfa-client/libs/tech-shared/src/lib/validation/tech.validators.ts

diff --git a/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-formservice.spec.ts b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-formservice.spec.ts
index 87d290d1c0..98cade5917 100644
--- a/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-formservice.spec.ts
+++ b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-formservice.spec.ts
@@ -24,7 +24,7 @@
 import { createEmptyStateResource, createStateResource, EMPTY_STRING, InvalidParam, setInvalidParamValidationError, StateResource, } from '@alfa-client/tech-shared';
 import { Injectable } from '@angular/core';
 import { TestBed } from '@angular/core/testing';
-import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms';
+import { AbstractControl, FormArray, FormBuilder, FormControl, FormGroup, UntypedFormGroup, Validators } from '@angular/forms';
 import { ActivatedRoute, UrlSegment } from '@angular/router';
 import { faker } from '@faker-js/faker/.';
 import { createDummy, Dummy } from 'libs/tech-shared/test/dummy';
@@ -212,12 +212,29 @@ describe('KeycloakFormService', () => {
 
     beforeEach(() => {
       service._doSubmit = jest.fn().mockReturnValue(singleHot(dummyStateResource));
-      service._setValidationErrorsOnControls = jest.fn();
-      service._mapKeycloakErrorRepresentationsToInvalidParams = jest.fn();
+      service._processInvalidForm = jest.fn().mockReturnValue(of(createEmptyStateResource()));
+      service._processResponseValidationErrors = jest.fn().mockReturnValue(of(createEmptyStateResource()));
+      service.form.setErrors(null);
+    });
+
+    describe('on client validation error', () => {
+      it('should process invalid form', () => {
+        service.form.setErrors({ dummy: 'dummy error' });
+
+        service.submit().subscribe();
+
+        expect(service._processInvalidForm).toHaveBeenCalled();
+      });
+
+      it('should return invalid form processing result on invalid form', () => {
+        service.form.setErrors({ dummy: 'dummy error' });
+
+        expect(service.submit()).toBeObservable(singleColdCompleted(createEmptyStateResource()));
+      });
     });
 
     it('should call do submit', () => {
-      service.submit();
+      service.submit().subscribe();
 
       expect(service._doSubmit).toHaveBeenCalled();
     });
@@ -228,61 +245,160 @@ describe('KeycloakFormService', () => {
       expect(submitResponse).toBeObservable(singleCold(dummyStateResource));
     });
 
-    describe('on keycloak error', () => {
+    describe('on server validation error', () => {
       const keycloakHttpError: KeycloakHttpErrorResponse = createKeycloakHttpErrorResponse();
 
       beforeEach(() => {
         service._doSubmit = jest.fn().mockReturnValue(throwError(() => keycloakHttpError));
-        service._handleUnknownKeycloakError = jest.fn();
       });
 
-      it('should set validation errors on controls', () => {
-        const invalidParams: InvalidParam[] = [createInvalidParam()];
-        service._mapKeycloakErrorRepresentationsToInvalidParams = jest.fn().mockReturnValue(invalidParams);
-
+      it('should process response validation errors', () => {
         service.submit().subscribe();
 
-        expect(service._setValidationErrorsOnControls).toHaveBeenCalledWith(invalidParams);
+        expect(service._processResponseValidationErrors).toHaveBeenCalledWith(keycloakHttpError);
       });
 
-      it('should map keycloak error representations to invalid params', () => {
-        const errorRepresentation: ErrorRepresentation = createErrorRepresentation();
-        extractErrorRepresentationsMock.mockReturnValue([errorRepresentation]);
+      it('should return processing response validation errors result', () => {
+        expect(service.submit()).toBeObservable(singleColdCompleted(createEmptyStateResource()));
+      });
+    });
+  });
 
-        service.submit().subscribe();
+  describe('process invalid form', () => {
+    beforeEach(() => {
+      service._showValidationErrorForAllInvalidControls = jest.fn();
+    });
 
-        expect(service._mapKeycloakErrorRepresentationsToInvalidParams).toHaveBeenCalledWith([errorRepresentation]);
-      });
+    it('should show validation errors on all invalid controls', () => {
+      service._processInvalidForm();
 
-      it('should extract error representations', () => {
-        service.submit().subscribe();
+      expect(service._showValidationErrorForAllInvalidControls).toHaveBeenCalledWith(service.form);
+    });
+
+    it('should return emit state resource', () => {
+      expect(service._processInvalidForm()).toBeObservable(singleColdCompleted(createEmptyStateResource()));
+    });
+  });
+
+  describe('process response validation errors', () => {
+    const keycloakHttpError: KeycloakHttpErrorResponse = createKeycloakHttpErrorResponse();
 
-        expect(extractErrorRepresentationsMock).toHaveBeenCalledWith(keycloakHttpError);
+    beforeEach(() => {
+      service._handleUnknownKeycloakError = jest.fn();
+      service._setValidationErrorsOnControls = jest.fn();
+      service._mapKeycloakErrorRepresentationsToInvalidParams = jest.fn();
+    });
+
+    it('should set validation errors on controls', () => {
+      const invalidParams: InvalidParam[] = [createInvalidParam()];
+      service._mapKeycloakErrorRepresentationsToInvalidParams = jest.fn().mockReturnValue(invalidParams);
+
+      service._processResponseValidationErrors(keycloakHttpError);
+
+      expect(service._setValidationErrorsOnControls).toHaveBeenCalledWith(invalidParams);
+    });
+
+    it('should map keycloak error representations to invalid params', () => {
+      const errorRepresentation: ErrorRepresentation = createErrorRepresentation();
+      extractErrorRepresentationsMock.mockReturnValue([errorRepresentation]);
+
+      service._processResponseValidationErrors(keycloakHttpError);
+
+      expect(service._mapKeycloakErrorRepresentationsToInvalidParams).toHaveBeenCalledWith([errorRepresentation]);
+    });
+
+    it('should extract error representations', () => {
+      service._processResponseValidationErrors(keycloakHttpError);
+
+      expect(extractErrorRepresentationsMock).toHaveBeenCalledWith(keycloakHttpError);
+    });
+
+    it('should emit empty state resource', () => {
+      expect(service._processResponseValidationErrors(keycloakHttpError)).toBeObservable(
+        singleColdCompleted(createEmptyStateResource()),
+      );
+    });
+
+    it('should handle unknown keycloak error', () => {
+      const error: Error = new Error('Fehler');
+      extractErrorRepresentationsMock.mockImplementation(() => {
+        throw error;
       });
 
-      it('should emit empty state resource', () => {
-        expect(service.submit()).toBeObservable(singleColdCompleted(createEmptyStateResource()));
+      service._processResponseValidationErrors(keycloakHttpError);
+
+      expect(service._handleUnknownKeycloakError).toHaveBeenCalledWith(keycloakHttpError);
+    });
+
+    it('should emit empty state resource exception', () => {
+      const error: Error = new Error('Fehler');
+      extractErrorRepresentationsMock.mockImplementation(() => {
+        throw error;
       });
 
-      it('should handle unknown keycloak error', () => {
-        const error: Error = new Error('Fehler');
-        extractErrorRepresentationsMock.mockImplementation(() => {
-          throw error;
-        });
+      expect(service._processResponseValidationErrors(keycloakHttpError)).toBeObservable(
+        singleColdCompleted(createEmptyStateResource()),
+      );
+    });
+  });
+
+  describe('show validation errors on all invalid controls', () => {
+    it('should update value and validity on invalid control', () => {
+      const control: AbstractControl = new FormControl();
+      control.setErrors({ dummy: 'error' });
+      const spy: jest.SpyInstance = jest.spyOn(control, 'updateValueAndValidity');
 
-        service.submit().subscribe();
+      service._showValidationErrorForAllInvalidControls(control);
 
-        expect(service._handleUnknownKeycloakError).toHaveBeenCalledWith(keycloakHttpError);
+      expect(spy).toHaveBeenCalled();
+    });
+
+    it('should update value and validity form invalid control from group', () => {
+      const control: AbstractControl = new FormControl();
+      control.setErrors({ dummy: 'error' });
+      const spy: jest.SpyInstance = jest.spyOn(service, '_showValidationErrorForAllInvalidControls');
+      const group: UntypedFormGroup = new FormGroup({
+        someControl: control,
       });
 
-      it('should emit empty state resource exception', () => {
-        const error: Error = new Error('Fehler');
-        extractErrorRepresentationsMock.mockImplementation(() => {
-          throw error;
-        });
+      service._showValidationErrorForAllInvalidControls(group);
 
-        expect(service.submit()).toBeObservable(singleColdCompleted(createEmptyStateResource()));
+      expect(spy).toHaveBeenCalledWith(control);
+    });
+
+    it('should update value and validity on invalid control from group', () => {
+      const control: AbstractControl = new FormControl();
+      control.setErrors({ dummy: 'error' });
+      const spy: jest.SpyInstance = jest.spyOn(control, 'updateValueAndValidity');
+      const group: UntypedFormGroup = new FormGroup({
+        someControl: control,
       });
+
+      service._showValidationErrorForAllInvalidControls(group);
+
+      expect(spy).toHaveBeenCalled();
+    });
+
+    it('should update value and validity form invalid control from array', () => {
+      const control: AbstractControl = new FormControl();
+      control.setErrors({ dummy: 'error' });
+      const spy: jest.SpyInstance = jest.spyOn(service, '_showValidationErrorForAllInvalidControls');
+      const array: FormArray = new FormArray([control]);
+
+      service._showValidationErrorForAllInvalidControls(array);
+
+      expect(spy).toHaveBeenCalledWith(control);
+    });
+
+    it('should update value and validity on invalid control from group', () => {
+      const control: AbstractControl = new FormControl();
+      control.setErrors({ dummy: 'error' });
+      const spy: jest.SpyInstance = jest.spyOn(control, 'updateValueAndValidity');
+      const array: FormArray = new FormArray([control]);
+
+      service._showValidationErrorForAllInvalidControls(array);
+
+      expect(spy).toHaveBeenCalled();
     });
   });
 
diff --git a/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-formservice.ts b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-formservice.ts
index 9fa4a9ed9d..a949187cc4 100644
--- a/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-formservice.ts
+++ b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-formservice.ts
@@ -23,7 +23,7 @@
  */
 import { createEmptyStateResource, InvalidParam, isLoaded, setInvalidParamValidationError, StateResource, } from '@alfa-client/tech-shared';
 import { inject, Injectable } from '@angular/core';
-import { FormBuilder, FormGroup } from '@angular/forms';
+import { AbstractControl, FormArray, FormBuilder, FormGroup } from '@angular/forms';
 import { ActivatedRoute, UrlSegment } from '@angular/router';
 import { catchError, first, Observable, of, tap } from 'rxjs';
 import { ValidationMessageCode } from '../../../../tech-shared/src/lib/validation/tech.validation.messages';
@@ -71,20 +71,38 @@ export abstract class KeycloakFormService<T> {
   }
 
   public submit(): Observable<StateResource<T>> {
+    if (this.form.invalid) {
+      return this._processInvalidForm();
+    }
     return this._doSubmit().pipe(
-      catchError((keycloakError: KeycloakHttpErrorResponse) => {
-        try {
-          this._setValidationErrorsOnControls(
-            this._mapKeycloakErrorRepresentationsToInvalidParams(extractErrorRepresentations(keycloakError)),
-          );
-        } catch (error: any) {
-          this._handleUnknownKeycloakError(keycloakError);
-        }
-        return of(createEmptyStateResource<T>());
-      }),
+      catchError((keycloakError: KeycloakHttpErrorResponse) => this._processResponseValidationErrors(keycloakError)),
     );
   }
 
+  _processInvalidForm(): Observable<StateResource<T>> {
+    this._showValidationErrorForAllInvalidControls(this.form);
+    return of(createEmptyStateResource<T>());
+  }
+
+  _processResponseValidationErrors(keycloakError: KeycloakHttpErrorResponse): Observable<StateResource<T>> {
+    try {
+      this._setValidationErrorsOnControls(
+        this._mapKeycloakErrorRepresentationsToInvalidParams(extractErrorRepresentations(keycloakError)),
+      );
+    } catch (error: any) {
+      console.error(error);
+      this._handleUnknownKeycloakError(keycloakError);
+    }
+    return of(createEmptyStateResource<T>());
+  }
+
+  _showValidationErrorForAllInvalidControls(control: AbstractControl): void {
+    if (control.invalid) control.updateValueAndValidity();
+    if (control instanceof FormGroup || control instanceof FormArray) {
+      Object.values(control.controls).forEach((control) => this._showValidationErrorForAllInvalidControls(control));
+    }
+  }
+
   _setValidationErrorsOnControls(invalidParams: InvalidParam[]): void {
     invalidParams.forEach((invalidParam: InvalidParam) => {
       setInvalidParamValidationError(this.form, invalidParam);
diff --git a/alfa-client/libs/admin/keycloak-shared/src/lib/user.repository.spec.ts b/alfa-client/libs/admin/keycloak-shared/src/lib/user.repository.spec.ts
index ee91561216..471464d347 100644
--- a/alfa-client/libs/admin/keycloak-shared/src/lib/user.repository.spec.ts
+++ b/alfa-client/libs/admin/keycloak-shared/src/lib/user.repository.spec.ts
@@ -34,7 +34,6 @@ import { RoleMappingPayload } from '@keycloak/keycloak-admin-client/lib/defs/rol
 import { Users } from '@keycloak/keycloak-admin-client/lib/resources/users';
 import { cold } from 'jest-marbles';
 import { omit, times } from 'lodash-es';
-import { throwError } from 'rxjs';
 import { createUser } from '../../../user-shared/test/user';
 import { UserFormService } from '../../../user/src/lib/user-form/user.formservice';
 
@@ -120,16 +119,6 @@ describe('UserRepository', () => {
         done();
       });
     });
-
-    it('should call handleError', fakeAsync(() => {
-      repository._handleSaveError = jest.fn();
-      kcAdminClient.users['update'] = jest.fn().mockReturnValue(throwError(() => new Error('error')));
-
-      repository.saveInKeycloak(user).subscribe({ error: () => {} });
-      tick();
-
-      expect(repository._handleSaveError).toHaveBeenCalled();
-    }));
   });
 
   describe('updateUserGroups', () => {
diff --git a/alfa-client/libs/admin/keycloak-shared/src/lib/user.repository.ts b/alfa-client/libs/admin/keycloak-shared/src/lib/user.repository.ts
index 4dd503aadc..cf471180b7 100644
--- a/alfa-client/libs/admin/keycloak-shared/src/lib/user.repository.ts
+++ b/alfa-client/libs/admin/keycloak-shared/src/lib/user.repository.ts
@@ -32,7 +32,7 @@ import { RoleMappingPayload } from '@keycloak/keycloak-admin-client/lib/defs/rol
 import UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation';
 import * as _ from 'lodash-es';
 import { isNil, omit } from 'lodash-es';
-import { catchError, concatMap, forkJoin, from, map, mergeMap, Observable, tap, throwError } from 'rxjs';
+import { concatMap, forkJoin, from, map, mergeMap, Observable, tap, throwError } from 'rxjs';
 
 @Injectable({
   providedIn: 'root',
@@ -62,7 +62,6 @@ export class UserRepository {
         await this._updateUserGroups(user.id, user.groupIds);
       }),
       map((): User => user),
-      catchError((err: Error): Observable<never> => this._handleSaveError(err)),
     );
   }
 
diff --git a/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.html b/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.html
index a8931fbcb6..ddf6dce385 100644
--- a/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.html
+++ b/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.html
@@ -1,4 +1,9 @@
-<h2 class="heading-2 mt-4">Rollen für OZG-Cloud</h2>
+<h2 class="heading-2 mt-4">Rollen für OZG-Cloud *</h2>
+<ods-validation-error
+  [invalidParams]="invalidParams$ | async"
+  label="Rollen"
+  data-test-id="rollen-error"
+></ods-validation-error>
 <div [formGroup]="formGroupParent">
   <div [formGroupName]="UserFormService.CLIENT_ROLES" class="mb-8 flex gap-56">
     <div [formGroupName]="UserFormService.ADMINISTRATION_GROUP" class="flex flex-col gap-2">
diff --git a/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.spec.ts b/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.spec.ts
index b27337b278..bc6c77336f 100644
--- a/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.spec.ts
+++ b/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.spec.ts
@@ -1,14 +1,24 @@
+import { InvalidParam } from '@alfa-client/tech-shared';
+import { existsAsHtmlElement, getElementComponentFromFixtureByCss } from '@alfa-client/test-utils';
 import { ComponentFixture, TestBed } from '@angular/core/testing';
-import { ReactiveFormsModule } from '@angular/forms';
+import { AbstractControl, FormControl, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms';
+import { expect } from '@jest/globals';
 import { InfoIconComponent, TooltipDirective } from '@ods/system';
 import { MockComponent, MockDirective } from 'ng-mocks';
+import { of } from 'rxjs';
+import { ValidationErrorComponent } from '../../../../../../design-component/src/lib/form/validation-error/validation-error.component';
+import { getDataTestIdOf } from '../../../../../../tech-shared/test/data-test';
+import { createInvalidParam } from '../../../../../../tech-shared/test/error';
 import { createUserFormGroup } from '../../../../test/form';
+import { UserFormService } from '../user.formservice';
 import { UserFormRolesComponent } from './user-form-roles.component';
 
 describe('UserFormRolesComponent', () => {
   let component: UserFormRolesComponent;
   let fixture: ComponentFixture<UserFormRolesComponent>;
 
+  const validationErrorTestId: string = getDataTestIdOf('rollen-error');
+
   beforeEach(async () => {
     await TestBed.configureTestingModule({
       imports: [UserFormRolesComponent, ReactiveFormsModule, MockComponent(InfoIconComponent), MockDirective(TooltipDirective)],
@@ -23,4 +33,56 @@ describe('UserFormRolesComponent', () => {
   it('should create', () => {
     expect(component).toBeTruthy();
   });
+
+  describe('component', () => {
+    describe('ngOnInit', () => {
+      it('should emit invalid params on invalid control', (done) => {
+        const invalidParam: InvalidParam = createInvalidParam();
+        const error: any = { dummy: invalidParam };
+        const control: AbstractControl = new FormControl();
+        component.formGroupParent = new UntypedFormGroup({ [UserFormService.CLIENT_ROLES]: control });
+
+        component.ngOnInit();
+        component.invalidParams$.subscribe((result) => {
+          expect(result).toEqual([invalidParam]);
+          done();
+        });
+        control.setErrors(error);
+      });
+
+      it('should emit empty array on valid control', (done) => {
+        const control: AbstractControl = new FormControl();
+        component.formGroupParent = new UntypedFormGroup({ [UserFormService.CLIENT_ROLES]: control });
+
+        component.ngOnInit();
+        component.invalidParams$.subscribe((result) => {
+          expect(result).toEqual([]);
+          done();
+        });
+
+        control.setErrors(null);
+      });
+    });
+  });
+
+  describe('template', () => {
+    describe('validation error', () => {
+      it('should exists', () => {
+        existsAsHtmlElement(fixture, validationErrorTestId);
+      });
+
+      it('should have inputs', () => {
+        const invalidParam: InvalidParam = createInvalidParam();
+        component.invalidParams$ = of([invalidParam]);
+
+        fixture.detectChanges();
+        const validationErrorComponent: ValidationErrorComponent = getElementComponentFromFixtureByCss(
+          fixture,
+          validationErrorTestId,
+        );
+
+        expect(validationErrorComponent.invalidParams).toEqual([invalidParam]);
+      });
+    });
+  });
 });
diff --git a/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.ts b/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.ts
index c7d46e948f..9c6608e1f5 100644
--- a/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.ts
+++ b/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.ts
@@ -1,17 +1,37 @@
-import { Component, Input } from '@angular/core';
-import { ReactiveFormsModule, UntypedFormGroup } from '@angular/forms';
+import { InvalidParam } from '@alfa-client/tech-shared';
+import { AsyncPipe } from '@angular/common';
+import { Component, Input, OnInit } from '@angular/core';
+import { AbstractControl, FormControlStatus, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms';
 import { CheckboxEditorComponent } from '@ods/component';
 import { InfoIconComponent, TooltipDirective } from '@ods/system';
+import { map, Observable, of } from 'rxjs';
+import { ValidationErrorComponent } from '../../../../../../design-component/src/lib/form/validation-error/validation-error.component';
 import { UserFormService } from '../user.formservice';
 
 @Component({
   selector: 'admin-user-form-roles',
   standalone: true,
-  imports: [CheckboxEditorComponent, ReactiveFormsModule, TooltipDirective, InfoIconComponent],
+  imports: [
+    CheckboxEditorComponent,
+    ReactiveFormsModule,
+    TooltipDirective,
+    InfoIconComponent,
+    ValidationErrorComponent,
+    AsyncPipe,
+  ],
   templateUrl: './user-form-roles.component.html',
 })
-export class UserFormRolesComponent {
+export class UserFormRolesComponent implements OnInit {
   @Input() formGroupParent: UntypedFormGroup;
 
+  public invalidParams$: Observable<InvalidParam[]> = of([]);
+
   public readonly UserFormService = UserFormService;
+
+  ngOnInit(): void {
+    const control: AbstractControl = this.formGroupParent.controls[UserFormService.CLIENT_ROLES];
+    this.invalidParams$ = control.statusChanges.pipe(
+      map((status: FormControlStatus) => (status === 'INVALID' ? Object.values(control.errors) : [])),
+    );
+  }
 }
diff --git a/alfa-client/libs/admin/user/src/lib/user-form/user.formservice.spec.ts b/alfa-client/libs/admin/user/src/lib/user-form/user.formservice.spec.ts
index d5340a9bfa..fe90cad9af 100644
--- a/alfa-client/libs/admin/user/src/lib/user-form/user.formservice.spec.ts
+++ b/alfa-client/libs/admin/user/src/lib/user-form/user.formservice.spec.ts
@@ -213,22 +213,6 @@ describe('UserFormService', () => {
     });
   });
 
-  describe('roleValidator', () => {
-    it('should return error if no role is selected', () => {
-      const result = service.roleValidator()(roleGroup);
-
-      expect(result).toEqual({ atLeastOneRoleSelected: true });
-    });
-
-    it('should return null if at least one role is selected', () => {
-      alfaGroup.get(UserFormService.LOESCHEN).setValue(true);
-
-      const result = service.roleValidator()(roleGroup);
-
-      expect(result).toBeNull();
-    });
-  });
-
   describe('handleAlfaGroupChange', () => {
     it('should call disableUncheckedCheckboxes if any checkbox is checked', () => {
       service._isAnyChecked = jest.fn().mockReturnValue(true);
@@ -318,7 +302,7 @@ describe('UserFormService', () => {
     it('should call createUser', () => {
       service._createUser = jest.fn();
 
-      service.submit();
+      service._doSubmit().subscribe();
 
       expect(service._createUser).toHaveBeenCalled();
     });
@@ -327,7 +311,7 @@ describe('UserFormService', () => {
       service._createUser = jest.fn().mockReturnValue(user);
       service._createOrSave = jest.fn().mockReturnValue(of(user));
 
-      service.submit();
+      service._doSubmit().subscribe();
 
       expect(service._createOrSave).toHaveBeenCalledWith(user);
     });
@@ -335,8 +319,7 @@ describe('UserFormService', () => {
     it('should call handleOnCreateUserSuccess if not loading', fakeAsync(() => {
       const handleOnCreateUserSuccessSpy: SpyInstance = jest.spyOn(service, 'handleOnCreateUserSuccess');
 
-      service.submit().subscribe();
-      tick();
+      service._doSubmit().subscribe();
 
       expect(handleOnCreateUserSuccessSpy).toHaveBeenCalled();
     }));
diff --git a/alfa-client/libs/admin/user/src/lib/user-form/user.formservice.ts b/alfa-client/libs/admin/user/src/lib/user-form/user.formservice.ts
index 140881cfe7..fb4974b4cb 100644
--- a/alfa-client/libs/admin/user/src/lib/user-form/user.formservice.ts
+++ b/alfa-client/libs/admin/user/src/lib/user-form/user.formservice.ts
@@ -24,15 +24,14 @@
 import { AdminOrganisationsEinheit, AdminOrganisationsEinheitService } from '@admin-client/organisations-einheit-shared';
 import { ROUTES } from '@admin-client/shared';
 import { User, UserService } from '@admin-client/user-shared';
-import { KeycloakFormService, KeycloakHttpErrorResponse, PatchConfig } from '@admin/keycloak-shared';
+import { KeycloakErrorMessage, KeycloakFieldName, KeycloakFormService, KeycloakHttpErrorResponse, PatchConfig, } from '@admin/keycloak-shared';
 import { NavigationService } from '@alfa-client/navigation-shared';
-import { EMPTY_STRING, isLoaded, mapToResource, StateResource } from '@alfa-client/tech-shared';
+import { checkBoxGroupsEmptyValidator, EMPTY_STRING, fieldEmptyValidator, fieldInvalidValidator, fieldLengthValidator, isLoaded, mapToResource, StateResource, } from '@alfa-client/tech-shared';
 import { SnackBarService } from '@alfa-client/ui';
 import { Injectable, OnDestroy } from '@angular/core';
-import { AbstractControl, FormControl, UntypedFormBuilder, UntypedFormGroup, ValidationErrors, ValidatorFn, Validators, } from '@angular/forms';
+import { AbstractControl, FormControl, UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
 import { UrlSegment } from '@angular/router';
 import { filter, Observable, Subscription, tap } from 'rxjs';
-import { KeycloakErrorMessage, KeycloakFieldName } from '../../../../keycloak-shared/src/lib/keycloak-error.model';
 
 @Injectable()
 export class UserFormService extends KeycloakFormService<User> implements OnDestroy {
@@ -86,10 +85,12 @@ export class UserFormService extends KeycloakFormService<User> implements OnDest
 
   _initForm(): UntypedFormGroup {
     return this.formBuilder.group({
-      [UserFormService.FIRST_NAME]: new FormControl(EMPTY_STRING, Validators.required),
-      [UserFormService.LAST_NAME]: new FormControl(EMPTY_STRING, Validators.required),
-      [UserFormService.USERNAME]: new FormControl(EMPTY_STRING, Validators.required),
-      [UserFormService.EMAIL]: new FormControl(EMPTY_STRING, [Validators.required]),
+      [UserFormService.FIRST_NAME]: new FormControl(EMPTY_STRING, fieldEmptyValidator(UserFormService.FIRST_NAME)),
+      [UserFormService.LAST_NAME]: new FormControl(EMPTY_STRING, fieldEmptyValidator(UserFormService.LAST_NAME)),
+      [UserFormService.USERNAME]: new FormControl(EMPTY_STRING, [fieldLengthValidator(UserFormService.USERNAME, 3, 255)]),
+      [UserFormService.EMAIL]: new FormControl(EMPTY_STRING, [
+        fieldInvalidValidator(UserFormService.EMAIL, /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/),
+      ]),
       [UserFormService.CLIENT_ROLES]: this.formBuilder.group(
         {
           [UserFormService.ADMINISTRATION_GROUP]: this.formBuilder.group({
@@ -102,33 +103,17 @@ export class UserFormService extends KeycloakFormService<User> implements OnDest
             [UserFormService.POSTSTELLE]: new FormControl(false),
           }),
         },
-        { validators: this.roleValidator() },
+        {
+          validators: checkBoxGroupsEmptyValidator(UserFormService.CLIENT_ROLES, [
+            UserFormService.ADMINISTRATION_GROUP,
+            UserFormService.ALFA_GROUP,
+          ]),
+        },
       ),
       [UserFormService.GROUPS]: this.formBuilder.group({}),
     });
   }
 
-  roleValidator(): ValidatorFn {
-    return (control: AbstractControl<UntypedFormGroup>): ValidationErrors | null => {
-      const rolesGroups: UntypedFormGroup = control.value;
-
-      if (this.anyRoleSelected(rolesGroups)) {
-        return null;
-      }
-
-      return { atLeastOneRoleSelected: true };
-    };
-  }
-
-  private anyRoleSelected(rolesGroups: UntypedFormGroup): boolean {
-    for (const subGroup of Object.values(rolesGroups)) {
-      if (Object.values(subGroup).includes(true)) {
-        return true;
-      }
-    }
-    return false;
-  }
-
   _initOrganisationsEinheiten(): Observable<AdminOrganisationsEinheit[]> {
     const organisationsEinheitenGroup: UntypedFormGroup = this.getOrganisationsEinheitenGroup();
     return this.adminOrganisationsEinheitService.getAll().pipe(
@@ -278,6 +263,8 @@ export class UserFormService extends KeycloakFormService<User> implements OnDest
   }
 
   _handleUnknownKeycloakError(error: KeycloakHttpErrorResponse): void {
-    this.snackBarService.showError('Der Benutzer konnte nicht angelegt werden.');
+    this.snackBarService.showError(
+      this.isPatch() ? 'Der Benutzer konnte nicht gespeichert werden.' : 'Der Benutzer konnte nicht angelegt werden.',
+    );
   }
 }
diff --git a/alfa-client/libs/design-component/src/lib/form/formcontrol-editor.abstract.component.ts b/alfa-client/libs/design-component/src/lib/form/formcontrol-editor.abstract.component.ts
index 0e77b51c10..89c0cd5fb1 100644
--- a/alfa-client/libs/design-component/src/lib/form/formcontrol-editor.abstract.component.ts
+++ b/alfa-client/libs/design-component/src/lib/form/formcontrol-editor.abstract.component.ts
@@ -64,12 +64,15 @@ export abstract class FormControlEditorAbstractComponent implements ControlValue
     this.fieldControl.setValue(text);
     this.setErrors();
   }
+
   registerOnChange(fn: (text: string | Date) => {}): void {
     this.onChange = fn;
   }
+
   registerOnTouched(fn: () => {}): void {
     this.onTouched = fn;
   }
+
   setDisabledState?(isDisabled: boolean): void {
     this.disabled = isDisabled;
   }
@@ -83,9 +86,9 @@ export abstract class FormControlEditorAbstractComponent implements ControlValue
     if (this.control) {
       this.fieldControl.setErrors(this.control.errors);
       if (this.control.invalid) {
-        this._updateInvalidParams();
         this.fieldControl.markAsTouched();
       }
+      this._updateInvalidParams();
     }
   }
 
diff --git a/alfa-client/libs/design-component/src/lib/form/validation-error/validation-error.component.ts b/alfa-client/libs/design-component/src/lib/form/validation-error/validation-error.component.ts
index 8ae6e949f8..8844b8075d 100644
--- a/alfa-client/libs/design-component/src/lib/form/validation-error/validation-error.component.ts
+++ b/alfa-client/libs/design-component/src/lib/form/validation-error/validation-error.component.ts
@@ -21,7 +21,7 @@
  * Die sprachspezifischen Genehmigungen und Beschränkungen
  * unter der Lizenz sind dem Lizenztext zu entnehmen.
  */
-import { InvalidParam, getMessageForInvalidParam } from '@alfa-client/tech-shared';
+import { getMessageForInvalidParam, InvalidParam } from '@alfa-client/tech-shared';
 import { CommonModule } from '@angular/common';
 import { Component, Input } from '@angular/core';
 import { ErrorMessageComponent } from '@ods/system';
diff --git a/alfa-client/libs/tech-shared/src/index.ts b/alfa-client/libs/tech-shared/src/index.ts
index be9ebae3ab..9220218eb9 100644
--- a/alfa-client/libs/tech-shared/src/index.ts
+++ b/alfa-client/libs/tech-shared/src/index.ts
@@ -62,3 +62,4 @@ export * from './lib/service/formservice.abstract';
 export * from './lib/tech.model';
 export * from './lib/tech.util';
 export * from './lib/validation/tech.validation.util';
+export * from './lib/validation/tech.validators';
diff --git a/alfa-client/libs/tech-shared/src/lib/validation/tech.validators.spec.ts b/alfa-client/libs/tech-shared/src/lib/validation/tech.validators.spec.ts
new file mode 100644
index 0000000000..00ef101f7d
--- /dev/null
+++ b/alfa-client/libs/tech-shared/src/lib/validation/tech.validators.spec.ts
@@ -0,0 +1,155 @@
+import {
+  checkBoxGroupsEmptyValidator,
+  fieldEmptyValidator,
+  fieldInvalidValidator,
+  fieldLengthValidator,
+  InvalidParam,
+} from '@alfa-client/tech-shared';
+import { AbstractControl, FormControl, UntypedFormGroup } from '@angular/forms';
+import { expect } from '@jest/globals';
+import { ValidationMessageCode } from './tech.validation.messages';
+
+describe('Tech Validators', () => {
+  describe('field empty validator', () => {
+    it('should return null', () => {
+      const control: AbstractControl = new FormControl('test');
+
+      expect(fieldEmptyValidator('')(control)).toBeNull();
+    });
+
+    it('should return invalid param', () => {
+      const control: AbstractControl = new FormControl(null);
+
+      expect(fieldEmptyValidator('')(control)).toEqual({
+        [ValidationMessageCode.FIELD_EMPTY]: {
+          name: '',
+          value: null,
+          reason: ValidationMessageCode.FIELD_EMPTY,
+          constraintParameters: [],
+        } as InvalidParam,
+      });
+    });
+  });
+
+  describe('field invalid validator', () => {
+    it('should return null', () => {
+      const control: AbstractControl = new FormControl('test');
+
+      expect(fieldInvalidValidator('', /^test$/)(control)).toBeNull();
+    });
+
+    it('should return invalid param', () => {
+      const control: AbstractControl = new FormControl('test2');
+
+      expect(fieldInvalidValidator('', /^test$/)(control)).toEqual({
+        [ValidationMessageCode.FIELD_INVALID]: {
+          name: '',
+          value: null,
+          reason: ValidationMessageCode.FIELD_INVALID,
+          constraintParameters: [],
+        } as InvalidParam,
+      });
+    });
+  });
+
+  describe('field length validator', () => {
+    it('should return null', () => {
+      const control: AbstractControl = new FormControl('test');
+
+      expect(fieldLengthValidator('', 1, 5)(control)).toBeNull();
+    });
+
+    it('should return invalid param with min length', () => {
+      const control: AbstractControl = new FormControl('t');
+
+      expect(fieldLengthValidator('', 2, 5)(control)).toEqual({
+        [ValidationMessageCode.FIELD_SIZE]: {
+          name: '',
+          value: null,
+          reason: ValidationMessageCode.FIELD_SIZE,
+          constraintParameters: [
+            { name: 'min', value: '2' },
+            { name: 'max', value: '5' },
+          ],
+        } as InvalidParam,
+      });
+    });
+
+    it('should return invalid param with max length', () => {
+      const control: AbstractControl = new FormControl('test test test');
+
+      expect(fieldLengthValidator('', 2, 5)(control)).toEqual({
+        [ValidationMessageCode.FIELD_SIZE]: {
+          name: '',
+          value: null,
+          reason: ValidationMessageCode.FIELD_SIZE,
+          constraintParameters: [
+            { name: 'min', value: '2' },
+            { name: 'max', value: '5' },
+          ],
+        } as InvalidParam,
+      });
+    });
+  });
+
+  describe('check box group empty validator', () => {
+    it('should return null', () => {
+      const control: UntypedFormGroup = new UntypedFormGroup({
+        group1: new UntypedFormGroup({
+          check1: new FormControl(true),
+          check2: new FormControl(false),
+        }),
+        group2: new UntypedFormGroup({
+          check1: new FormControl(false),
+          check2: new FormControl(false),
+        }),
+      });
+
+      expect(checkBoxGroupsEmptyValidator('', ['group1', 'group2'])(control)).toBeNull();
+    });
+
+    it('should return invalid param', () => {
+      const control: UntypedFormGroup = new UntypedFormGroup({
+        group1: new UntypedFormGroup({
+          check1: new FormControl(false),
+          check2: new FormControl(false),
+        }),
+        group2: new UntypedFormGroup({
+          check1: new FormControl(false),
+          check2: new FormControl(false),
+        }),
+      });
+
+      expect(checkBoxGroupsEmptyValidator('', ['group1', 'group2'])(control)).toEqual({
+        [ValidationMessageCode.FIELD_EMPTY]: {
+          name: '',
+          value: null,
+          reason: ValidationMessageCode.FIELD_EMPTY,
+          constraintParameters: [],
+        } as InvalidParam,
+      });
+    });
+
+    it('should return invalid param and ignore group', () => {
+      const control: UntypedFormGroup = new UntypedFormGroup({
+        group1: new UntypedFormGroup({
+          check1: new FormControl(false),
+          check2: new FormControl(false),
+        }),
+        group2: new UntypedFormGroup({
+          check1: new FormControl(true),
+          check2: new FormControl(false),
+        }),
+      });
+
+      expect(checkBoxGroupsEmptyValidator('', ['group1'])(control)).toEqual({
+        [ValidationMessageCode.FIELD_EMPTY]: {
+          name: '',
+          value: null,
+          reason: ValidationMessageCode.FIELD_EMPTY,
+          constraintParameters: [],
+        } as InvalidParam,
+      });
+    });
+  });
+});
diff --git a/alfa-client/libs/tech-shared/src/lib/validation/tech.validators.ts b/alfa-client/libs/tech-shared/src/lib/validation/tech.validators.ts
new file mode 100644
index 0000000000..798a98d8eb
--- /dev/null
+++ b/alfa-client/libs/tech-shared/src/lib/validation/tech.validators.ts
@@ -0,0 +1,81 @@
+import { InvalidParam, isNotEmpty, isNotNil } from '@alfa-client/tech-shared';
+import { AbstractControl, UntypedFormGroup, ValidationErrors, ValidatorFn } from '@angular/forms';
+import { ValidationMessageCode } from './tech.validation.messages';
+
+export function fieldEmptyValidator(controlName: string): ValidatorFn {
+  return (control: AbstractControl): ValidationErrors | null => {
+    if (isNotEmpty(control.value)) return null;
+    return {
+      [ValidationMessageCode.FIELD_EMPTY]: {
+        name: controlName,
+        value: null,
+        reason: ValidationMessageCode.FIELD_EMPTY,
+        constraintParameters: [],
+      } as InvalidParam,
+    };
+  };
+}
+
+export function fieldInvalidValidator(controlName: string, regex: RegExp): ValidatorFn {
+  return (control: AbstractControl): ValidationErrors | null => {
+    if (regex.test(control.value)) return null;
+    return {
+      [ValidationMessageCode.FIELD_INVALID]: {
+        name: controlName,
+        value: null,
+        reason: ValidationMessageCode.FIELD_INVALID,
+        constraintParameters: [],
+      } as InvalidParam,
+    };
+  };
+}
+
+export function fieldLengthValidator(controlName: string, min: number, max: number): ValidatorFn {
+  return (control: AbstractControl): ValidationErrors | null => {
+    const value: string = control.value;
+    if (isNotEmpty(control.value) && value.length >= min && value.length <= max) return null;
+    return {
+      [ValidationMessageCode.FIELD_SIZE]: {
+        name: controlName,
+        value: null,
+        reason: ValidationMessageCode.FIELD_SIZE,
+        constraintParameters: [
+          { name: 'min', value: min.toString() },
+          { name: 'max', value: max.toString() },
+        ],
+      } as InvalidParam,
+    };
+  };
+}
+
+/**
+ * A simplified version of validation subgroups of check boxes. Could be extended for more advanced forms.
+ * It only looks up direct children of check box groups.
+ */
+export function checkBoxGroupsEmptyValidator(controlName: string, groupNames: string[]): ValidatorFn {
+  return (control: AbstractControl<UntypedFormGroup>): ValidationErrors | null => {
+    const group: UntypedFormGroup = control as UntypedFormGroup;
+    const found: boolean = groupNames
+      .filter((groupName: string) => _existsUntypedFormSubGroup(group, groupName))
+      .map((groupName: string) => group.controls[groupName] as UntypedFormGroup)
+      .map(_isAtLeastOneChecked)
+      .reduce((a: boolean, b: boolean) => a || b);
+    if (found) return null;
+    return {
+      [ValidationMessageCode.FIELD_EMPTY]: {
+        name: controlName,
+        value: null,
+        reason: ValidationMessageCode.FIELD_EMPTY,
+        constraintParameters: [],
+      } as InvalidParam,
+    };
+  };
+}
+
+export function _existsUntypedFormSubGroup(group: UntypedFormGroup, subGroupName: string): boolean {
+  return isNotNil(group.controls[subGroupName]) && group.controls[subGroupName] instanceof UntypedFormGroup;
+}
+
+export function _isAtLeastOneChecked(group: UntypedFormGroup): boolean {
+  return Object.values(group.value).findIndex((isChecked: boolean) => isChecked) > -1;
+}
-- 
GitLab


From 7a5cf8d076f1fceded38742aebae1cf182cfb385 Mon Sep 17 00:00:00 2001
From: sebo <sebastian.bergandy@external.mgm-cp.com>
Date: Sun, 9 Mar 2025 20:33:11 +0100
Subject: [PATCH 04/22] OZG-7591 add data test ids for children

Sub task: OZG-7805
---
 .../user-form-data/user-form-data.component.html          | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/alfa-client/libs/admin/user/src/lib/user-form/user-form-data/user-form-data.component.html b/alfa-client/libs/admin/user/src/lib/user-form/user-form-data/user-form-data.component.html
index 430a3fde77..97ad637c9a 100644
--- a/alfa-client/libs/admin/user/src/lib/user-form/user-form-data/user-form-data.component.html
+++ b/alfa-client/libs/admin/user/src/lib/user-form/user-form-data/user-form-data.component.html
@@ -1,8 +1,8 @@
 <div [formGroup]="formGroupParent">
   <div class="mb-4 grid gap-4 xl:grid-cols-2">
-    <ods-text-editor [formControlName]="UserFormService.FIRST_NAME" [isRequired]="true" label="Vorname" />
-    <ods-text-editor [formControlName]="UserFormService.LAST_NAME" [isRequired]="true" label="Nachname" />
-    <ods-text-editor [formControlName]="UserFormService.USERNAME" [isRequired]="true" label="Benutzername" />
-    <ods-text-editor [formControlName]="UserFormService.EMAIL" [isRequired]="true" label="E-Mail" />
+    <ods-text-editor [formControlName]="UserFormService.FIRST_NAME" [isRequired]="true" label="Vorname" dataTestId="firstName" />
+    <ods-text-editor [formControlName]="UserFormService.LAST_NAME" [isRequired]="true" label="Nachname" dataTestId="lastName"/>
+    <ods-text-editor [formControlName]="UserFormService.USERNAME" [isRequired]="true" label="Benutzername" dataTestId="username"/>
+    <ods-text-editor [formControlName]="UserFormService.EMAIL" [isRequired]="true" label="E-Mail" dataTestId="email"/>
   </div>
 </div>
-- 
GitLab


From 3363163e6f45c003e1dec0ca9447e8236cb9e64d Mon Sep 17 00:00:00 2001
From: sebo <sebastian.bergandy@external.mgm-cp.com>
Date: Sun, 9 Mar 2025 20:33:58 +0100
Subject: [PATCH 05/22] OZG-7591 refresh list resource on error

Fixes issue with not loaded user list after an error occurs.

Sub task: OZG-7805
---
 .../src/lib/keycloak.resource.service.spec.ts | 26 ++++++++++++++-----
 .../src/lib/keycloak.resource.service.ts      |  6 ++++-
 2 files changed, 25 insertions(+), 7 deletions(-)

diff --git a/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak.resource.service.spec.ts b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak.resource.service.spec.ts
index 5a91a0813f..329afd1ac1 100644
--- a/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak.resource.service.spec.ts
+++ b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak.resource.service.spec.ts
@@ -21,17 +21,16 @@
  * Die sprachspezifischen Genehmigungen und Beschränkungen
  * unter der Lizenz sind dem Lizenztext zu entnehmen.
  */
-import { TestBed, fakeAsync, tick } from '@angular/core/testing';
+import { fakeAsync, TestBed, tick } from '@angular/core/testing';
 import { faker } from '@faker-js/faker';
 import { cold } from 'jest-marbles';
-import { StateResource, createEmptyStateResource, createStateResource } from 'libs/tech-shared/src/lib/resource/resource.util';
-import { Dummy, createDummy } from 'libs/tech-shared/test/dummy';
+import * as ResourceUtil from 'libs/tech-shared/src/lib/resource/resource.util';
+import { createEmptyStateResource, createStateResource, StateResource } from 'libs/tech-shared/src/lib/resource/resource.util';
+import { createDummy, Dummy } from 'libs/tech-shared/test/dummy';
 import { singleCold } from 'libs/tech-shared/test/marbles';
-import { Observable, of } from 'rxjs';
+import { Observable, of, throwError } from 'rxjs';
 import { KeycloakResourceService } from './keycloak.resource.service';
 
-import * as ResourceUtil from 'libs/tech-shared/src/lib/resource/resource.util';
-
 describe('KeycloakResourceService', () => {
   let service: KeycloakResourceService<unknown>;
 
@@ -200,6 +199,21 @@ describe('KeycloakResourceService', () => {
 
       expect(service.refresh).toHaveBeenCalled();
     }));
+
+    it('should call refresh on error', () => {
+      service.refresh = jest.fn();
+
+      service.refreshAfterFirstEmit(throwError(() => new Error('dummy'))).subscribe();
+
+      expect(service.refresh).toHaveBeenCalled();
+    });
+
+    it('should rethrow error', () => {
+      const error = new Error('dummy');
+      service.refresh = jest.fn();
+
+      expect(service.refreshAfterFirstEmit(throwError(() => error))).toBeObservable(cold('#', null, error));
+    });
   });
 
   describe('setLoadingInStateResource', () => {
diff --git a/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak.resource.service.ts b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak.resource.service.ts
index ae74c73ee2..8bd44d4254 100644
--- a/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak.resource.service.ts
+++ b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak.resource.service.ts
@@ -22,7 +22,7 @@
  * unter der Lizenz sind dem Lizenztext zu entnehmen.
  */
 import { createEmptyStateResource, createStateResource, doIfLoadingRequired, StateResource } from '@alfa-client/tech-shared';
-import { BehaviorSubject, first, map, Observable, startWith, switchMap, tap } from 'rxjs';
+import { BehaviorSubject, catchError, first, map, Observable, startWith, switchMap, tap, throwError } from 'rxjs';
 
 export abstract class KeycloakResourceService<T> {
   readonly stateResource: BehaviorSubject<StateResource<T[]>> = new BehaviorSubject(createEmptyStateResource());
@@ -78,6 +78,10 @@ export abstract class KeycloakResourceService<T> {
     return action.pipe(
       first(),
       tap((): void => this.refresh()),
+      catchError((err) => {
+        this.refresh();
+        return throwError(() => err);
+      }),
     );
   }
 
-- 
GitLab


From 1f7523b8e89122018411e69ff433278672bcaa2f Mon Sep 17 00:00:00 2001
From: sebo <sebastian.bergandy@external.mgm-cp.com>
Date: Sun, 9 Mar 2025 20:34:28 +0100
Subject: [PATCH 06/22] OZG-7591 add e2e tests for validation

Sub task: OZG-7805
---
 .../benutzer/benutzer.e2e.component.ts        | 43 ++++++++-
 .../benutzer_rollen/benutzer-anlegen.cy.ts    | 93 +++++++++++++++++++
 .../src/helper/benutzer/benutzer.executor.ts  | 20 ++++
 .../src/helper/benutzer/benutzer.helper.ts    | 20 ++++
 .../src/helper/benutzer/benutzer.verifier.ts  | 57 +++++++++++-
 5 files changed, 228 insertions(+), 5 deletions(-)

diff --git a/alfa-client/apps/admin-e2e/src/components/benutzer/benutzer.e2e.component.ts b/alfa-client/apps/admin-e2e/src/components/benutzer/benutzer.e2e.component.ts
index 673ea2c475..2d2fdbc886 100644
--- a/alfa-client/apps/admin-e2e/src/components/benutzer/benutzer.e2e.component.ts
+++ b/alfa-client/apps/admin-e2e/src/components/benutzer/benutzer.e2e.component.ts
@@ -93,12 +93,21 @@ export class BenutzerListItemE2EComponent {
 }
 
 export class BenutzerE2EComponent {
+  public static readonly VORNAME_EMPTY_ERROR: string = 'Bitte Vorname ausfüllen';
+  public static readonly NACHNAME_EMPTY_ERROR: string = 'Bitte Nachname ausfüllen';
+  public static readonly BENUTZERNAME_SIZE_ERROR: string =
+    'Benutzername muss mindestens 3 und darf höchstens 255 Zeichen enthalten';
+  public static readonly EMAIL_INVALID_ERROR: string = 'Bitte E-Mail korrekt ausfüllen';
+  public static readonly ROLLEN_EMPTY_ERROR: string = 'Bitte Rollen ausfüllen';
+  public static readonly BENUTZER_NAME_EXISTS: string = 'Benutzername bereits verwendet';
+  public static readonly EMAIL_EXISTS: string = 'Email-Adresse bereits verwendet';
+
   private readonly headline: string = 'benutzer-form-headline';
 
-  private readonly userVorname: string = 'Vorname-text-input';
-  private readonly userNachname: string = 'Nachname-text-input';
-  private readonly userBenutzername: string = 'Benutzername-text-input';
-  private readonly userMail: string = 'E-Mail-text-input';
+  private readonly userVorname: string = 'firstName-text-input';
+  private readonly userNachname: string = 'lastName-text-input';
+  private readonly userBenutzername: string = 'username-text-input';
+  private readonly userMail: string = 'email-text-input';
 
   private readonly adminCheckboxLabel: string = 'Admin';
   private readonly loeschenCheckboxLabel: string = 'Löschen';
@@ -111,6 +120,12 @@ export class BenutzerE2EComponent {
   private readonly saveButton: string = 'save-button';
   private readonly deleteButton: string = 'delete-button';
 
+  private readonly firstNameValidationError: string = 'firstName-text-editor-error';
+  private readonly lastNameValidationError: string = 'lastName-text-editor-error';
+  private readonly usernameValidationError: string = 'username-text-editor-error';
+  private readonly emailValidationError: string = 'email-text-editor-error';
+  private readonly rollenValidationError: string = 'rollen-error';
+
   public getHeadline(): Cypress.Chainable<Element> {
     return cy.getTestElement(this.headline);
   }
@@ -166,6 +181,26 @@ export class BenutzerE2EComponent {
   public getDeleteButton(): Cypress.Chainable<Element> {
     return cy.getTestElement(this.deleteButton);
   }
+
+  public getFirstNameValidationError(): Cypress.Chainable<Element> {
+    return cy.getTestElement(this.firstNameValidationError);
+  }
+
+  public getLastNameValidationError(): Cypress.Chainable<Element> {
+    return cy.getTestElement(this.lastNameValidationError);
+  }
+
+  public getUsernameValidationError(): Cypress.Chainable<Element> {
+    return cy.getTestElement(this.usernameValidationError);
+  }
+
+  public getEmailValidationError(): Cypress.Chainable<Element> {
+    return cy.getTestElement(this.emailValidationError);
+  }
+
+  public getRollenValidationError(): Cypress.Chainable<Element> {
+    return cy.getTestElement(this.rollenValidationError);
+  }
 }
 
 export class BenutzerDeleteDialogE2EComponent {
diff --git a/alfa-client/apps/admin-e2e/src/e2e/main-tests/benutzer_rollen/benutzer-anlegen.cy.ts b/alfa-client/apps/admin-e2e/src/e2e/main-tests/benutzer_rollen/benutzer-anlegen.cy.ts
index e2cbf35c6a..d5b90ab701 100644
--- a/alfa-client/apps/admin-e2e/src/e2e/main-tests/benutzer_rollen/benutzer-anlegen.cy.ts
+++ b/alfa-client/apps/admin-e2e/src/e2e/main-tests/benutzer_rollen/benutzer-anlegen.cy.ts
@@ -1,3 +1,4 @@
+import { faker } from '@faker-js/faker';
 import { E2EBenutzerHelper } from 'apps/admin-e2e/src/helper/benutzer/benutzer.helper';
 import { E2EBenutzerVerifier } from 'apps/admin-e2e/src/helper/benutzer/benutzer.verifier';
 import { getCypressEnv, interceptWithResponse, waitOfInterceptor } from 'apps/admin-e2e/src/support/cypress-helper';
@@ -62,4 +63,96 @@ describe('Benutzer anlegen', () => {
     benutzerHelper.deleteBenutzer(newUser.username);
     benutzerVerifier.verifyUserNotInList(newUser.username);
   });
+
+  describe('client validation errors', () => {
+    it('should show all if empty form', () => {
+      benutzerHelper.openNewBenutzerPage();
+      benutzerHelper.saveBenutzer();
+
+      benutzerVerifier.verifyMissingVornameError();
+      benutzerVerifier.verifyLastNameError();
+      benutzerVerifier.verifyUsernameSizeError();
+      benutzerVerifier.verifyEmailInvalidError();
+      benutzerVerifier.verifyRollenError();
+    });
+
+    it('should hide Vorname validation error', () => {
+      benutzerHelper.editVorname('Max');
+      benutzerVerifier.verifyNoFirstNameValidationError();
+      benutzerVerifier.verifyLastNameError();
+      benutzerVerifier.verifyUsernameSizeError();
+      benutzerVerifier.verifyEmailInvalidError();
+      benutzerVerifier.verifyRollenError();
+    });
+
+    it('should hide Nachname validation error', () => {
+      benutzerHelper.editNachname('Mustermann');
+      benutzerVerifier.verifyNoFirstNameValidationError();
+      benutzerVerifier.verifyNoLastNameValidationError();
+      benutzerVerifier.verifyUsernameSizeError();
+      benutzerVerifier.verifyEmailInvalidError();
+      benutzerVerifier.verifyRollenError();
+    });
+
+    it('should hide Benutzername validation error', () => {
+      benutzerHelper.editBenutzername('Max');
+      benutzerVerifier.verifyNoFirstNameValidationError();
+      benutzerVerifier.verifyNoLastNameValidationError();
+      benutzerVerifier.verifyNoUsernameSizeValidationError();
+      benutzerVerifier.verifyEmailInvalidError();
+      benutzerVerifier.verifyRollenError();
+    });
+
+    it('should hide Email validation error', () => {
+      benutzerHelper.editEmail('max@max.local');
+      benutzerVerifier.verifyNoFirstNameValidationError();
+      benutzerVerifier.verifyNoLastNameValidationError();
+      benutzerVerifier.verifyNoUsernameSizeValidationError();
+      benutzerVerifier.verifyNoEmailInvalidValidationError();
+      benutzerVerifier.verifyRollenError();
+    });
+
+    it('should hide Rollen validation error', () => {
+      benutzerHelper.addUserRole();
+      benutzerVerifier.verifyNoFirstNameValidationError();
+      benutzerVerifier.verifyNoLastNameValidationError();
+      benutzerVerifier.verifyNoUsernameSizeValidationError();
+      benutzerVerifier.verifyNoEmailInvalidValidationError();
+      benutzerVerifier.verifyNoRollenValidationError();
+    });
+  });
+
+  describe('server validation errors', () => {
+    it('should create user', () => {
+      benutzerHelper.openNewBenutzerPage();
+
+      benutzerHelper.addBenutzer(newUser);
+      benutzerHelper.saveBenutzer();
+
+      benutzerVerifier.verifyUserInList(newUser);
+    });
+
+    it('should show validation error on existing email', () => {
+      benutzerHelper.openNewBenutzerPage();
+      benutzerHelper.editBenutzer(newUser);
+      benutzerHelper.saveBenutzer();
+
+      benutzerVerifier.verifyEmailExistsError();
+    });
+
+    it('should show validation error on existing username', () => {
+      benutzerHelper.openNewBenutzerPage();
+      benutzerHelper.editBenutzer({ ...newUser, email: faker.internet.email() + '.local' });
+      benutzerHelper.saveBenutzer();
+
+      benutzerVerifier.verifyUserExistsError();
+    });
+
+    it('should remove benutzer', () => {
+      benutzerHelper.openBenutzerPage(newUser.username);
+      benutzerHelper.deleteBenutzer(newUser.username);
+
+      benutzerVerifier.verifyUserNotInList(newUser.username);
+    });
+  });
 });
diff --git a/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.executor.ts b/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.executor.ts
index f0ed339c63..03638bd1b1 100644
--- a/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.executor.ts
+++ b/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.executor.ts
@@ -36,6 +36,26 @@ export class E2EBenutzerExecutor {
     this.modifyOrganisationsEinheiten(user.organisationseinheiten);
   }
 
+  public modifyVorname(vorname: string): void {
+    this.benutzerPage.getVornameInput().type(vorname);
+  }
+
+  public modifyNachname(nachname: string): void {
+    this.benutzerPage.getNachnameInput().type(nachname);
+  }
+
+  public modifyBenutzername(benutzername: string): void {
+    this.benutzerPage.getBenutzernameInput().type(benutzername);
+  }
+
+  public modifyEmail(email: string): void {
+    this.benutzerPage.getMailInput().type(email);
+  }
+
+  public checkUserRole(): void {
+    this.benutzerPage.getUserCheckbox().getRoot().click();
+  }
+
   public modifyOrganisationsEinheiten(organisationseinheiten: OrganisationsEinheitE2E[]): void {
     organisationseinheiten.forEach((name: string) => this.benutzerPage.getOrganisationsEinheitCheckbox(name).click());
   }
diff --git a/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.helper.ts b/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.helper.ts
index 2b06766322..ae7758ac0a 100644
--- a/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.helper.ts
+++ b/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.helper.ts
@@ -33,6 +33,26 @@ export class E2EBenutzerHelper {
     this.modifyBenutzer(user);
   }
 
+  public editVorname(vorname: string): void {
+    this.executer.modifyVorname(vorname);
+  }
+
+  public editNachname(nachname: string): void {
+    this.executer.modifyNachname(nachname);
+  }
+
+  public editBenutzername(username: string): void {
+    this.executer.modifyBenutzername(username);
+  }
+
+  public editEmail(email: string): void {
+    this.executer.modifyEmail(email);
+  }
+
+  public addUserRole(): void {
+    this.executer.checkUserRole();
+  }
+
   private modifyBenutzer(user: AdminUserE2E): void {
     this.executer.modifyBenutzer(user);
   }
diff --git a/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.verifier.ts b/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.verifier.ts
index be750e3dfb..b1e61a5344 100644
--- a/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.verifier.ts
+++ b/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.verifier.ts
@@ -4,7 +4,7 @@ import {
   BenutzerListItemE2EComponent,
 } from '../../components/benutzer/benutzer.e2e.component';
 import { AdminUserE2E } from '../../model/util';
-import { contains, exist, notExist } from '../../support/cypress.util';
+import { contains, exist, notContains, notExist } from '../../support/cypress.util';
 import { AlfaRollen } from '../../support/user-util';
 
 export class E2EBenutzerVerifier {
@@ -48,4 +48,59 @@ export class E2EBenutzerVerifier {
   private getBenutzerItem(userName: string): BenutzerListItemE2EComponent {
     return this.benutzerListPage.getItem(userName);
   }
+
+  public verifyMissingVornameError(): void {
+    exist(this.benutzerPage.getFirstNameValidationError());
+    contains(this.benutzerPage.getFirstNameValidationError(), BenutzerE2EComponent.VORNAME_EMPTY_ERROR);
+  }
+
+  public verifyLastNameError(): void {
+    exist(this.benutzerPage.getLastNameValidationError());
+    contains(this.benutzerPage.getLastNameValidationError(), BenutzerE2EComponent.NACHNAME_EMPTY_ERROR);
+  }
+
+  public verifyUsernameSizeError(): void {
+    exist(this.benutzerPage.getUsernameValidationError());
+    contains(this.benutzerPage.getUsernameValidationError(), BenutzerE2EComponent.BENUTZERNAME_SIZE_ERROR);
+  }
+
+  public verifyUserExistsError(): void {
+    exist(this.benutzerPage.getUsernameValidationError());
+    contains(this.benutzerPage.getUsernameValidationError(), BenutzerE2EComponent.BENUTZER_NAME_EXISTS);
+  }
+
+  public verifyEmailInvalidError(): void {
+    exist(this.benutzerPage.getEmailValidationError());
+    contains(this.benutzerPage.getEmailValidationError(), BenutzerE2EComponent.EMAIL_INVALID_ERROR);
+  }
+
+  public verifyEmailExistsError(): void {
+    exist(this.benutzerPage.getEmailValidationError());
+    contains(this.benutzerPage.getEmailValidationError(), BenutzerE2EComponent.EMAIL_EXISTS);
+  }
+
+  public verifyRollenError(): void {
+    exist(this.benutzerPage.getRollenValidationError());
+    contains(this.benutzerPage.getRollenValidationError(), BenutzerE2EComponent.ROLLEN_EMPTY_ERROR);
+  }
+
+  public verifyNoFirstNameValidationError(): void {
+    notContains(this.benutzerPage.getFirstNameValidationError(), BenutzerE2EComponent.VORNAME_EMPTY_ERROR);
+  }
+
+  public verifyNoLastNameValidationError(): void {
+    notContains(this.benutzerPage.getLastNameValidationError(), BenutzerE2EComponent.NACHNAME_EMPTY_ERROR);
+  }
+
+  public verifyNoUsernameSizeValidationError(): void {
+    notContains(this.benutzerPage.getUsernameValidationError(), BenutzerE2EComponent.BENUTZERNAME_SIZE_ERROR);
+  }
+
+  public verifyNoEmailInvalidValidationError(): void {
+    notContains(this.benutzerPage.getEmailValidationError(), BenutzerE2EComponent.EMAIL_INVALID_ERROR);
+  }
+
+  public verifyNoRollenValidationError(): void {
+    notContains(this.benutzerPage.getRollenValidationError(), BenutzerE2EComponent.ROLLEN_EMPTY_ERROR);
+  }
 }
-- 
GitLab


From 403539dd44a7c6c3a993d102314a33e783e64ab3 Mon Sep 17 00:00:00 2001
From: sebo <sebastian.bergandy@external.mgm-cp.com>
Date: Thu, 13 Mar 2025 17:39:55 +0100
Subject: [PATCH 07/22] OZG-7591 move code from model to util

CR
---
 .../src/lib/keycloak-error.model.ts           | 37 +----------------
 ...el.spec.ts => keycloak-error.util.spec.ts} | 10 +++--
 .../src/lib/keycloak-error.util.ts            | 40 +++++++++++++++++++
 .../src/lib/keycloak-formservice.spec.ts      |  7 ++--
 .../src/lib/keycloak-formservice.ts           |  3 +-
 5 files changed, 53 insertions(+), 44 deletions(-)
 rename alfa-client/libs/admin/keycloak-shared/src/lib/{keycloak-error.model.spec.ts => keycloak-error.util.spec.ts} (99%)
 create mode 100644 alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error.util.ts

diff --git a/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error.model.ts b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error.model.ts
index 356ed67c26..87bdce2f2d 100644
--- a/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error.model.ts
+++ b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error.model.ts
@@ -1,7 +1,4 @@
-import { isNotNil } from '@alfa-client/tech-shared';
-import { isNil } from 'lodash-es';
-
-const validationErrorStatusCodes: number[] = [400, 409];
+export const KEYCLOAK_VALIDATION_ERROR_STATUS_CODES: number[] = [400, 409];
 
 export enum KeycloakFieldName {
   USERNAME = 'username',
@@ -32,35 +29,3 @@ export interface KeycloakHttpErrorResponse {
 export interface KeycloakErrorResponseData extends ErrorRepresentation {
   errors?: ErrorRepresentation[];
 }
-
-export function extractErrorRepresentations(error: KeycloakHttpErrorResponse): ErrorRepresentation[] {
-  if (!canHandleKeycloakError(error)) {
-    throw new Error(`Cannot handle keycloak error: ${error}`);
-  }
-  const errorResponseData: KeycloakErrorResponseData = error.responseData;
-  return error.responseData.errors ?? [errorResponseData];
-}
-
-export function canHandleKeycloakError(err: KeycloakHttpErrorResponse): boolean {
-  if (!validationErrorStatusCodes.includes(err?.response?.status)) return false;
-  if (isNil(err?.responseData)) return false;
-
-  if (containsErrorMessage(err)) return true;
-  return containsErrorArray(err) && isNotNil(err.responseData.errors[0]?.errorMessage);
-}
-
-function containsErrorMessage(err: any): boolean {
-  return isNotNil(err?.responseData?.errorMessage);
-}
-
-function containsErrorArray(err: any): boolean {
-  return isNotNil(err?.responseData?.errors) && err.responseData.errors.length > 0;
-}
-
-export function isSingleErrorMessage(err: KeycloakErrorResponseData): boolean {
-  return isNotNil(err.errorMessage) && isNil(err.field) && isNil(err.params) && isNil(err.errors);
-}
-
-export function isFieldErrorMessage(err: KeycloakErrorResponseData): boolean {
-  return isNotNil(err.errorMessage) && isNotNil(err.field) && isNotNil(err.params) && isNil(err.errors);
-}
diff --git a/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error.model.spec.ts b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error.util.spec.ts
similarity index 99%
rename from alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error.model.spec.ts
rename to alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error.util.spec.ts
index 9c0d629e95..29c1aea25a 100644
--- a/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error.model.spec.ts
+++ b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error.util.spec.ts
@@ -1,15 +1,17 @@
 import { describe, expect, it } from '@jest/globals';
 import {
-  canHandleKeycloakError,
   ErrorRepresentation,
-  extractErrorRepresentations,
-  isFieldErrorMessage,
-  isSingleErrorMessage,
   KeycloakErrorMessage,
   KeycloakErrorResponseData,
   KeycloakFieldName,
   KeycloakHttpErrorResponse,
 } from './keycloak-error.model';
+import {
+  canHandleKeycloakError,
+  extractErrorRepresentations,
+  isFieldErrorMessage,
+  isSingleErrorMessage,
+} from './keycloak-error.util';
 
 describe('keycloak error model', () => {
   describe('extract error representations', () => {
diff --git a/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error.util.ts b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error.util.ts
new file mode 100644
index 0000000000..5f8cf30cad
--- /dev/null
+++ b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error.util.ts
@@ -0,0 +1,40 @@
+import {
+  ErrorRepresentation,
+  KEYCLOAK_VALIDATION_ERROR_STATUS_CODES,
+  KeycloakErrorResponseData,
+  KeycloakHttpErrorResponse,
+} from '@admin/keycloak-shared';
+import { isNotNil } from '@alfa-client/tech-shared';
+import { isNil } from 'lodash-es';
+
+export function extractErrorRepresentations(error: KeycloakHttpErrorResponse): ErrorRepresentation[] {
+  if (!canHandleKeycloakError(error)) {
+    throw new Error(`Cannot handle keycloak error: ${error}`);
+  }
+  const errorResponseData: KeycloakErrorResponseData = error.responseData;
+  return error.responseData.errors ?? [errorResponseData];
+}
+
+export function canHandleKeycloakError(err: KeycloakHttpErrorResponse): boolean {
+  if (!KEYCLOAK_VALIDATION_ERROR_STATUS_CODES.includes(err?.response?.status)) return false;
+  if (isNil(err?.responseData)) return false;
+
+  if (containsErrorMessage(err)) return true;
+  return containsErrorArray(err) && isNotNil(err.responseData.errors[0]?.errorMessage);
+}
+
+function containsErrorMessage(err: any): boolean {
+  return isNotNil(err?.responseData?.errorMessage);
+}
+
+function containsErrorArray(err: any): boolean {
+  return isNotNil(err?.responseData?.errors) && err.responseData.errors.length > 0;
+}
+
+export function isSingleErrorMessage(err: KeycloakErrorResponseData): boolean {
+  return isNotNil(err.errorMessage) && isNil(err.field) && isNil(err.params) && isNil(err.errors);
+}
+
+export function isFieldErrorMessage(err: KeycloakErrorResponseData): boolean {
+  return isNotNil(err.errorMessage) && isNotNil(err.field) && isNotNil(err.params) && isNil(err.errors);
+}
diff --git a/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-formservice.spec.ts b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-formservice.spec.ts
index 98cade5917..038187decc 100644
--- a/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-formservice.spec.ts
+++ b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-formservice.spec.ts
@@ -39,10 +39,11 @@ import { ValidationMessageCode } from '../../../../tech-shared/src/lib/validatio
 import { createInvalidParam } from '../../../../tech-shared/test/error';
 import { createErrorRepresentation, createKeycloakHttpErrorResponse } from '../test/keycloak';
 import * as FormUtil from './form.util';
-import { ErrorRepresentation, extractErrorRepresentations, isFieldErrorMessage, isSingleErrorMessage, KeycloakErrorMessage, KeycloakFieldName, KeycloakHttpErrorResponse, } from './keycloak-error.model';
+import { ErrorRepresentation, KeycloakErrorMessage, KeycloakFieldName, KeycloakHttpErrorResponse } from './keycloak-error.model';
+import { extractErrorRepresentations, isFieldErrorMessage, isSingleErrorMessage } from './keycloak-error.util';
 
-jest.mock('./keycloak-error.model', () => ({
-  ...jest.requireActual('./keycloak-error.model'),
+jest.mock('./keycloak-error.util', () => ({
+  ...jest.requireActual('./keycloak-error.util'),
   extractErrorRepresentations: jest.fn(),
   isSingleErrorMessage: jest.fn(),
   isFieldErrorMessage: jest.fn(),
diff --git a/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-formservice.ts b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-formservice.ts
index a949187cc4..e1856df17c 100644
--- a/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-formservice.ts
+++ b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-formservice.ts
@@ -28,7 +28,8 @@ import { ActivatedRoute, UrlSegment } from '@angular/router';
 import { catchError, first, Observable, of, tap } from 'rxjs';
 import { ValidationMessageCode } from '../../../../tech-shared/src/lib/validation/tech.validation.messages';
 import * as FormUtil from './form.util';
-import { ErrorRepresentation, extractErrorRepresentations, isFieldErrorMessage, isSingleErrorMessage, KeycloakErrorMessage, KeycloakFieldName, KeycloakHttpErrorResponse, } from './keycloak-error.model';
+import { ErrorRepresentation, KeycloakErrorMessage, KeycloakFieldName, KeycloakHttpErrorResponse } from './keycloak-error.model';
+import { extractErrorRepresentations, isFieldErrorMessage, isSingleErrorMessage } from './keycloak-error.util';
 
 @Injectable()
 export abstract class KeycloakFormService<T> {
-- 
GitLab


From 83f4f89668a4918b659373c9f794c19e1319ef8c Mon Sep 17 00:00:00 2001
From: sebo <sebastian.bergandy@external.mgm-cp.com>
Date: Thu, 13 Mar 2025 17:43:16 +0100
Subject: [PATCH 08/22] OZG-7591 add export to index

CR
---
 .../lib/user-form/user-form-roles/user-form-roles.component.ts | 3 +--
 alfa-client/libs/design-component/src/index.ts                 | 1 +
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.ts b/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.ts
index 9c6608e1f5..70a8bdfdcc 100644
--- a/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.ts
+++ b/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.ts
@@ -2,10 +2,9 @@ import { InvalidParam } from '@alfa-client/tech-shared';
 import { AsyncPipe } from '@angular/common';
 import { Component, Input, OnInit } from '@angular/core';
 import { AbstractControl, FormControlStatus, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms';
-import { CheckboxEditorComponent } from '@ods/component';
+import { CheckboxEditorComponent, ValidationErrorComponent } from '@ods/component';
 import { InfoIconComponent, TooltipDirective } from '@ods/system';
 import { map, Observable, of } from 'rxjs';
-import { ValidationErrorComponent } from '../../../../../../design-component/src/lib/form/validation-error/validation-error.component';
 import { UserFormService } from '../user.formservice';
 
 @Component({
diff --git a/alfa-client/libs/design-component/src/index.ts b/alfa-client/libs/design-component/src/index.ts
index 9eec827c3a..27e3b6381b 100644
--- a/alfa-client/libs/design-component/src/index.ts
+++ b/alfa-client/libs/design-component/src/index.ts
@@ -31,6 +31,7 @@ export * from './lib/form/formcontrol-editor.abstract.component';
 export * from './lib/form/single-file-upload-editor/single-file-upload-editor.component';
 export * from './lib/form/text-editor/text-editor.component';
 export * from './lib/form/textarea-editor/textarea-editor.component';
+export * from './lib/form/validation-error/validation-error.component';
 export * from './lib/open-dialog-button/open-dialog-button.component';
 export * from './lib/routing-button/routing-button.component';
 export * from './lib/spinner/spinner.component';
-- 
GitLab


From c94bc2e511fbdd9cfd215852b3170e9ac704dace Mon Sep 17 00:00:00 2001
From: sebo <sebastian.bergandy@external.mgm-cp.com>
Date: Thu, 13 Mar 2025 17:51:31 +0100
Subject: [PATCH 09/22] OZG-7591 organize imports

CR
---
 .../src/lib/user-form/user.formservice.spec.ts    | 15 +++++++--------
 1 file changed, 7 insertions(+), 8 deletions(-)

diff --git a/alfa-client/libs/admin/user/src/lib/user-form/user.formservice.spec.ts b/alfa-client/libs/admin/user/src/lib/user-form/user.formservice.spec.ts
index 61786fdc9f..d8d3db22fd 100644
--- a/alfa-client/libs/admin/user/src/lib/user-form/user.formservice.spec.ts
+++ b/alfa-client/libs/admin/user/src/lib/user-form/user.formservice.spec.ts
@@ -24,7 +24,7 @@
 import { AdminOrganisationsEinheit, AdminOrganisationsEinheitService } from '@admin-client/organisations-einheit-shared';
 import { ROUTES } from '@admin-client/shared';
 import { User, UserService } from '@admin-client/user-shared';
-import { PatchConfig } from '@admin/keycloak-shared';
+import { KeycloakErrorMessage, KeycloakFieldName, PatchConfig } from '@admin/keycloak-shared';
 import { NavigationService } from '@alfa-client/navigation-shared';
 import { createEmptyStateResource, createLoadingStateResource, createStateResource, StateResource, } from '@alfa-client/tech-shared';
 import { Mock, mock, mockWindowError } from '@alfa-client/test-utils';
@@ -38,7 +38,6 @@ import { createUser } from 'libs/admin/user-shared/test/user';
 import { Observable, of } from 'rxjs';
 import { createUrlSegment } from '../../../../../navigation-shared/test/navigation-test-factory';
 import { singleCold, singleColdCompleted, singleHot } from '../../../../../tech-shared/test/marbles';
-import { KeycloakErrorMessage, KeycloakFieldName } from '../../../../keycloak-shared/src/lib/keycloak-error.model';
 import { createKeycloakHttpErrorResponse } from '../../../../keycloak-shared/src/test/keycloak';
 import { createAdminOrganisationsEinheit } from '../../../../organisations-einheit-shared/src/test/organisations-einheit';
 import { UserFormService } from './user.formservice';
@@ -149,11 +148,11 @@ describe('UserFormService', () => {
 
   describe('listenToAlfaGroupChanges', () => {
     it('should call handleAlfaGroupChange on initial change', () => {
-      formService._handleAlfaGroupChange = jest.fn();
+      service._handleAlfaGroupChange = jest.fn();
 
-      formService.listenToAlfaGroupChanges();
+      service.listenToAlfaGroupChanges();
 
-      expect(formService._handleAlfaGroupChange).toHaveBeenCalled();
+      expect(service._handleAlfaGroupChange).toHaveBeenCalled();
     });
 
     it('should call handleAlfaGroupChange when value of form element changes', fakeAsync(() => {
@@ -443,11 +442,11 @@ describe('UserFormService', () => {
     });
 
     it('should unsubscribe from initOrganisationsEinheiten$', () => {
-      formService._alfaGroupChanges.unsubscribe = jest.fn();
+      service._alfaGroupChanges.unsubscribe = jest.fn();
 
-      formService.ngOnDestroy();
+      service.ngOnDestroy();
 
-      expect(formService._alfaGroupChanges.unsubscribe).toHaveBeenCalled();
+      expect(service._alfaGroupChanges.unsubscribe).toHaveBeenCalled();
     });
   });
 
-- 
GitLab


From 976e9c799e30f90b68dbc2c3f1f8614421e221b2 Mon Sep 17 00:00:00 2001
From: sebo <sebastian.bergandy@external.mgm-cp.com>
Date: Thu, 13 Mar 2025 18:31:34 +0100
Subject: [PATCH 10/22] OZG-7591 move validation messages

CR
---
 .../benutzer/benutzer.e2e.component.ts        |  9 -------
 .../src/helper/benutzer/benutzer.verifier.ts  | 25 ++++++++++---------
 .../admin-e2e/src/model/benutzer.messages.ts  |  9 +++++++
 3 files changed, 22 insertions(+), 21 deletions(-)
 create mode 100644 alfa-client/apps/admin-e2e/src/model/benutzer.messages.ts

diff --git a/alfa-client/apps/admin-e2e/src/components/benutzer/benutzer.e2e.component.ts b/alfa-client/apps/admin-e2e/src/components/benutzer/benutzer.e2e.component.ts
index 2d2fdbc886..fc463536e8 100644
--- a/alfa-client/apps/admin-e2e/src/components/benutzer/benutzer.e2e.component.ts
+++ b/alfa-client/apps/admin-e2e/src/components/benutzer/benutzer.e2e.component.ts
@@ -93,15 +93,6 @@ export class BenutzerListItemE2EComponent {
 }
 
 export class BenutzerE2EComponent {
-  public static readonly VORNAME_EMPTY_ERROR: string = 'Bitte Vorname ausfüllen';
-  public static readonly NACHNAME_EMPTY_ERROR: string = 'Bitte Nachname ausfüllen';
-  public static readonly BENUTZERNAME_SIZE_ERROR: string =
-    'Benutzername muss mindestens 3 und darf höchstens 255 Zeichen enthalten';
-  public static readonly EMAIL_INVALID_ERROR: string = 'Bitte E-Mail korrekt ausfüllen';
-  public static readonly ROLLEN_EMPTY_ERROR: string = 'Bitte Rollen ausfüllen';
-  public static readonly BENUTZER_NAME_EXISTS: string = 'Benutzername bereits verwendet';
-  public static readonly EMAIL_EXISTS: string = 'Email-Adresse bereits verwendet';
-
   private readonly headline: string = 'benutzer-form-headline';
 
   private readonly userVorname: string = 'firstName-text-input';
diff --git a/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.verifier.ts b/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.verifier.ts
index eea2afce0e..afd3f0539a 100644
--- a/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.verifier.ts
+++ b/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.verifier.ts
@@ -3,6 +3,7 @@ import {
   BenutzerListE2EComponent,
   BenutzerListItemE2EComponent,
 } from '../../components/benutzer/benutzer.e2e.component';
+import { BenutzerValidationMessages } from '../../model/benutzer.messages';
 import { AdminUserE2E } from '../../model/util';
 import { contains, exist, notBeEnabled, notContains, notExist } from '../../support/cypress.util';
 import { AlfaRollen } from '../../support/user-util';
@@ -70,56 +71,56 @@ export class E2EBenutzerVerifier {
 
   public verifyMissingVornameError(): void {
     exist(this.benutzerPage.getFirstNameValidationError());
-    contains(this.benutzerPage.getFirstNameValidationError(), BenutzerE2EComponent.VORNAME_EMPTY_ERROR);
+    contains(this.benutzerPage.getFirstNameValidationError(), BenutzerValidationMessages.VORNAME_EMPTY_ERROR);
   }
 
   public verifyLastNameError(): void {
     exist(this.benutzerPage.getLastNameValidationError());
-    contains(this.benutzerPage.getLastNameValidationError(), BenutzerE2EComponent.NACHNAME_EMPTY_ERROR);
+    contains(this.benutzerPage.getLastNameValidationError(), BenutzerValidationMessages.NACHNAME_EMPTY_ERROR);
   }
 
   public verifyUsernameSizeError(): void {
     exist(this.benutzerPage.getUsernameValidationError());
-    contains(this.benutzerPage.getUsernameValidationError(), BenutzerE2EComponent.BENUTZERNAME_SIZE_ERROR);
+    contains(this.benutzerPage.getUsernameValidationError(), BenutzerValidationMessages.BENUTZERNAME_SIZE_ERROR);
   }
 
   public verifyUserExistsError(): void {
     exist(this.benutzerPage.getUsernameValidationError());
-    contains(this.benutzerPage.getUsernameValidationError(), BenutzerE2EComponent.BENUTZER_NAME_EXISTS);
+    contains(this.benutzerPage.getUsernameValidationError(), BenutzerValidationMessages.BENUTZER_NAME_EXISTS);
   }
 
   public verifyEmailInvalidError(): void {
     exist(this.benutzerPage.getEmailValidationError());
-    contains(this.benutzerPage.getEmailValidationError(), BenutzerE2EComponent.EMAIL_INVALID_ERROR);
+    contains(this.benutzerPage.getEmailValidationError(), BenutzerValidationMessages.EMAIL_INVALID_ERROR);
   }
 
   public verifyEmailExistsError(): void {
     exist(this.benutzerPage.getEmailValidationError());
-    contains(this.benutzerPage.getEmailValidationError(), BenutzerE2EComponent.EMAIL_EXISTS);
+    contains(this.benutzerPage.getEmailValidationError(), BenutzerValidationMessages.EMAIL_EXISTS);
   }
 
   public verifyRollenError(): void {
     exist(this.benutzerPage.getRollenValidationError());
-    contains(this.benutzerPage.getRollenValidationError(), BenutzerE2EComponent.ROLLEN_EMPTY_ERROR);
+    contains(this.benutzerPage.getRollenValidationError(), BenutzerValidationMessages.ROLLEN_EMPTY_ERROR);
   }
 
   public verifyNoFirstNameValidationError(): void {
-    notContains(this.benutzerPage.getFirstNameValidationError(), BenutzerE2EComponent.VORNAME_EMPTY_ERROR);
+    notContains(this.benutzerPage.getFirstNameValidationError(), BenutzerValidationMessages.VORNAME_EMPTY_ERROR);
   }
 
   public verifyNoLastNameValidationError(): void {
-    notContains(this.benutzerPage.getLastNameValidationError(), BenutzerE2EComponent.NACHNAME_EMPTY_ERROR);
+    notContains(this.benutzerPage.getLastNameValidationError(), BenutzerValidationMessages.NACHNAME_EMPTY_ERROR);
   }
 
   public verifyNoUsernameSizeValidationError(): void {
-    notContains(this.benutzerPage.getUsernameValidationError(), BenutzerE2EComponent.BENUTZERNAME_SIZE_ERROR);
+    notContains(this.benutzerPage.getUsernameValidationError(), BenutzerValidationMessages.BENUTZERNAME_SIZE_ERROR);
   }
 
   public verifyNoEmailInvalidValidationError(): void {
-    notContains(this.benutzerPage.getEmailValidationError(), BenutzerE2EComponent.EMAIL_INVALID_ERROR);
+    notContains(this.benutzerPage.getEmailValidationError(), BenutzerValidationMessages.EMAIL_INVALID_ERROR);
   }
 
   public verifyNoRollenValidationError(): void {
-    notContains(this.benutzerPage.getRollenValidationError(), BenutzerE2EComponent.ROLLEN_EMPTY_ERROR);
+    notContains(this.benutzerPage.getRollenValidationError(), BenutzerValidationMessages.ROLLEN_EMPTY_ERROR);
   }
 }
diff --git a/alfa-client/apps/admin-e2e/src/model/benutzer.messages.ts b/alfa-client/apps/admin-e2e/src/model/benutzer.messages.ts
new file mode 100644
index 0000000000..c99605b360
--- /dev/null
+++ b/alfa-client/apps/admin-e2e/src/model/benutzer.messages.ts
@@ -0,0 +1,9 @@
+export enum BenutzerValidationMessages {
+  VORNAME_EMPTY_ERROR = 'Bitte Vorname ausfüllen',
+  NACHNAME_EMPTY_ERROR = 'Bitte Nachname ausfüllen',
+  BENUTZERNAME_SIZE_ERROR = 'Benutzername muss mindestens 3 und darf höchstens 255 Zeichen enthalten',
+  EMAIL_INVALID_ERROR = 'Bitte E-Mail korrekt ausfüllen',
+  ROLLEN_EMPTY_ERROR = 'Bitte Rollen ausfüllen',
+  BENUTZER_NAME_EXISTS = 'Benutzername bereits verwendet',
+  EMAIL_EXISTS = 'Email-Adresse bereits verwendet',
+}
-- 
GitLab


From 261df9e59358d5eb7a2aa1ecb96a2840efcfc525 Mon Sep 17 00:00:00 2001
From: sebo <sebastian.bergandy@external.mgm-cp.com>
Date: Thu, 13 Mar 2025 19:25:54 +0100
Subject: [PATCH 11/22] OZG-7591 fix e2e

---
 .../src/e2e/main-tests/benutzer_rollen/benutzer-anlegen.cy.ts | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/alfa-client/apps/admin-e2e/src/e2e/main-tests/benutzer_rollen/benutzer-anlegen.cy.ts b/alfa-client/apps/admin-e2e/src/e2e/main-tests/benutzer_rollen/benutzer-anlegen.cy.ts
index d5b90ab701..3d3ad67524 100644
--- a/alfa-client/apps/admin-e2e/src/e2e/main-tests/benutzer_rollen/benutzer-anlegen.cy.ts
+++ b/alfa-client/apps/admin-e2e/src/e2e/main-tests/benutzer_rollen/benutzer-anlegen.cy.ts
@@ -134,7 +134,7 @@ describe('Benutzer anlegen', () => {
 
     it('should show validation error on existing email', () => {
       benutzerHelper.openNewBenutzerPage();
-      benutzerHelper.editBenutzer(newUser);
+      benutzerHelper.addBenutzer(newUser);
       benutzerHelper.saveBenutzer();
 
       benutzerVerifier.verifyEmailExistsError();
@@ -142,7 +142,7 @@ describe('Benutzer anlegen', () => {
 
     it('should show validation error on existing username', () => {
       benutzerHelper.openNewBenutzerPage();
-      benutzerHelper.editBenutzer({ ...newUser, email: faker.internet.email() + '.local' });
+      benutzerHelper.addBenutzer({ ...newUser, email: faker.internet.email() + '.local' });
       benutzerHelper.saveBenutzer();
 
       benutzerVerifier.verifyUserExistsError();
-- 
GitLab


From deb94b59987a8c5c3152f2b48d0e81381200883c Mon Sep 17 00:00:00 2001
From: sebo <sebastian.bergandy@external.mgm-cp.com>
Date: Thu, 13 Mar 2025 20:35:15 +0100
Subject: [PATCH 12/22] OZG-7591 add text editor e2e component

CR
---
 .../benutzer/benutzer.e2e.component.ts        | 37 +++++++++----------
 .../ods/text-editor.e2e.component.ts          | 17 +++++++++
 .../src/helper/benutzer/benutzer.helper.ts    |  3 ++
 .../src/helper/benutzer/benutzer.verifier.ts  | 31 ++++++++--------
 4 files changed, 53 insertions(+), 35 deletions(-)
 create mode 100644 alfa-client/apps/admin-e2e/src/components/ods/text-editor.e2e.component.ts

diff --git a/alfa-client/apps/admin-e2e/src/components/benutzer/benutzer.e2e.component.ts b/alfa-client/apps/admin-e2e/src/components/benutzer/benutzer.e2e.component.ts
index fc463536e8..86924dcfd1 100644
--- a/alfa-client/apps/admin-e2e/src/components/benutzer/benutzer.e2e.component.ts
+++ b/alfa-client/apps/admin-e2e/src/components/benutzer/benutzer.e2e.component.ts
@@ -23,6 +23,7 @@
  */
 import 'cypress-real-events';
 import { convertToDataTestId } from '../../support/tech-util';
+import { TextEditorE2eComponent } from '../ods/text-editor.e2e.component';
 
 //TODO BenutzerListPage erstellen welche den Button und die Liste enthaelt.
 export class BenutzerListE2EComponent {
@@ -95,11 +96,6 @@ export class BenutzerListItemE2EComponent {
 export class BenutzerE2EComponent {
   private readonly headline: string = 'benutzer-form-headline';
 
-  private readonly userVorname: string = 'firstName-text-input';
-  private readonly userNachname: string = 'lastName-text-input';
-  private readonly userBenutzername: string = 'username-text-input';
-  private readonly userMail: string = 'email-text-input';
-
   private readonly adminCheckboxLabel: string = 'Admin';
   private readonly loeschenCheckboxLabel: string = 'Löschen';
   private readonly userCheckboxLabel: string = 'User';
@@ -111,12 +107,13 @@ export class BenutzerE2EComponent {
   private readonly saveButton: string = 'save-button';
   private readonly deleteButton: string = 'delete-button';
 
-  private readonly firstNameValidationError: string = 'firstName-text-editor-error';
-  private readonly lastNameValidationError: string = 'lastName-text-editor-error';
-  private readonly usernameValidationError: string = 'username-text-editor-error';
-  private readonly emailValidationError: string = 'email-text-editor-error';
   private readonly rollenValidationError: string = 'rollen-error';
 
+  private readonly vornameTextFeld: TextEditorE2eComponent = new TextEditorE2eComponent('firstName');
+  private readonly nachnameTextFeld: TextEditorE2eComponent = new TextEditorE2eComponent('lastName');
+  private readonly benutzernameTextFeld: TextEditorE2eComponent = new TextEditorE2eComponent('username');
+  private readonly emailTextFeld: TextEditorE2eComponent = new TextEditorE2eComponent('email');
+
   public getHeadline(): Cypress.Chainable<Element> {
     return cy.getTestElement(this.headline);
   }
@@ -126,19 +123,19 @@ export class BenutzerE2EComponent {
   }
 
   public getVornameInput(): Cypress.Chainable<Element> {
-    return cy.getTestElement(this.userVorname);
+    return this.vornameTextFeld.getInput();
   }
 
   public getNachnameInput(): Cypress.Chainable<Element> {
-    return cy.getTestElement(this.userNachname);
+    return this.nachnameTextFeld.getInput();
   }
 
   public getBenutzernameInput(): Cypress.Chainable<Element> {
-    return cy.getTestElement(this.userBenutzername);
+    return this.benutzernameTextFeld.getInput();
   }
 
   public getMailInput(): Cypress.Chainable<Element> {
-    return cy.getTestElement(this.userMail);
+    return this.emailTextFeld.getInput();
   }
 
   public getAdminCheckbox(): BenutzerCheckboxE2EComponent {
@@ -173,20 +170,20 @@ export class BenutzerE2EComponent {
     return cy.getTestElement(this.deleteButton);
   }
 
-  public getFirstNameValidationError(): Cypress.Chainable<Element> {
-    return cy.getTestElement(this.firstNameValidationError);
+  public getVornameValidationError(): Cypress.Chainable<Element> {
+    return this.vornameTextFeld.getErrorMessage();
   }
 
-  public getLastNameValidationError(): Cypress.Chainable<Element> {
-    return cy.getTestElement(this.lastNameValidationError);
+  public getNachnameValidationError(): Cypress.Chainable<Element> {
+    return this.nachnameTextFeld.getErrorMessage();
   }
 
-  public getUsernameValidationError(): Cypress.Chainable<Element> {
-    return cy.getTestElement(this.usernameValidationError);
+  public getBenutzernameValidationError(): Cypress.Chainable<Element> {
+    return this.benutzernameTextFeld.getErrorMessage();
   }
 
   public getEmailValidationError(): Cypress.Chainable<Element> {
-    return cy.getTestElement(this.emailValidationError);
+    return this.emailTextFeld.getErrorMessage();
   }
 
   public getRollenValidationError(): Cypress.Chainable<Element> {
diff --git a/alfa-client/apps/admin-e2e/src/components/ods/text-editor.e2e.component.ts b/alfa-client/apps/admin-e2e/src/components/ods/text-editor.e2e.component.ts
new file mode 100644
index 0000000000..d3fede9efe
--- /dev/null
+++ b/alfa-client/apps/admin-e2e/src/components/ods/text-editor.e2e.component.ts
@@ -0,0 +1,17 @@
+export class TextEditorE2eComponent {
+  private readonly root: string;
+  private readonly validationError: string;
+
+  constructor(root: string) {
+    this.root = `${root}-text-editor`;
+    this.validationError = `${root}-text-editor-error`;
+  }
+
+  public getInput(): Cypress.Chainable<Element> {
+    return cy.getTestElement(this.root);
+  }
+
+  public getErrorMessage(): Cypress.Chainable<Element> {
+    return cy.getTestElement(this.validationError);
+  }
+}
diff --git a/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.helper.ts b/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.helper.ts
index 1a8deaf8db..72ef2c819e 100644
--- a/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.helper.ts
+++ b/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.helper.ts
@@ -1,5 +1,6 @@
 import { OrganisationsEinheitE2E } from '../../model/organisations-einheit';
 import { AdminUserE2E } from '../../model/util';
+import { waitForSpinnerToDisappear } from '../../page-objects/main.po';
 import { E2EBenutzerExecutor } from './benutzer.executor';
 import { E2EBenutzerNavigator } from './benutzer.navigator';
 
@@ -72,11 +73,13 @@ export class E2EBenutzerHelper {
 
   public saveBenutzer(): void {
     this.executer.saveBenutzer();
+    waitForSpinnerToDisappear();
   }
 
   public deleteBenutzer(userName: string): void {
     this.openBenutzerPage(userName);
     this.executer.deleteBenutzer();
+    waitForSpinnerToDisappear();
   }
 
   public openBenutzerPage(userName: string): void {
diff --git a/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.verifier.ts b/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.verifier.ts
index afd3f0539a..0b12ee8679 100644
--- a/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.verifier.ts
+++ b/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.verifier.ts
@@ -1,3 +1,4 @@
+import { EMPTY_STRING } from '@alfa-client/tech-shared';
 import {
   BenutzerE2EComponent,
   BenutzerListE2EComponent,
@@ -5,7 +6,7 @@ import {
 } from '../../components/benutzer/benutzer.e2e.component';
 import { BenutzerValidationMessages } from '../../model/benutzer.messages';
 import { AdminUserE2E } from '../../model/util';
-import { contains, exist, notBeEnabled, notContains, notExist } from '../../support/cypress.util';
+import { contains, exist, haveText, notBeEnabled, notContains, notExist } from '../../support/cypress.util';
 import { AlfaRollen } from '../../support/user-util';
 
 export class E2EBenutzerVerifier {
@@ -70,33 +71,33 @@ export class E2EBenutzerVerifier {
   }
 
   public verifyMissingVornameError(): void {
-    exist(this.benutzerPage.getFirstNameValidationError());
-    contains(this.benutzerPage.getFirstNameValidationError(), BenutzerValidationMessages.VORNAME_EMPTY_ERROR);
+    exist(this.benutzerPage.getVornameValidationError());
+    haveText(this.benutzerPage.getVornameValidationError(), BenutzerValidationMessages.VORNAME_EMPTY_ERROR);
   }
 
   public verifyLastNameError(): void {
-    exist(this.benutzerPage.getLastNameValidationError());
-    contains(this.benutzerPage.getLastNameValidationError(), BenutzerValidationMessages.NACHNAME_EMPTY_ERROR);
+    exist(this.benutzerPage.getNachnameValidationError());
+    haveText(this.benutzerPage.getNachnameValidationError(), BenutzerValidationMessages.NACHNAME_EMPTY_ERROR);
   }
 
   public verifyUsernameSizeError(): void {
-    exist(this.benutzerPage.getUsernameValidationError());
-    contains(this.benutzerPage.getUsernameValidationError(), BenutzerValidationMessages.BENUTZERNAME_SIZE_ERROR);
+    exist(this.benutzerPage.getBenutzernameValidationError());
+    haveText(this.benutzerPage.getBenutzernameValidationError(), BenutzerValidationMessages.BENUTZERNAME_SIZE_ERROR);
   }
 
   public verifyUserExistsError(): void {
-    exist(this.benutzerPage.getUsernameValidationError());
-    contains(this.benutzerPage.getUsernameValidationError(), BenutzerValidationMessages.BENUTZER_NAME_EXISTS);
+    exist(this.benutzerPage.getBenutzernameValidationError());
+    haveText(this.benutzerPage.getBenutzernameValidationError(), BenutzerValidationMessages.BENUTZER_NAME_EXISTS);
   }
 
   public verifyEmailInvalidError(): void {
     exist(this.benutzerPage.getEmailValidationError());
-    contains(this.benutzerPage.getEmailValidationError(), BenutzerValidationMessages.EMAIL_INVALID_ERROR);
+    haveText(this.benutzerPage.getEmailValidationError(), BenutzerValidationMessages.EMAIL_INVALID_ERROR);
   }
 
   public verifyEmailExistsError(): void {
     exist(this.benutzerPage.getEmailValidationError());
-    contains(this.benutzerPage.getEmailValidationError(), BenutzerValidationMessages.EMAIL_EXISTS);
+    haveText(this.benutzerPage.getEmailValidationError(), BenutzerValidationMessages.EMAIL_EXISTS);
   }
 
   public verifyRollenError(): void {
@@ -105,19 +106,19 @@ export class E2EBenutzerVerifier {
   }
 
   public verifyNoFirstNameValidationError(): void {
-    notContains(this.benutzerPage.getFirstNameValidationError(), BenutzerValidationMessages.VORNAME_EMPTY_ERROR);
+    haveText(this.benutzerPage.getVornameValidationError(), EMPTY_STRING);
   }
 
   public verifyNoLastNameValidationError(): void {
-    notContains(this.benutzerPage.getLastNameValidationError(), BenutzerValidationMessages.NACHNAME_EMPTY_ERROR);
+    haveText(this.benutzerPage.getNachnameValidationError(), EMPTY_STRING);
   }
 
   public verifyNoUsernameSizeValidationError(): void {
-    notContains(this.benutzerPage.getUsernameValidationError(), BenutzerValidationMessages.BENUTZERNAME_SIZE_ERROR);
+    haveText(this.benutzerPage.getBenutzernameValidationError(), EMPTY_STRING);
   }
 
   public verifyNoEmailInvalidValidationError(): void {
-    notContains(this.benutzerPage.getEmailValidationError(), BenutzerValidationMessages.EMAIL_INVALID_ERROR);
+    haveText(this.benutzerPage.getEmailValidationError(), EMPTY_STRING);
   }
 
   public verifyNoRollenValidationError(): void {
-- 
GitLab


From 24028756df756275660d1f1b74adc3db6a1e7cf0 Mon Sep 17 00:00:00 2001
From: sebo <sebastian.bergandy@external.mgm-cp.com>
Date: Thu, 13 Mar 2025 20:50:08 +0100
Subject: [PATCH 13/22] OZG-7591 fix e2e component

---
 .../admin-e2e/src/components/ods/text-editor.e2e.component.ts | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/alfa-client/apps/admin-e2e/src/components/ods/text-editor.e2e.component.ts b/alfa-client/apps/admin-e2e/src/components/ods/text-editor.e2e.component.ts
index d3fede9efe..c7ba7ea9c1 100644
--- a/alfa-client/apps/admin-e2e/src/components/ods/text-editor.e2e.component.ts
+++ b/alfa-client/apps/admin-e2e/src/components/ods/text-editor.e2e.component.ts
@@ -1,14 +1,16 @@
 export class TextEditorE2eComponent {
   private readonly root: string;
+  private readonly input: string;
   private readonly validationError: string;
 
   constructor(root: string) {
     this.root = `${root}-text-editor`;
+    this.input = `${root}-text-input`;
     this.validationError = `${root}-text-editor-error`;
   }
 
   public getInput(): Cypress.Chainable<Element> {
-    return cy.getTestElement(this.root);
+    return cy.getTestElement(this.input);
   }
 
   public getErrorMessage(): Cypress.Chainable<Element> {
-- 
GitLab


From 95f0f0c639dfcce872ec1a6953107934c59e6b5e Mon Sep 17 00:00:00 2001
From: sebo <sebastian.bergandy@external.mgm-cp.com>
Date: Thu, 13 Mar 2025 21:11:35 +0100
Subject: [PATCH 14/22] OZG-7591 fix e2e component

---
 .../benutzer_rollen/benutzer-anlegen.cy.ts    | 80 +++++++++----------
 .../src/helper/benutzer/benutzer.verifier.ts  | 22 ++---
 2 files changed, 51 insertions(+), 51 deletions(-)

diff --git a/alfa-client/apps/admin-e2e/src/e2e/main-tests/benutzer_rollen/benutzer-anlegen.cy.ts b/alfa-client/apps/admin-e2e/src/e2e/main-tests/benutzer_rollen/benutzer-anlegen.cy.ts
index 3d3ad67524..43af53698b 100644
--- a/alfa-client/apps/admin-e2e/src/e2e/main-tests/benutzer_rollen/benutzer-anlegen.cy.ts
+++ b/alfa-client/apps/admin-e2e/src/e2e/main-tests/benutzer_rollen/benutzer-anlegen.cy.ts
@@ -64,66 +64,66 @@ describe('Benutzer anlegen', () => {
     benutzerVerifier.verifyUserNotInList(newUser.username);
   });
 
-  describe('client validation errors', () => {
-    it('should show all if empty form', () => {
+  describe('when filling formular', () => {
+    it('should show validation errors on empty formular', () => {
       benutzerHelper.openNewBenutzerPage();
       benutzerHelper.saveBenutzer();
 
-      benutzerVerifier.verifyMissingVornameError();
-      benutzerVerifier.verifyLastNameError();
-      benutzerVerifier.verifyUsernameSizeError();
-      benutzerVerifier.verifyEmailInvalidError();
-      benutzerVerifier.verifyRollenError();
+      benutzerVerifier.verifyEmptyVornameError();
+      benutzerVerifier.verifyEmptyNameError();
+      benutzerVerifier.verifyUsernameLengthError();
+      benutzerVerifier.verifyInvalidEmailError();
+      benutzerVerifier.verifyEmptyRollenError();
     });
 
-    it('should hide Vorname validation error', () => {
+    it('should hide Vorname validation error on filled', () => {
       benutzerHelper.editVorname('Max');
-      benutzerVerifier.verifyNoFirstNameValidationError();
-      benutzerVerifier.verifyLastNameError();
-      benutzerVerifier.verifyUsernameSizeError();
-      benutzerVerifier.verifyEmailInvalidError();
-      benutzerVerifier.verifyRollenError();
+      benutzerVerifier.verifyVornameHasNoError();
+      benutzerVerifier.verifyEmptyNameError();
+      benutzerVerifier.verifyUsernameLengthError();
+      benutzerVerifier.verifyInvalidEmailError();
+      benutzerVerifier.verifyEmptyRollenError();
     });
 
-    it('should hide Nachname validation error', () => {
+    it('should hide Nachname validation error on filled', () => {
       benutzerHelper.editNachname('Mustermann');
-      benutzerVerifier.verifyNoFirstNameValidationError();
-      benutzerVerifier.verifyNoLastNameValidationError();
-      benutzerVerifier.verifyUsernameSizeError();
-      benutzerVerifier.verifyEmailInvalidError();
-      benutzerVerifier.verifyRollenError();
+      benutzerVerifier.verifyVornameHasNoError();
+      benutzerVerifier.verifyLastNameHasNoError();
+      benutzerVerifier.verifyUsernameLengthError();
+      benutzerVerifier.verifyInvalidEmailError();
+      benutzerVerifier.verifyEmptyRollenError();
     });
 
-    it('should hide Benutzername validation error', () => {
+    it('should hide Benutzername validation error on filled', () => {
       benutzerHelper.editBenutzername('Max');
-      benutzerVerifier.verifyNoFirstNameValidationError();
-      benutzerVerifier.verifyNoLastNameValidationError();
-      benutzerVerifier.verifyNoUsernameSizeValidationError();
-      benutzerVerifier.verifyEmailInvalidError();
-      benutzerVerifier.verifyRollenError();
+      benutzerVerifier.verifyVornameHasNoError();
+      benutzerVerifier.verifyLastNameHasNoError();
+      benutzerVerifier.verifyUsernameHasNoError();
+      benutzerVerifier.verifyInvalidEmailError();
+      benutzerVerifier.verifyEmptyRollenError();
     });
 
-    it('should hide Email validation error', () => {
+    it('should hide Email validation error on filled', () => {
       benutzerHelper.editEmail('max@max.local');
-      benutzerVerifier.verifyNoFirstNameValidationError();
-      benutzerVerifier.verifyNoLastNameValidationError();
-      benutzerVerifier.verifyNoUsernameSizeValidationError();
-      benutzerVerifier.verifyNoEmailInvalidValidationError();
-      benutzerVerifier.verifyRollenError();
+      benutzerVerifier.verifyVornameHasNoError();
+      benutzerVerifier.verifyLastNameHasNoError();
+      benutzerVerifier.verifyUsernameHasNoError();
+      benutzerVerifier.verifyEmailHasNoError();
+      benutzerVerifier.verifyEmptyRollenError();
     });
 
-    it('should hide Rollen validation error', () => {
+    it('should hide Rollen validation error on filled', () => {
       benutzerHelper.addUserRole();
-      benutzerVerifier.verifyNoFirstNameValidationError();
-      benutzerVerifier.verifyNoLastNameValidationError();
-      benutzerVerifier.verifyNoUsernameSizeValidationError();
-      benutzerVerifier.verifyNoEmailInvalidValidationError();
-      benutzerVerifier.verifyNoRollenValidationError();
+      benutzerVerifier.verifyVornameHasNoError();
+      benutzerVerifier.verifyLastNameHasNoError();
+      benutzerVerifier.verifyUsernameHasNoError();
+      benutzerVerifier.verifyEmailHasNoError();
+      benutzerVerifier.verifyRollenHasNoError();
     });
   });
 
-  describe('server validation errors', () => {
-    it('should create user', () => {
+  describe('after formular submission', () => {
+    it('should have user created', () => {
       benutzerHelper.openNewBenutzerPage();
 
       benutzerHelper.addBenutzer(newUser);
@@ -145,7 +145,7 @@ describe('Benutzer anlegen', () => {
       benutzerHelper.addBenutzer({ ...newUser, email: faker.internet.email() + '.local' });
       benutzerHelper.saveBenutzer();
 
-      benutzerVerifier.verifyUserExistsError();
+      benutzerVerifier.verifyUsernameExistsError();
     });
 
     it('should remove benutzer', () => {
diff --git a/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.verifier.ts b/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.verifier.ts
index 0b12ee8679..5618af6a9f 100644
--- a/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.verifier.ts
+++ b/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.verifier.ts
@@ -70,27 +70,27 @@ export class E2EBenutzerVerifier {
     return this.benutzerListPage.getItem(userName);
   }
 
-  public verifyMissingVornameError(): void {
+  public verifyEmptyVornameError(): void {
     exist(this.benutzerPage.getVornameValidationError());
     haveText(this.benutzerPage.getVornameValidationError(), BenutzerValidationMessages.VORNAME_EMPTY_ERROR);
   }
 
-  public verifyLastNameError(): void {
+  public verifyEmptyNameError(): void {
     exist(this.benutzerPage.getNachnameValidationError());
     haveText(this.benutzerPage.getNachnameValidationError(), BenutzerValidationMessages.NACHNAME_EMPTY_ERROR);
   }
 
-  public verifyUsernameSizeError(): void {
+  public verifyUsernameLengthError(): void {
     exist(this.benutzerPage.getBenutzernameValidationError());
     haveText(this.benutzerPage.getBenutzernameValidationError(), BenutzerValidationMessages.BENUTZERNAME_SIZE_ERROR);
   }
 
-  public verifyUserExistsError(): void {
+  public verifyUsernameExistsError(): void {
     exist(this.benutzerPage.getBenutzernameValidationError());
     haveText(this.benutzerPage.getBenutzernameValidationError(), BenutzerValidationMessages.BENUTZER_NAME_EXISTS);
   }
 
-  public verifyEmailInvalidError(): void {
+  public verifyInvalidEmailError(): void {
     exist(this.benutzerPage.getEmailValidationError());
     haveText(this.benutzerPage.getEmailValidationError(), BenutzerValidationMessages.EMAIL_INVALID_ERROR);
   }
@@ -100,28 +100,28 @@ export class E2EBenutzerVerifier {
     haveText(this.benutzerPage.getEmailValidationError(), BenutzerValidationMessages.EMAIL_EXISTS);
   }
 
-  public verifyRollenError(): void {
+  public verifyEmptyRollenError(): void {
     exist(this.benutzerPage.getRollenValidationError());
     contains(this.benutzerPage.getRollenValidationError(), BenutzerValidationMessages.ROLLEN_EMPTY_ERROR);
   }
 
-  public verifyNoFirstNameValidationError(): void {
+  public verifyVornameHasNoError(): void {
     haveText(this.benutzerPage.getVornameValidationError(), EMPTY_STRING);
   }
 
-  public verifyNoLastNameValidationError(): void {
+  public verifyLastNameHasNoError(): void {
     haveText(this.benutzerPage.getNachnameValidationError(), EMPTY_STRING);
   }
 
-  public verifyNoUsernameSizeValidationError(): void {
+  public verifyUsernameHasNoError(): void {
     haveText(this.benutzerPage.getBenutzernameValidationError(), EMPTY_STRING);
   }
 
-  public verifyNoEmailInvalidValidationError(): void {
+  public verifyEmailHasNoError(): void {
     haveText(this.benutzerPage.getEmailValidationError(), EMPTY_STRING);
   }
 
-  public verifyNoRollenValidationError(): void {
+  public verifyRollenHasNoError(): void {
     notContains(this.benutzerPage.getRollenValidationError(), BenutzerValidationMessages.ROLLEN_EMPTY_ERROR);
   }
 }
-- 
GitLab


From 0be832bb30b4ee3dda43e07daa8a9be2eff7c640 Mon Sep 17 00:00:00 2001
From: sebo <sebastian.bergandy@external.mgm-cp.com>
Date: Mon, 17 Mar 2025 17:14:38 +0100
Subject: [PATCH 15/22] OZG-7591 refactor e2e component

---
 .../benutzer/benutzer.e2e.component.ts        | 16 +++++-----
 .../src/helper/benutzer/benutzer.verifier.ts  | 32 +++++++++----------
 2 files changed, 24 insertions(+), 24 deletions(-)

diff --git a/alfa-client/apps/admin-e2e/src/components/benutzer/benutzer.e2e.component.ts b/alfa-client/apps/admin-e2e/src/components/benutzer/benutzer.e2e.component.ts
index 86924dcfd1..d875e2832c 100644
--- a/alfa-client/apps/admin-e2e/src/components/benutzer/benutzer.e2e.component.ts
+++ b/alfa-client/apps/admin-e2e/src/components/benutzer/benutzer.e2e.component.ts
@@ -170,20 +170,20 @@ export class BenutzerE2EComponent {
     return cy.getTestElement(this.deleteButton);
   }
 
-  public getVornameValidationError(): Cypress.Chainable<Element> {
-    return this.vornameTextFeld.getErrorMessage();
+  public getVornameTextFeld(): TextEditorE2eComponent {
+    return this.vornameTextFeld;
   }
 
-  public getNachnameValidationError(): Cypress.Chainable<Element> {
-    return this.nachnameTextFeld.getErrorMessage();
+  public getNachnameTextFeld(): TextEditorE2eComponent {
+    return this.nachnameTextFeld;
   }
 
-  public getBenutzernameValidationError(): Cypress.Chainable<Element> {
-    return this.benutzernameTextFeld.getErrorMessage();
+  public getBenutzernameTextFeld(): TextEditorE2eComponent {
+    return this.benutzernameTextFeld;
   }
 
-  public getEmailValidationError(): Cypress.Chainable<Element> {
-    return this.emailTextFeld.getErrorMessage();
+  public getEmailTextFeld(): TextEditorE2eComponent {
+    return this.emailTextFeld;
   }
 
   public getRollenValidationError(): Cypress.Chainable<Element> {
diff --git a/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.verifier.ts b/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.verifier.ts
index 5618af6a9f..120f766515 100644
--- a/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.verifier.ts
+++ b/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.verifier.ts
@@ -71,33 +71,33 @@ export class E2EBenutzerVerifier {
   }
 
   public verifyEmptyVornameError(): void {
-    exist(this.benutzerPage.getVornameValidationError());
-    haveText(this.benutzerPage.getVornameValidationError(), BenutzerValidationMessages.VORNAME_EMPTY_ERROR);
+    exist(this.benutzerPage.getVornameTextFeld().getErrorMessage());
+    haveText(this.benutzerPage.getVornameTextFeld().getErrorMessage(), BenutzerValidationMessages.VORNAME_EMPTY_ERROR);
   }
 
   public verifyEmptyNameError(): void {
-    exist(this.benutzerPage.getNachnameValidationError());
-    haveText(this.benutzerPage.getNachnameValidationError(), BenutzerValidationMessages.NACHNAME_EMPTY_ERROR);
+    exist(this.benutzerPage.getNachnameTextFeld().getErrorMessage());
+    haveText(this.benutzerPage.getNachnameTextFeld().getErrorMessage(), BenutzerValidationMessages.NACHNAME_EMPTY_ERROR);
   }
 
   public verifyUsernameLengthError(): void {
-    exist(this.benutzerPage.getBenutzernameValidationError());
-    haveText(this.benutzerPage.getBenutzernameValidationError(), BenutzerValidationMessages.BENUTZERNAME_SIZE_ERROR);
+    exist(this.benutzerPage.getBenutzernameTextFeld().getErrorMessage());
+    haveText(this.benutzerPage.getBenutzernameTextFeld().getErrorMessage(), BenutzerValidationMessages.BENUTZERNAME_SIZE_ERROR);
   }
 
   public verifyUsernameExistsError(): void {
-    exist(this.benutzerPage.getBenutzernameValidationError());
-    haveText(this.benutzerPage.getBenutzernameValidationError(), BenutzerValidationMessages.BENUTZER_NAME_EXISTS);
+    exist(this.benutzerPage.getBenutzernameTextFeld().getErrorMessage());
+    haveText(this.benutzerPage.getBenutzernameTextFeld().getErrorMessage(), BenutzerValidationMessages.BENUTZER_NAME_EXISTS);
   }
 
   public verifyInvalidEmailError(): void {
-    exist(this.benutzerPage.getEmailValidationError());
-    haveText(this.benutzerPage.getEmailValidationError(), BenutzerValidationMessages.EMAIL_INVALID_ERROR);
+    exist(this.benutzerPage.getEmailTextFeld().getErrorMessage());
+    haveText(this.benutzerPage.getEmailTextFeld().getErrorMessage(), BenutzerValidationMessages.EMAIL_INVALID_ERROR);
   }
 
   public verifyEmailExistsError(): void {
-    exist(this.benutzerPage.getEmailValidationError());
-    haveText(this.benutzerPage.getEmailValidationError(), BenutzerValidationMessages.EMAIL_EXISTS);
+    exist(this.benutzerPage.getEmailTextFeld().getErrorMessage());
+    haveText(this.benutzerPage.getEmailTextFeld().getErrorMessage(), BenutzerValidationMessages.EMAIL_EXISTS);
   }
 
   public verifyEmptyRollenError(): void {
@@ -106,19 +106,19 @@ export class E2EBenutzerVerifier {
   }
 
   public verifyVornameHasNoError(): void {
-    haveText(this.benutzerPage.getVornameValidationError(), EMPTY_STRING);
+    haveText(this.benutzerPage.getVornameTextFeld().getErrorMessage(), EMPTY_STRING);
   }
 
   public verifyLastNameHasNoError(): void {
-    haveText(this.benutzerPage.getNachnameValidationError(), EMPTY_STRING);
+    haveText(this.benutzerPage.getNachnameTextFeld().getErrorMessage(), EMPTY_STRING);
   }
 
   public verifyUsernameHasNoError(): void {
-    haveText(this.benutzerPage.getBenutzernameValidationError(), EMPTY_STRING);
+    haveText(this.benutzerPage.getBenutzernameTextFeld().getErrorMessage(), EMPTY_STRING);
   }
 
   public verifyEmailHasNoError(): void {
-    haveText(this.benutzerPage.getEmailValidationError(), EMPTY_STRING);
+    haveText(this.benutzerPage.getEmailTextFeld().getErrorMessage(), EMPTY_STRING);
   }
 
   public verifyRollenHasNoError(): void {
-- 
GitLab


From 55e95208bf55c5b59e6697f46ffaccdc6f456f44 Mon Sep 17 00:00:00 2001
From: sebo <sebastian.bergandy@external.mgm-cp.com>
Date: Mon, 17 Mar 2025 17:14:56 +0100
Subject: [PATCH 16/22] OZG-7591 rename e2e component

---
 .../benutzer/benutzer.e2e.component.ts         | 18 +++++++++---------
 ...ponent.ts => text-editor-e2-e.component.ts} |  2 +-
 2 files changed, 10 insertions(+), 10 deletions(-)
 rename alfa-client/apps/admin-e2e/src/components/ods/{text-editor.e2e.component.ts => text-editor-e2-e.component.ts} (92%)

diff --git a/alfa-client/apps/admin-e2e/src/components/benutzer/benutzer.e2e.component.ts b/alfa-client/apps/admin-e2e/src/components/benutzer/benutzer.e2e.component.ts
index d875e2832c..c80f8d4b50 100644
--- a/alfa-client/apps/admin-e2e/src/components/benutzer/benutzer.e2e.component.ts
+++ b/alfa-client/apps/admin-e2e/src/components/benutzer/benutzer.e2e.component.ts
@@ -23,7 +23,7 @@
  */
 import 'cypress-real-events';
 import { convertToDataTestId } from '../../support/tech-util';
-import { TextEditorE2eComponent } from '../ods/text-editor.e2e.component';
+import { TextEditorE2EComponent } from '../ods/text-editor-e2-e.component';
 
 //TODO BenutzerListPage erstellen welche den Button und die Liste enthaelt.
 export class BenutzerListE2EComponent {
@@ -109,10 +109,10 @@ export class BenutzerE2EComponent {
 
   private readonly rollenValidationError: string = 'rollen-error';
 
-  private readonly vornameTextFeld: TextEditorE2eComponent = new TextEditorE2eComponent('firstName');
-  private readonly nachnameTextFeld: TextEditorE2eComponent = new TextEditorE2eComponent('lastName');
-  private readonly benutzernameTextFeld: TextEditorE2eComponent = new TextEditorE2eComponent('username');
-  private readonly emailTextFeld: TextEditorE2eComponent = new TextEditorE2eComponent('email');
+  private readonly vornameTextFeld: TextEditorE2EComponent = new TextEditorE2EComponent('firstName');
+  private readonly nachnameTextFeld: TextEditorE2EComponent = new TextEditorE2EComponent('lastName');
+  private readonly benutzernameTextFeld: TextEditorE2EComponent = new TextEditorE2EComponent('username');
+  private readonly emailTextFeld: TextEditorE2EComponent = new TextEditorE2EComponent('email');
 
   public getHeadline(): Cypress.Chainable<Element> {
     return cy.getTestElement(this.headline);
@@ -170,19 +170,19 @@ export class BenutzerE2EComponent {
     return cy.getTestElement(this.deleteButton);
   }
 
-  public getVornameTextFeld(): TextEditorE2eComponent {
+  public getVornameTextFeld(): TextEditorE2EComponent {
     return this.vornameTextFeld;
   }
 
-  public getNachnameTextFeld(): TextEditorE2eComponent {
+  public getNachnameTextFeld(): TextEditorE2EComponent {
     return this.nachnameTextFeld;
   }
 
-  public getBenutzernameTextFeld(): TextEditorE2eComponent {
+  public getBenutzernameTextFeld(): TextEditorE2EComponent {
     return this.benutzernameTextFeld;
   }
 
-  public getEmailTextFeld(): TextEditorE2eComponent {
+  public getEmailTextFeld(): TextEditorE2EComponent {
     return this.emailTextFeld;
   }
 
diff --git a/alfa-client/apps/admin-e2e/src/components/ods/text-editor.e2e.component.ts b/alfa-client/apps/admin-e2e/src/components/ods/text-editor-e2-e.component.ts
similarity index 92%
rename from alfa-client/apps/admin-e2e/src/components/ods/text-editor.e2e.component.ts
rename to alfa-client/apps/admin-e2e/src/components/ods/text-editor-e2-e.component.ts
index c7ba7ea9c1..0605779543 100644
--- a/alfa-client/apps/admin-e2e/src/components/ods/text-editor.e2e.component.ts
+++ b/alfa-client/apps/admin-e2e/src/components/ods/text-editor-e2-e.component.ts
@@ -1,4 +1,4 @@
-export class TextEditorE2eComponent {
+export class TextEditorE2EComponent {
   private readonly root: string;
   private readonly input: string;
   private readonly validationError: string;
-- 
GitLab


From bb27c1a197aca3f848b7e40bb5159ff3a3e4a1b4 Mon Sep 17 00:00:00 2001
From: sebo <sebastian.bergandy@external.mgm-cp.com>
Date: Mon, 17 Mar 2025 17:16:04 +0100
Subject: [PATCH 17/22] OZG-7591 rename e2e enum

---
 .../src/helper/benutzer/benutzer.verifier.ts  | 21 +++++++++++--------
 .../admin-e2e/src/model/benutzer.messages.ts  |  2 +-
 2 files changed, 13 insertions(+), 10 deletions(-)

diff --git a/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.verifier.ts b/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.verifier.ts
index 120f766515..22f42fb09f 100644
--- a/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.verifier.ts
+++ b/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.verifier.ts
@@ -4,7 +4,7 @@ import {
   BenutzerListE2EComponent,
   BenutzerListItemE2EComponent,
 } from '../../components/benutzer/benutzer.e2e.component';
-import { BenutzerValidationMessages } from '../../model/benutzer.messages';
+import { E2EBenutzerValidationMessages } from '../../model/benutzer.messages';
 import { AdminUserE2E } from '../../model/util';
 import { contains, exist, haveText, notBeEnabled, notContains, notExist } from '../../support/cypress.util';
 import { AlfaRollen } from '../../support/user-util';
@@ -72,37 +72,40 @@ export class E2EBenutzerVerifier {
 
   public verifyEmptyVornameError(): void {
     exist(this.benutzerPage.getVornameTextFeld().getErrorMessage());
-    haveText(this.benutzerPage.getVornameTextFeld().getErrorMessage(), BenutzerValidationMessages.VORNAME_EMPTY_ERROR);
+    haveText(this.benutzerPage.getVornameTextFeld().getErrorMessage(), E2EBenutzerValidationMessages.VORNAME_EMPTY_ERROR);
   }
 
   public verifyEmptyNameError(): void {
     exist(this.benutzerPage.getNachnameTextFeld().getErrorMessage());
-    haveText(this.benutzerPage.getNachnameTextFeld().getErrorMessage(), BenutzerValidationMessages.NACHNAME_EMPTY_ERROR);
+    haveText(this.benutzerPage.getNachnameTextFeld().getErrorMessage(), E2EBenutzerValidationMessages.NACHNAME_EMPTY_ERROR);
   }
 
   public verifyUsernameLengthError(): void {
     exist(this.benutzerPage.getBenutzernameTextFeld().getErrorMessage());
-    haveText(this.benutzerPage.getBenutzernameTextFeld().getErrorMessage(), BenutzerValidationMessages.BENUTZERNAME_SIZE_ERROR);
+    haveText(
+      this.benutzerPage.getBenutzernameTextFeld().getErrorMessage(),
+      E2EBenutzerValidationMessages.BENUTZERNAME_SIZE_ERROR,
+    );
   }
 
   public verifyUsernameExistsError(): void {
     exist(this.benutzerPage.getBenutzernameTextFeld().getErrorMessage());
-    haveText(this.benutzerPage.getBenutzernameTextFeld().getErrorMessage(), BenutzerValidationMessages.BENUTZER_NAME_EXISTS);
+    haveText(this.benutzerPage.getBenutzernameTextFeld().getErrorMessage(), E2EBenutzerValidationMessages.BENUTZER_NAME_EXISTS);
   }
 
   public verifyInvalidEmailError(): void {
     exist(this.benutzerPage.getEmailTextFeld().getErrorMessage());
-    haveText(this.benutzerPage.getEmailTextFeld().getErrorMessage(), BenutzerValidationMessages.EMAIL_INVALID_ERROR);
+    haveText(this.benutzerPage.getEmailTextFeld().getErrorMessage(), E2EBenutzerValidationMessages.EMAIL_INVALID_ERROR);
   }
 
   public verifyEmailExistsError(): void {
     exist(this.benutzerPage.getEmailTextFeld().getErrorMessage());
-    haveText(this.benutzerPage.getEmailTextFeld().getErrorMessage(), BenutzerValidationMessages.EMAIL_EXISTS);
+    haveText(this.benutzerPage.getEmailTextFeld().getErrorMessage(), E2EBenutzerValidationMessages.EMAIL_EXISTS);
   }
 
   public verifyEmptyRollenError(): void {
     exist(this.benutzerPage.getRollenValidationError());
-    contains(this.benutzerPage.getRollenValidationError(), BenutzerValidationMessages.ROLLEN_EMPTY_ERROR);
+    contains(this.benutzerPage.getRollenValidationError(), E2EBenutzerValidationMessages.ROLLEN_EMPTY_ERROR);
   }
 
   public verifyVornameHasNoError(): void {
@@ -122,6 +125,6 @@ export class E2EBenutzerVerifier {
   }
 
   public verifyRollenHasNoError(): void {
-    notContains(this.benutzerPage.getRollenValidationError(), BenutzerValidationMessages.ROLLEN_EMPTY_ERROR);
+    notContains(this.benutzerPage.getRollenValidationError(), E2EBenutzerValidationMessages.ROLLEN_EMPTY_ERROR);
   }
 }
diff --git a/alfa-client/apps/admin-e2e/src/model/benutzer.messages.ts b/alfa-client/apps/admin-e2e/src/model/benutzer.messages.ts
index c99605b360..db6aa9f09f 100644
--- a/alfa-client/apps/admin-e2e/src/model/benutzer.messages.ts
+++ b/alfa-client/apps/admin-e2e/src/model/benutzer.messages.ts
@@ -1,4 +1,4 @@
-export enum BenutzerValidationMessages {
+export enum E2EBenutzerValidationMessages {
   VORNAME_EMPTY_ERROR = 'Bitte Vorname ausfüllen',
   NACHNAME_EMPTY_ERROR = 'Bitte Nachname ausfüllen',
   BENUTZERNAME_SIZE_ERROR = 'Benutzername muss mindestens 3 und darf höchstens 255 Zeichen enthalten',
-- 
GitLab


From 10a784c7b63fd198520ea306f52f76ea653dc03b Mon Sep 17 00:00:00 2001
From: Martin <git@mail.de>
Date: Tue, 18 Mar 2025 09:49:49 +0100
Subject: [PATCH 18/22] OZG-7591 add validation error id for aria-describedby

---
 .../user-form-roles.component.html            |  1 +
 .../user-form-roles.component.ts              |  3 +-
 .../checkbox-editor.component.html            |  2 ++
 .../checkbox-editor.component.ts              |  4 ++-
 .../text-editor/text-editor.component.html    |  2 ++
 .../form/text-editor/text-editor.component.ts |  4 ++-
 .../textarea-editor.component.html            |  2 ++
 .../textarea-editor.component.ts              |  4 ++-
 .../validation-error.component.html           |  2 +-
 .../validation-error.component.ts             |  1 +
 .../lib/form/checkbox/checkbox.component.ts   |  4 ++-
 .../error-message/error-message.component.ts  |  4 ++-
 .../form/text-input/text-input.component.ts   |  4 ++-
 .../lib/form/textarea/textarea.component.ts   |  4 ++-
 .../lib/validation/tech.validation.util.ts    | 36 ++++++-------------
 15 files changed, 43 insertions(+), 34 deletions(-)

diff --git a/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.html b/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.html
index 38380b3fa1..f410d02855 100644
--- a/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.html
+++ b/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.html
@@ -1,5 +1,6 @@
 <h2 class="heading-2 mt-4">Rollen für OZG-Cloud *</h2>
 <ods-validation-error
+  [id]="validationErrorId"
   [invalidParams]="invalidParams$ | async"
   label="Rollen"
   data-test-id="rollen-error"
diff --git a/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.ts b/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.ts
index 70a8bdfdcc..37336816f4 100644
--- a/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.ts
+++ b/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.ts
@@ -1,4 +1,4 @@
-import { InvalidParam } from '@alfa-client/tech-shared';
+import { generateValidationErrorId, InvalidParam } from '@alfa-client/tech-shared';
 import { AsyncPipe } from '@angular/common';
 import { Component, Input, OnInit } from '@angular/core';
 import { AbstractControl, FormControlStatus, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms';
@@ -26,6 +26,7 @@ export class UserFormRolesComponent implements OnInit {
   public invalidParams$: Observable<InvalidParam[]> = of([]);
 
   public readonly UserFormService = UserFormService;
+  public readonly validationErrorId: string = generateValidationErrorId();
 
   ngOnInit(): void {
     const control: AbstractControl = this.formGroupParent.controls[UserFormService.CLIENT_ROLES];
diff --git a/alfa-client/libs/design-component/src/lib/form/checkbox-editor/checkbox-editor.component.html b/alfa-client/libs/design-component/src/lib/form/checkbox-editor/checkbox-editor.component.html
index 2533dc1ed4..1cc8b07470 100644
--- a/alfa-client/libs/design-component/src/lib/form/checkbox-editor/checkbox-editor.component.html
+++ b/alfa-client/libs/design-component/src/lib/form/checkbox-editor/checkbox-editor.component.html
@@ -29,9 +29,11 @@
   [label]="label"
   [disabled]="control.disabled"
   [hasError]="hasError"
+  [ariaDescribedBy]="validationErrorId"
 >
   <ods-validation-error
     error
+    [id]="validationErrorId"
     [invalidParams]="invalidParams"
     [label]="label"
     [attr.data-test-id]="(label | convertForDataTest) + '-checkbox-editor-error'"
diff --git a/alfa-client/libs/design-component/src/lib/form/checkbox-editor/checkbox-editor.component.ts b/alfa-client/libs/design-component/src/lib/form/checkbox-editor/checkbox-editor.component.ts
index dfab859671..bbb9f693fc 100644
--- a/alfa-client/libs/design-component/src/lib/form/checkbox-editor/checkbox-editor.component.ts
+++ b/alfa-client/libs/design-component/src/lib/form/checkbox-editor/checkbox-editor.component.ts
@@ -21,7 +21,7 @@
  * Die sprachspezifischen Genehmigungen und Beschränkungen
  * unter der Lizenz sind dem Lizenztext zu entnehmen.
  */
-import { ConvertForDataTestPipe } from '@alfa-client/tech-shared';
+import { ConvertForDataTestPipe, generateValidationErrorId } from '@alfa-client/tech-shared';
 import { Component, Input } from '@angular/core';
 import { ReactiveFormsModule } from '@angular/forms';
 import { CheckboxComponent } from '@ods/system';
@@ -38,6 +38,8 @@ export class CheckboxEditorComponent extends FormControlEditorAbstractComponent
   @Input() inputId: string;
   @Input() label: string;
 
+  public readonly validationErrorId: string = generateValidationErrorId();
+
   get hasError(): boolean {
     return this.invalidParams.length > 0;
   }
diff --git a/alfa-client/libs/design-component/src/lib/form/text-editor/text-editor.component.html b/alfa-client/libs/design-component/src/lib/form/text-editor/text-editor.component.html
index c4c0091074..3c116fb0b2 100644
--- a/alfa-client/libs/design-component/src/lib/form/text-editor/text-editor.component.html
+++ b/alfa-client/libs/design-component/src/lib/form/text-editor/text-editor.component.html
@@ -34,9 +34,11 @@
   [required]="isRequired"
   [focus]="focus"
   [showLabel]="showLabel"
+  [ariaDescribedBy]="validationErrorId"
 >
   <ods-validation-error
     error
+    [id]="validationErrorId"
     [invalidParams]="invalidParams"
     [label]="label"
     [attr.data-test-id]="dataTestId + '-text-editor-error'"
diff --git a/alfa-client/libs/design-component/src/lib/form/text-editor/text-editor.component.ts b/alfa-client/libs/design-component/src/lib/form/text-editor/text-editor.component.ts
index 45f7c42c8f..19ae4d4d43 100644
--- a/alfa-client/libs/design-component/src/lib/form/text-editor/text-editor.component.ts
+++ b/alfa-client/libs/design-component/src/lib/form/text-editor/text-editor.component.ts
@@ -21,7 +21,7 @@
  * Die sprachspezifischen Genehmigungen und Beschränkungen
  * unter der Lizenz sind dem Lizenztext zu entnehmen.
  */
-import { ConvertForDataTestPipe } from '@alfa-client/tech-shared';
+import { ConvertForDataTestPipe, generateValidationErrorId } from '@alfa-client/tech-shared';
 import { CommonModule } from '@angular/common';
 import { Component, Input } from '@angular/core';
 import { ReactiveFormsModule } from '@angular/forms';
@@ -44,6 +44,8 @@ export class TextEditorComponent extends FormControlEditorAbstractComponent {
   @Input() showLabel: boolean = true;
   @Input() dataTestId: string;
 
+  public readonly validationErrorId: string = generateValidationErrorId();
+
   get variant(): string {
     return this.invalidParams.length > 0 ? 'error' : 'default';
   }
diff --git a/alfa-client/libs/design-component/src/lib/form/textarea-editor/textarea-editor.component.html b/alfa-client/libs/design-component/src/lib/form/textarea-editor/textarea-editor.component.html
index 39ac310964..753a2c385a 100644
--- a/alfa-client/libs/design-component/src/lib/form/textarea-editor/textarea-editor.component.html
+++ b/alfa-client/libs/design-component/src/lib/form/textarea-editor/textarea-editor.component.html
@@ -34,9 +34,11 @@
   [focus]="focus"
   [isResizable]="isResizable"
   [showLabel]="showLabel"
+  [ariaDescribedBy]="validationErrorId"
 >
   <ods-validation-error
     error
+    [id]="validationErrorId"
     [invalidParams]="invalidParams"
     [label]="label"
     [attr.data-test-id]="(label | convertForDataTest) + '-textarea-editor-error'"
diff --git a/alfa-client/libs/design-component/src/lib/form/textarea-editor/textarea-editor.component.ts b/alfa-client/libs/design-component/src/lib/form/textarea-editor/textarea-editor.component.ts
index c7032ef03b..9203da053f 100644
--- a/alfa-client/libs/design-component/src/lib/form/textarea-editor/textarea-editor.component.ts
+++ b/alfa-client/libs/design-component/src/lib/form/textarea-editor/textarea-editor.component.ts
@@ -21,7 +21,7 @@
  * Die sprachspezifischen Genehmigungen und Beschränkungen
  * unter der Lizenz sind dem Lizenztext zu entnehmen.
  */
-import { ConvertForDataTestPipe } from '@alfa-client/tech-shared';
+import { ConvertForDataTestPipe, generateValidationErrorId } from '@alfa-client/tech-shared';
 import { CommonModule } from '@angular/common';
 import { Component, Input } from '@angular/core';
 import { ReactiveFormsModule } from '@angular/forms';
@@ -44,6 +44,8 @@ export class TextareaEditorComponent extends FormControlEditorAbstractComponent
   @Input() isResizable: boolean = true;
   @Input() showLabel: boolean = true;
 
+  public readonly validationErrorId: string = generateValidationErrorId();
+
   get variant(): string {
     return this.invalidParams.length > 0 ? 'error' : 'default';
   }
diff --git a/alfa-client/libs/design-component/src/lib/form/validation-error/validation-error.component.html b/alfa-client/libs/design-component/src/lib/form/validation-error/validation-error.component.html
index 224dbe8cc5..cb566bafe0 100644
--- a/alfa-client/libs/design-component/src/lib/form/validation-error/validation-error.component.html
+++ b/alfa-client/libs/design-component/src/lib/form/validation-error/validation-error.component.html
@@ -24,5 +24,5 @@
 
 -->
 <ng-container *ngFor="let invalidParam of invalidParams"
-  ><ods-error-message [text]="message(invalidParam)"></ods-error-message
+  ><ods-error-message [id]="id" [text]="message(invalidParam)"></ods-error-message
 ></ng-container>
diff --git a/alfa-client/libs/design-component/src/lib/form/validation-error/validation-error.component.ts b/alfa-client/libs/design-component/src/lib/form/validation-error/validation-error.component.ts
index 8844b8075d..fca8d2a2ec 100644
--- a/alfa-client/libs/design-component/src/lib/form/validation-error/validation-error.component.ts
+++ b/alfa-client/libs/design-component/src/lib/form/validation-error/validation-error.component.ts
@@ -33,6 +33,7 @@ import { ErrorMessageComponent } from '@ods/system';
   templateUrl: './validation-error.component.html',
 })
 export class ValidationErrorComponent {
+  @Input({ required: true }) id: string;
   @Input() label: string;
   @Input() invalidParams: InvalidParam[];
 
diff --git a/alfa-client/libs/design-system/src/lib/form/checkbox/checkbox.component.ts b/alfa-client/libs/design-system/src/lib/form/checkbox/checkbox.component.ts
index ffadf796c9..3b3750bd31 100644
--- a/alfa-client/libs/design-system/src/lib/form/checkbox/checkbox.component.ts
+++ b/alfa-client/libs/design-system/src/lib/form/checkbox/checkbox.component.ts
@@ -21,7 +21,7 @@
  * Die sprachspezifischen Genehmigungen und Beschränkungen
  * unter der Lizenz sind dem Lizenztext zu entnehmen.
  */
-import { ConvertForDataTestPipe } from '@alfa-client/tech-shared';
+import { ConvertForDataTestPipe, EMPTY_STRING } from '@alfa-client/tech-shared';
 import { CommonModule } from '@angular/common';
 import { Component, Input } from '@angular/core';
 import { FormControl, ReactiveFormsModule } from '@angular/forms';
@@ -45,6 +45,7 @@ import { FormControl, ReactiveFormsModule } from '@angular/forms';
           [attr.id]="inputId"
           [attr.disabled]="disabled ? true : null"
           [attr.data-test-id]="(label | convertForDataTest) + '-checkbox-editor'"
+          [attr.aria-describedby]="ariaDescribedBy"
         />
         <label class="leading-5 text-text" [attr.for]="inputId">{{ label }}</label>
         <svg
@@ -69,4 +70,5 @@ export class CheckboxComponent {
   @Input() label: string;
   @Input() disabled: boolean = false;
   @Input() hasError: boolean = false;
+  @Input() ariaDescribedBy: string = EMPTY_STRING;
 }
diff --git a/alfa-client/libs/design-system/src/lib/form/error-message/error-message.component.ts b/alfa-client/libs/design-system/src/lib/form/error-message/error-message.component.ts
index 66ed08e7f9..8c33dd8452 100644
--- a/alfa-client/libs/design-system/src/lib/form/error-message/error-message.component.ts
+++ b/alfa-client/libs/design-system/src/lib/form/error-message/error-message.component.ts
@@ -21,6 +21,7 @@
  * Die sprachspezifischen Genehmigungen und Beschränkungen
  * unter der Lizenz sind dem Lizenztext zu entnehmen.
  */
+import { EMPTY_STRING } from '@alfa-client/tech-shared';
 import { CommonModule } from '@angular/common';
 import { Component, Input } from '@angular/core';
 import { ExclamationIconComponent } from '../../icons/exclamation-icon/exclamation-icon.component';
@@ -31,13 +32,14 @@ import { ExclamationIconComponent } from '../../icons/exclamation-icon/exclamati
   imports: [CommonModule, ExclamationIconComponent],
   styles: [':host {@apply flex text-error my-2 text-sm items-center font-medium}'],
   template: `<ods-exclamation-icon class="mr-1"></ods-exclamation-icon>
-    <div class="flex-grow break-all">
+    <div class="flex-grow break-all" [id]="id">
       {{ text }}
       <br *ngIf="subText" aria-hidden="true" />
       {{ subText }}
     </div> `,
 })
 export class ErrorMessageComponent {
+  @Input() id: string = EMPTY_STRING;
   @Input({ required: true }) text!: string;
   @Input() subText: string = '';
 }
diff --git a/alfa-client/libs/design-system/src/lib/form/text-input/text-input.component.ts b/alfa-client/libs/design-system/src/lib/form/text-input/text-input.component.ts
index cd01ba0190..4a8ddff099 100644
--- a/alfa-client/libs/design-system/src/lib/form/text-input/text-input.component.ts
+++ b/alfa-client/libs/design-system/src/lib/form/text-input/text-input.component.ts
@@ -62,6 +62,7 @@ type TextInputVariants = VariantProps<typeof textInputVariants>;
           <ng-content select="[prefix]" />
         </div>
         <input
+          #inputElement
           type="text"
           [id]="id"
           [formControl]="fieldControl"
@@ -71,8 +72,8 @@ type TextInputVariants = VariantProps<typeof textInputVariants>;
           [attr.aria-required]="required"
           [attr.aria-invalid]="variant === 'error'"
           [attr.data-test-id]="_dataTestId + '-text-input'"
+          [attr.aria-describedby]="ariaDescribedBy"
           (click)="clickEmitter.emit()"
-          #inputElement
         />
         <div *ngIf="withSuffix" class="absolute bottom-2 right-2 flex size-6 items-center justify-center">
           <ng-content select="[suffix]" />
@@ -102,6 +103,7 @@ export class TextInputComponent implements AfterViewInit {
   @Input() set dataTestId(value: string) {
     if (isNotUndefined(value)) this._dataTestId = value;
   }
+  @Input() ariaDescribedBy: string = EMPTY_STRING;
 
   @Output() clickEmitter: EventEmitter<MouseEvent> = new EventEmitter<MouseEvent>();
 
diff --git a/alfa-client/libs/design-system/src/lib/form/textarea/textarea.component.ts b/alfa-client/libs/design-system/src/lib/form/textarea/textarea.component.ts
index f3eb84af9d..d3c04b6c82 100644
--- a/alfa-client/libs/design-system/src/lib/form/textarea/textarea.component.ts
+++ b/alfa-client/libs/design-system/src/lib/form/textarea/textarea.component.ts
@@ -57,6 +57,7 @@ type TextareaVariants = VariantProps<typeof textareaVariants>;
         {{ inputLabel }}<ng-container *ngIf="required"><i aria-hidden="true">*</i></ng-container>
       </label>
       <textarea
+        #textAreaElement
         [id]="id"
         [formControl]="fieldControl"
         [rows]="rows"
@@ -66,7 +67,7 @@ type TextareaVariants = VariantProps<typeof textareaVariants>;
         [attr.aria-required]="required"
         [attr.aria-invalid]="variant === 'error'"
         [attr.data-test-id]="(inputLabel | convertForDataTest) + '-textarea'"
-        #textAreaElement
+        [attr.aria-describedby]="ariaDescribedBy"
       ></textarea>
       <ng-content select="[error]"></ng-content>
     </div>
@@ -94,6 +95,7 @@ export class TextareaComponent {
       this.textAreaElement.nativeElement.focus();
     }
   }
+  @Input() ariaDescribedBy: string = EMPTY_STRING;
 
   inputLabel: string;
   id: string;
diff --git a/alfa-client/libs/tech-shared/src/lib/validation/tech.validation.util.ts b/alfa-client/libs/tech-shared/src/lib/validation/tech.validation.util.ts
index b0cb78f8a1..7f8f416bf9 100644
--- a/alfa-client/libs/tech-shared/src/lib/validation/tech.validation.util.ts
+++ b/alfa-client/libs/tech-shared/src/lib/validation/tech.validation.util.ts
@@ -22,7 +22,7 @@
  * unter der Lizenz sind dem Lizenztext zu entnehmen.
  */
 import { AbstractControl, UntypedFormGroup } from '@angular/forms';
-import { isEmpty, isNil } from 'lodash-es';
+import { isEmpty, isNil, uniqueId } from 'lodash-es';
 import { ApiError, InvalidParam, Issue, IssueParam, ProblemDetail } from '../tech.model';
 import { replacePlaceholder } from '../tech.util';
 import { VALIDATION_MESSAGES, ValidationMessageCode } from './tech.validation.messages';
@@ -31,28 +31,18 @@ export function isValidationError(issue: Issue): boolean {
   return issue.messageCode.includes('javax.validation.constraints');
 }
 
-export function setIssueValidationError(
-  form: UntypedFormGroup,
-  issue: Issue,
-  pathPrefix?: string,
-): void {
+export function setIssueValidationError(form: UntypedFormGroup, issue: Issue, pathPrefix?: string): void {
   const control: AbstractControl = getControlForIssue(form, issue, pathPrefix);
 
   control.setErrors({ [issue.messageCode]: issue });
   control.markAsTouched();
 }
 
-export function getControlForIssue(
-  form: UntypedFormGroup,
-  issue: Issue,
-  pathPrefix?: string,
-): AbstractControl {
+export function getControlForIssue(form: UntypedFormGroup, issue: Issue, pathPrefix?: string): AbstractControl {
   const fieldPath: string = getFieldPath(issue.field, pathPrefix);
 
   let curControl: AbstractControl = form;
-  fieldPath
-    .split('.')
-    .forEach((field) => (curControl = (<UntypedFormGroup>curControl).controls[field]));
+  fieldPath.split('.').forEach((field) => (curControl = (<UntypedFormGroup>curControl).controls[field]));
 
   return curControl;
 }
@@ -66,9 +56,7 @@ export function getMessageForIssue(label: string, issue: Issue): string {
   }
 
   msg = replacePlaceholder(msg, 'field', label);
-  issue.parameters.forEach(
-    (param: IssueParam) => (msg = replacePlaceholder(msg, param.name, param.value)),
-  );
+  issue.parameters.forEach((param: IssueParam) => (msg = replacePlaceholder(msg, param.name, param.value)));
   return msg;
 }
 
@@ -84,11 +72,7 @@ export function getMessageCode(apiError: ApiError): string {
   return apiError.issues[0].messageCode;
 }
 
-export function setInvalidParamValidationError(
-  form: UntypedFormGroup,
-  invalidParam: InvalidParam,
-  pathPrefix?: string,
-): void {
+export function setInvalidParamValidationError(form: UntypedFormGroup, invalidParam: InvalidParam, pathPrefix?: string): void {
   const control: AbstractControl = getControlForInvalidParam(form, invalidParam, pathPrefix);
 
   control.setErrors({ [invalidParam.reason]: invalidParam });
@@ -112,9 +96,7 @@ export function getMessageForInvalidParam(label: string, invalidParam: InvalidPa
   }
 
   msg = replacePlaceholder(msg, 'field', label);
-  invalidParam.constraintParameters.forEach(
-    (param: IssueParam) => (msg = replacePlaceholder(msg, param.name, param.value)),
-  );
+  invalidParam.constraintParameters.forEach((param: IssueParam) => (msg = replacePlaceholder(msg, param.name, param.value)));
   return msg;
 }
 
@@ -126,3 +108,7 @@ export function getFieldPath(name: string, pathPrefix: string): string {
   const indexOfField = name.lastIndexOf(pathPrefix) + pathPrefix.length + 1;
   return name.slice(indexOfField);
 }
+
+export function generateValidationErrorId(): string {
+  return `${uniqueId()}-validation-error`;
+}
-- 
GitLab


From e45895e9dc41360a0d4fc6a6be5ab2172be36eb6 Mon Sep 17 00:00:00 2001
From: Oliver Schmidt <kontakt@webkreation.de>
Date: Tue, 18 Mar 2025 18:39:29 +0100
Subject: [PATCH 19/22] OZG-7591-7932 opt form ui and a11y

---
 ...-organisations-einheit-list.component.html | 21 +++++++++-------
 .../user-form-roles.component.html            | 24 ++++++++++++-------
 .../user-form-roles.component.ts              |  5 +++-
 3 files changed, 31 insertions(+), 19 deletions(-)

diff --git a/alfa-client/libs/admin/user/src/lib/user-form/user-form-organisations-einheit-list/user-form-organisations-einheit-list.component.html b/alfa-client/libs/admin/user/src/lib/user-form/user-form-organisations-einheit-list/user-form-organisations-einheit-list.component.html
index 5d3fea2f35..2c1d3520d1 100644
--- a/alfa-client/libs/admin/user/src/lib/user-form/user-form-organisations-einheit-list/user-form-organisations-einheit-list.component.html
+++ b/alfa-client/libs/admin/user/src/lib/user-form/user-form-organisations-einheit-list/user-form-organisations-einheit-list.component.html
@@ -1,11 +1,14 @@
-<div class="mb-12 block">
-  <h2 class="heading-2 mt-4">Organisationseinheiten</h2>
-  <div [formGroup]="formGroupParent">
-    <div [formGroupName]="UserFormService.GROUPS" class="flex flex-col gap-2">
-      <p class="font-medium">Dem Benutzer sind folgende Organisationseinheiten zugewiesen</p>
-      @for (controlName of formGroupOrganisationsEinheiten.controls | keyvalue; track controlName.key) {
-        <ods-checkbox-editor [formControlName]="controlName.key" [label]="controlName.key" [inputId]="controlName.key" />
-      }
-    </div>
+<div
+  [formGroup]="formGroupParent"
+  role="group"
+  aria-labelledby="organisationseinheiten-heading organisationseinheiten-desc"
+  class="mb-12"
+>
+  <h2 id="organisationseinheiten-heading" class="heading-2 mt-4">Organisationseinheiten</h2>
+  <p id="organisationseinheiten-desc" class="mb-2 font-medium">Dem Benutzer sind folgende Organisationseinheiten zugewiesen</p>
+  <div [formGroupName]="UserFormService.GROUPS" class="flex flex-col gap-2">
+    @for (controlName of formGroupOrganisationsEinheiten.controls | keyvalue; track controlName.key) {
+      <ods-checkbox-editor [formControlName]="controlName.key" [label]="controlName.key" [inputId]="controlName.key" />
+    }
   </div>
 </div>
diff --git a/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.html b/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.html
index f410d02855..d3d39a71ce 100644
--- a/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.html
+++ b/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.html
@@ -1,12 +1,12 @@
-<h2 class="heading-2 mt-4">Rollen für OZG-Cloud *</h2>
-<ods-validation-error
-  [id]="validationErrorId"
-  [invalidParams]="invalidParams$ | async"
-  label="Rollen"
-  data-test-id="rollen-error"
-></ods-validation-error>
-<div [formGroup]="formGroupParent">
-  <div [formGroupName]="UserFormService.CLIENT_ROLES" class="mb-8 flex flex-col gap-4 md:flex-row">
+<h2 class="heading-2 mt-4" id="rollen-ozg-heading">Rollen für OZG-Cloud<i aria-hidden="true">*</i></h2>
+<div
+  [formGroup]="formGroupParent"
+  role="group"
+  aria-required="true"
+  aria-labelledby="rollen-ozg-heading"
+  [attr.aria-invalid]="!isValid"
+>
+  <div [formGroupName]="UserFormService.CLIENT_ROLES" class="flex flex-col gap-4 md:flex-row">
     <div [formGroupName]="UserFormService.ADMINISTRATION_GROUP" class="flex flex-1 flex-col gap-2">
       <h3 class="text-md block font-medium text-text">Administration</h3>
       <div class="flex items-center gap-2">
@@ -60,4 +60,10 @@
       </div>
     </div>
   </div>
+  <ods-validation-error
+    [id]="validationErrorId"
+    [invalidParams]="invalidParams$ | async"
+    label="Rollen"
+    data-test-id="rollen-error"
+  ></ods-validation-error>
 </div>
diff --git a/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.ts b/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.ts
index 37336816f4..7a8c901dab 100644
--- a/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.ts
+++ b/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.ts
@@ -4,7 +4,8 @@ import { Component, Input, OnInit } from '@angular/core';
 import { AbstractControl, FormControlStatus, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms';
 import { CheckboxEditorComponent, ValidationErrorComponent } from '@ods/component';
 import { InfoIconComponent, TooltipDirective } from '@ods/system';
-import { map, Observable, of } from 'rxjs';
+import { isEmpty } from 'lodash-es';
+import { map, Observable, of, tap } from 'rxjs';
 import { UserFormService } from '../user.formservice';
 
 @Component({
@@ -27,11 +28,13 @@ export class UserFormRolesComponent implements OnInit {
 
   public readonly UserFormService = UserFormService;
   public readonly validationErrorId: string = generateValidationErrorId();
+  public isValid: boolean = true;
 
   ngOnInit(): void {
     const control: AbstractControl = this.formGroupParent.controls[UserFormService.CLIENT_ROLES];
     this.invalidParams$ = control.statusChanges.pipe(
       map((status: FormControlStatus) => (status === 'INVALID' ? Object.values(control.errors) : [])),
+      tap((invalidParams: InvalidParam[]) => (this.isValid = isEmpty(invalidParams))),
     );
   }
 }
-- 
GitLab


From 21337e8ce70ecc6a2871ef4225b1ca7165376082 Mon Sep 17 00:00:00 2001
From: sebo <sebastian.bergandy@external.mgm-cp.com>
Date: Tue, 18 Mar 2025 19:40:42 +0100
Subject: [PATCH 20/22] OZG-7591 add tests

---
 .../user-form-roles.component.spec.ts         | 28 +++++++++++++++++++
 1 file changed, 28 insertions(+)

diff --git a/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.spec.ts b/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.spec.ts
index bc6c77336f..5f21edb7a5 100644
--- a/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.spec.ts
+++ b/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.spec.ts
@@ -62,6 +62,34 @@ describe('UserFormRolesComponent', () => {
 
         control.setErrors(null);
       });
+
+      it('should mark as invalid', (done) => {
+        const invalidParam: InvalidParam = createInvalidParam();
+        const error: any = { dummy: invalidParam };
+        const control: AbstractControl = new FormControl();
+        component.formGroupParent = new UntypedFormGroup({ [UserFormService.CLIENT_ROLES]: control });
+
+        component.ngOnInit();
+        component.invalidParams$.subscribe(() => {
+          expect(component.isValid).toBe(false);
+          done();
+        });
+
+        control.setErrors(error);
+      });
+
+      it('should mark as valid', (done) => {
+        const control: AbstractControl = new FormControl();
+        component.formGroupParent = new UntypedFormGroup({ [UserFormService.CLIENT_ROLES]: control });
+
+        component.ngOnInit();
+        component.invalidParams$.subscribe(() => {
+          expect(component.isValid).toBe(true);
+          done();
+        });
+
+        control.setErrors(null);
+      });
     });
   });
 
-- 
GitLab


From ea1124f548d3935e9d43946619f86c0240a15cf7 Mon Sep 17 00:00:00 2001
From: Oliver Schmidt <kontakt@webkreation.de>
Date: Fri, 21 Mar 2025 12:47:02 +0100
Subject: [PATCH 21/22] OZG-7591 fix error massage break

---
 .../src/lib/form/error-message/error-message.component.ts       | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/alfa-client/libs/design-system/src/lib/form/error-message/error-message.component.ts b/alfa-client/libs/design-system/src/lib/form/error-message/error-message.component.ts
index 8c33dd8452..7e8cc5ae0e 100644
--- a/alfa-client/libs/design-system/src/lib/form/error-message/error-message.component.ts
+++ b/alfa-client/libs/design-system/src/lib/form/error-message/error-message.component.ts
@@ -32,7 +32,7 @@ import { ExclamationIconComponent } from '../../icons/exclamation-icon/exclamati
   imports: [CommonModule, ExclamationIconComponent],
   styles: [':host {@apply flex text-error my-2 text-sm items-center font-medium}'],
   template: `<ods-exclamation-icon class="mr-1"></ods-exclamation-icon>
-    <div class="flex-grow break-all" [id]="id">
+    <div class="flex-grow break-words" [id]="id">
       {{ text }}
       <br *ngIf="subText" aria-hidden="true" />
       {{ subText }}
-- 
GitLab


From 4f427a735744a3117c6f6d9f08418d63ffcc1335 Mon Sep 17 00:00:00 2001
From: sebo <sebastian.bergandy@external.mgm-cp.com>
Date: Mon, 24 Mar 2025 12:59:33 +0100
Subject: [PATCH 22/22] OZG-7591 fix e2e

---
 .../benutzer_rollen/benutzer-anlegen.cy.ts         | 14 +++++++-------
 1 file changed, 7 insertions(+), 7 deletions(-)

diff --git a/alfa-client/apps/admin-e2e/src/e2e/main-tests/benutzer_rollen/benutzer-anlegen.cy.ts b/alfa-client/apps/admin-e2e/src/e2e/main-tests/benutzer_rollen/benutzer-anlegen.cy.ts
index 9caecfb235..e659bf8dde 100644
--- a/alfa-client/apps/admin-e2e/src/e2e/main-tests/benutzer_rollen/benutzer-anlegen.cy.ts
+++ b/alfa-client/apps/admin-e2e/src/e2e/main-tests/benutzer_rollen/benutzer-anlegen.cy.ts
@@ -185,15 +185,15 @@ describe('Benutzer anlegen', () => {
     it('should have user created', () => {
       benutzerHelper.openNewBenutzerPage();
 
-      benutzerHelper.addBenutzer(newUser);
+      benutzerHelper.addBenutzer(newAdminUser);
       benutzerHelper.saveBenutzer();
 
-      benutzerVerifier.verifyUserInList(newUser);
+      benutzerVerifier.verifyUserInList(newAdminUser);
     });
 
     it('should show validation error on existing email', () => {
       benutzerHelper.openNewBenutzerPage();
-      benutzerHelper.addBenutzer(newUser);
+      benutzerHelper.addBenutzer(newAdminUser);
       benutzerHelper.saveBenutzer();
 
       benutzerVerifier.verifyEmailExistsError();
@@ -201,17 +201,17 @@ describe('Benutzer anlegen', () => {
 
     it('should show validation error on existing username', () => {
       benutzerHelper.openNewBenutzerPage();
-      benutzerHelper.addBenutzer({ ...newUser, email: faker.internet.email() + '.local' });
+      benutzerHelper.addBenutzer({ ...newAdminUser, email: faker.internet.email() + '.local' });
       benutzerHelper.saveBenutzer();
 
       benutzerVerifier.verifyUsernameExistsError();
     });
 
     it('should remove benutzer', () => {
-      benutzerHelper.openBenutzerPage(newUser.username);
-      benutzerHelper.deleteBenutzer(newUser.username);
+      benutzerHelper.openBenutzerPage(newAdminUser.username);
+      benutzerHelper.deleteBenutzer(newAdminUser.username);
 
-      benutzerVerifier.verifyUserNotInList(newUser.username);
+      benutzerVerifier.verifyUserNotInList(newAdminUser.username);
     });
   });
 });
-- 
GitLab