From 4c0cc7faed8608039dd08a9448610a5283bc407f Mon Sep 17 00:00:00 2001 From: edraft Date: Thu, 1 May 2025 16:04:22 +0200 Subject: [PATCH] Manage user space users #15 --- .../field/mutation_field_builder.py | 2 +- api/src/api_graphql/graphql/user_space.gql | 2 + .../mutations/user_space_mutation.py | 28 ++++++++-- .../api_graphql/queries/user_space_query.py | 4 +- .../schemas/public/user_space_user_dao.py | 4 +- api/src/service/data_privacy_service.py | 1 + web/src/app/core/token.interceptor.ts | 2 +- .../user-space-form-page.component.html | 18 ++++++ .../user-space-form-page.component.ts | 32 ++++++++++- .../user-spaces/user-spaces.data.service.ts | 28 ++++++++++ .../chip-input/chip-input.component.html | 15 +++++ .../chip-input/chip-input.component.scss | 0 .../chip-input/chip-input.component.spec.ts | 23 ++++++++ .../chip-input/chip-input.component.ts | 55 +++++++++++++++++++ web/src/app/modules/shared/shared.module.ts | 5 +- web/src/assets/i18n/de.json | 7 +++ web/src/assets/i18n/en.json | 7 +++ web/src/styles.scss | 5 ++ web/src/styles/theme.scss | 2 +- 19 files changed, 224 insertions(+), 16 deletions(-) create mode 100644 web/src/app/modules/shared/components/chip-input/chip-input.component.html create mode 100644 web/src/app/modules/shared/components/chip-input/chip-input.component.scss create mode 100644 web/src/app/modules/shared/components/chip-input/chip-input.component.spec.ts create mode 100644 web/src/app/modules/shared/components/chip-input/chip-input.component.ts diff --git a/api/src/api_graphql/field/mutation_field_builder.py b/api/src/api_graphql/field/mutation_field_builder.py index 245045f..8ee5330 100644 --- a/api/src/api_graphql/field/mutation_field_builder.py +++ b/api/src/api_graphql/field/mutation_field_builder.py @@ -60,7 +60,7 @@ class MutationFieldBuilder(FieldBuilderABC): def with_input( self, - input_type: Type[Union[InputABC, str, int, bool]], + input_type: Type[Union[InputABC, str, int, bool, list]], input_key: str = "input", ) -> Self: self._input_type = input_type diff --git a/api/src/api_graphql/graphql/user_space.gql b/api/src/api_graphql/graphql/user_space.gql index cc28baf..d86cc5c 100644 --- a/api/src/api_graphql/graphql/user_space.gql +++ b/api/src/api_graphql/graphql/user_space.gql @@ -74,6 +74,8 @@ type UserSpaceMutation { update(input: UserSpaceUpdateInput!): UserSpace delete(id: Int!): Boolean restore(id: Int!): Boolean + + inviteUsers(emails: [String!]!): Boolean } input UserSpaceCreateInput { diff --git a/api/src/api_graphql/mutations/user_space_mutation.py b/api/src/api_graphql/mutations/user_space_mutation.py index 36690db..bc1dba8 100644 --- a/api/src/api_graphql/mutations/user_space_mutation.py +++ b/api/src/api_graphql/mutations/user_space_mutation.py @@ -3,6 +3,7 @@ from api_graphql.abc.mutation_abc import MutationABC from api_graphql.field.mutation_field_builder import MutationFieldBuilder from api_graphql.input.user_space_create_input import UserSpaceCreateInput from api_graphql.input.user_space_update_input import UserSpaceUpdateInput +from api_graphql.service.query_context import QueryContext from core.logger import APILogger from core.string import first_to_lower from data.schemas.administration.user_dao import userDao @@ -26,9 +27,11 @@ class UserSpaceMutation(MutationABC): .with_change_broadcast( f"{first_to_lower(self.name.replace("Mutation", ""))}Change" ) - .with_require_any_permission([Permissions.user_spaces_create]) ) + async def _xzy(ctx: QueryContext): + pass + self.field( MutationFieldBuilder("update") .with_resolver(self.resolve_update) @@ -36,7 +39,7 @@ class UserSpaceMutation(MutationABC): .with_change_broadcast( f"{first_to_lower(self.name.replace("Mutation", ""))}Change" ) - .with_require_any_permission([Permissions.user_spaces_update]) + .with_require_any([Permissions.user_spaces_update], [_xzy]) ) self.field( @@ -45,7 +48,7 @@ class UserSpaceMutation(MutationABC): .with_change_broadcast( f"{first_to_lower(self.name.replace("Mutation", ""))}Change" ) - .with_require_any_permission([Permissions.user_spaces_delete]) + .with_require_any([Permissions.user_spaces_delete], [_xzy]) ) self.field( @@ -57,6 +60,18 @@ class UserSpaceMutation(MutationABC): .with_require_any_permission([Permissions.user_spaces_delete]) ) + self.field( + MutationFieldBuilder("inviteUsers") + .with_resolver(self.resolve_invite_users) + .with_input(list, "emails") + .with_change_broadcast( + f"{first_to_lower(self.name.replace("Mutation", ""))}Change" + ) + .with_require_any( + [Permissions.user_spaces_create, Permissions.user_spaces_update], [_xzy] + ) + ) + async def resolve_create(self, obj: UserSpaceCreateInput, *_): logger.debug(f"create user_space: {obj.__dict__}") @@ -88,9 +103,7 @@ class UserSpaceMutation(MutationABC): user_space = await userSpaceDao.get_by_id(obj.id) if obj.name is not None: - already_exists = await userSpaceDao.find_by( - {UserSpace.name: obj.name, UserSpace.id: {"ne": obj.id}} - ) + already_exists = await userSpaceDao.find_by({UserSpace.name: obj.name}) if len(already_exists) > 0: raise ValueError(f"UserSpace {obj.name} already exists") @@ -123,3 +136,6 @@ class UserSpaceMutation(MutationABC): user_space = await userSpaceDao.get_by_id(id) await userSpaceDao.restore(user_space) return True + + async def resolve_invite_users(self, emails: list[str], *_): + pass diff --git a/api/src/api_graphql/queries/user_space_query.py b/api/src/api_graphql/queries/user_space_query.py index cdd1b72..b443df4 100644 --- a/api/src/api_graphql/queries/user_space_query.py +++ b/api/src/api_graphql/queries/user_space_query.py @@ -17,5 +17,5 @@ class UserSpaceQuery(DbModelQueryABC): self.set_history_reference_dao(shortUrlDao, "userspaceid") @staticmethod - async def _get_users(group: UserSpace, *_): - return await userSpaceDao.get(group.id) + async def _get_users(space: UserSpace, *_): + return await userSpaceDao.get_users(space.id) diff --git a/api/src/data/schemas/public/user_space_user_dao.py b/api/src/data/schemas/public/user_space_user_dao.py index 8ed77bc..b9a6bdc 100644 --- a/api/src/data/schemas/public/user_space_user_dao.py +++ b/api/src/data/schemas/public/user_space_user_dao.py @@ -10,8 +10,8 @@ class UserSpaceUserDao(DbModelDaoABC[UserSpaceUser]): DbModelDaoABC.__init__( self, __name__, UserSpaceUser, "public.user_spaces_users" ) - self.attribute(UserSpaceUser.user_space_id, str) - self.attribute(UserSpaceUser.user_id, str) + self.attribute(UserSpaceUser.user_space_id, int) + self.attribute(UserSpaceUser.user_id, int) async def get_by_user_space_id( self, user_space_id: str, with_deleted=False diff --git a/api/src/service/data_privacy_service.py b/api/src/service/data_privacy_service.py index e0d6d82..509c024 100644 --- a/api/src/service/data_privacy_service.py +++ b/api/src/service/data_privacy_service.py @@ -78,6 +78,7 @@ class DataPrivacyService: # Anonymize internal data await user.anonymize() + await userDao.delete(user) # Anonymize external data try: diff --git a/web/src/app/core/token.interceptor.ts b/web/src/app/core/token.interceptor.ts index 776d13e..443d5d6 100644 --- a/web/src/app/core/token.interceptor.ts +++ b/web/src/app/core/token.interceptor.ts @@ -20,6 +20,7 @@ export const tokenInterceptor: HttpInterceptorFn = (req, next) => { return next(req); } + const auth = inject(AuthService); return from(keycloak.getToken()).pipe( switchMap(token => { if (!token) { @@ -46,7 +47,6 @@ export const tokenInterceptor: HttpInterceptorFn = (req, next) => { ); }), catchError(() => { - const auth = inject(AuthService); auth.logout().then(); return next(req); }) diff --git a/web/src/app/modules/admin/user-spaces/form-page/user-space-form-page.component.html b/web/src/app/modules/admin/user-spaces/form-page/user-space-form-page.component.html index d9bc903..a12e9e2 100644 --- a/web/src/app/modules/admin/user-spaces/form-page/user-space-form-page.component.html +++ b/web/src/app/modules/admin/user-spaces/form-page/user-space-form-page.component.html @@ -27,5 +27,23 @@ type="text" formControlName="name"/> +
+
+

{{ 'user_space.assign_users' | translate }}

+
+
+ +
+
+
+ {{ 'table.no_entries_found' | translate }} +
+
+
+

{{ 'user_space.invite_users' | translate }}

+
+ +
+
diff --git a/web/src/app/modules/admin/user-spaces/form-page/user-space-form-page.component.ts b/web/src/app/modules/admin/user-spaces/form-page/user-space-form-page.component.ts index 7a5fdf7..77692d7 100644 --- a/web/src/app/modules/admin/user-spaces/form-page/user-space-form-page.component.ts +++ b/web/src/app/modules/admin/user-spaces/form-page/user-space-form-page.component.ts @@ -8,6 +8,8 @@ import { UserSpaceUpdateInput, } from 'src/app/model/entities/user-space'; import { UserSpacesDataService } from 'src/app/modules/admin/user-spaces/user-spaces.data.service'; +import { User } from 'src/app/model/auth/user'; +import { catchError } from 'rxjs/operators'; @Component({ selector: 'app-user-space-form-page', @@ -45,6 +47,7 @@ export class UserSpaceFormPageComponent extends FormPageBase< name: new FormControl(undefined, [ Validators.required, ]), + emailsToInvite: new FormControl([]), }); this.form.controls['id'].disable(); } @@ -59,6 +62,7 @@ export class UserSpaceFormPageComponent extends FormPageBase< getCreateInput(): UserSpaceCreateInput { return { name: this.form.controls['name'].value ?? undefined, + users: this.node.users?.map(x => x.id) ?? [], }; } @@ -69,11 +73,36 @@ export class UserSpaceFormPageComponent extends FormPageBase< return { id: this.form.controls['id'].value, - name: this.form.controls['name'].value ?? undefined, + name: this.form.controls['name'].pristine + ? undefined + : this.form.controls['name'].value, + users: this.node.users?.map(x => x.id) ?? [], }; } + protected handleEMailInvitation() { + const emailsToInvite = this.form.controls['emailsToInvite'].value; + if (!(emailsToInvite && emailsToInvite.length > 0)) { + return; + } + + this.dataService + .inviteUsers(emailsToInvite) + .pipe( + catchError(err => { + this.spinner.hide(); + this.toast.error('action.failed', 'user_space.invite_users'); + throw err; + }) + ) + .subscribe(() => { + this.spinner.hide(); + this.toast.success('user_space.invited_users'); + }); + } + create(object: UserSpaceCreateInput): void { + this.handleEMailInvitation(); this.dataService.create(object).subscribe(() => { this.spinner.hide(); this.toast.success('action.created'); @@ -82,6 +111,7 @@ export class UserSpaceFormPageComponent extends FormPageBase< } update(object: UserSpaceUpdateInput): void { + this.handleEMailInvitation(); this.dataService.update(object).subscribe(() => { this.spinner.hide(); this.toast.success('action.created'); diff --git a/web/src/app/modules/admin/user-spaces/user-spaces.data.service.ts b/web/src/app/modules/admin/user-spaces/user-spaces.data.service.ts index d78ce6e..8b5726c 100644 --- a/web/src/app/modules/admin/user-spaces/user-spaces.data.service.ts +++ b/web/src/app/modules/admin/user-spaces/user-spaces.data.service.ts @@ -85,6 +85,11 @@ export class UserSpacesDataService id name + users { + id + username + } + ...DB_MODEL } } @@ -276,6 +281,29 @@ export class UserSpacesDataService .pipe(map(result => result.data?.userSpace.restore ?? false)); } + inviteUsers(emails: string[]): Observable { + return this.apollo + .mutate<{ userSpace: { inviteUsers: boolean } }>({ + mutation: gql` + mutation inviteUsers($emails: [String!]!) { + userSpace { + inviteUsers(emails: $emails) + } + } + `, + variables: { + emails, + }, + }) + .pipe( + catchError(err => { + this.spinner.hide(); + throw err; + }) + ) + .pipe(map(result => result.data?.userSpace.inviteUsers ?? false)); + } + static provide(): Provider[] { return [ { diff --git a/web/src/app/modules/shared/components/chip-input/chip-input.component.html b/web/src/app/modules/shared/components/chip-input/chip-input.component.html new file mode 100644 index 0000000..b404629 --- /dev/null +++ b/web/src/app/modules/shared/components/chip-input/chip-input.component.html @@ -0,0 +1,15 @@ +
+
+ + + +
+
\ No newline at end of file diff --git a/web/src/app/modules/shared/components/chip-input/chip-input.component.scss b/web/src/app/modules/shared/components/chip-input/chip-input.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/web/src/app/modules/shared/components/chip-input/chip-input.component.spec.ts b/web/src/app/modules/shared/components/chip-input/chip-input.component.spec.ts new file mode 100644 index 0000000..419f281 --- /dev/null +++ b/web/src/app/modules/shared/components/chip-input/chip-input.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ChipInputComponent } from './chip-input.component'; + +describe('ChipInputComponent', () => { + let component: ChipInputComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ChipInputComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ChipInputComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/web/src/app/modules/shared/components/chip-input/chip-input.component.ts b/web/src/app/modules/shared/components/chip-input/chip-input.component.ts new file mode 100644 index 0000000..419957c --- /dev/null +++ b/web/src/app/modules/shared/components/chip-input/chip-input.component.ts @@ -0,0 +1,55 @@ +import { Component, Input, forwardRef } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; + +@Component({ + selector: 'app-chip-input', + templateUrl: './chip-input.component.html', + styleUrls: ['./chip-input.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ChipInputComponent), + multi: true, + }, + ], +}) +export class ChipInputComponent implements ControlValueAccessor { + @Input() type: string = 'text'; + @Input() placeholder?: string; + chips: string[] = []; + inputValue: string = ''; + + protected onChange: (value: string[]) => void = () => {}; + protected onTouched: () => void = () => {}; + + writeValue(value: string[]): void { + this.chips = value || []; + } + + registerOnChange(fn: (value: string[]) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + addChip(): void { + if (!this.inputValue.trim()) { + return; + } + + if (this.type === 'number' && isNaN(Number(this.inputValue.trim()))) { + return; // Invalid number, do not add + } + if ( + this.type === 'email' && + !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.inputValue.trim()) + ) { + return; // Invalid email, do not add + } + this.chips.push(this.inputValue.trim()); + this.inputValue = ''; + this.onChange(this.chips); + } +} diff --git a/web/src/app/modules/shared/shared.module.ts b/web/src/app/modules/shared/shared.module.ts index d221894..73725b9 100644 --- a/web/src/app/modules/shared/shared.module.ts +++ b/web/src/app/modules/shared/shared.module.ts @@ -72,6 +72,7 @@ import { getMainDefinition } from '@apollo/client/utilities'; import { Kind, OperationTypeNode } from 'graphql/index'; import { HistorySidebarComponent } from 'src/app/modules/shared/components/history/history-sidebar.component'; import { SliderModule } from 'primeng/slider'; +import { ChipInputComponent } from './components/chip-input/chip-input.component'; const sharedModules = [ StepsModule, @@ -146,9 +147,9 @@ function debounce(func: (...args: unknown[]) => void, wait: number) { } @NgModule({ - declarations: [...sharedComponents], + declarations: [...sharedComponents, ChipInputComponent], imports: [CommonModule, ...sharedModules], - exports: [...sharedModules, ...sharedComponents], + exports: [...sharedModules, ...sharedComponents, ChipInputComponent], providers: [ provideHttpClient(withInterceptors([tokenInterceptor])), provideApollo(() => { diff --git a/web/src/assets/i18n/de.json b/web/src/assets/i18n/de.json index e76cf8b..f48c939 100644 --- a/web/src/assets/i18n/de.json +++ b/web/src/assets/i18n/de.json @@ -2,6 +2,7 @@ "action": { "created": "Erstellt", "deleted": "Gelöscht", + "failed": "Fehlgeschlagen", "restored": "Wiederhergestellt", "updated": "Geändert" }, @@ -28,6 +29,7 @@ "domains": "Domains", "download": "Herunterladen", "editor": "Bearbeiter", + "enter_emails": "E-Mails eintragen", "error": "Fehler", "group": "Gruppe", "groups": "Gruppen", @@ -270,5 +272,10 @@ "keycloak_id": "Keycloak Id", "user": "Benutzer", "username": "Benutzername" + }, + "user_space": { + "assign_users": "Benutzer", + "invite_users": "Benutzer einladen", + "invited_users": "Benutzer eingeladen" } } \ No newline at end of file diff --git a/web/src/assets/i18n/en.json b/web/src/assets/i18n/en.json index 3bbe59b..bbc7784 100644 --- a/web/src/assets/i18n/en.json +++ b/web/src/assets/i18n/en.json @@ -2,6 +2,7 @@ "action": { "created": "Created", "deleted": "Deleted", + "failed": "Failed", "restored": "Recovered", "updated": "Updated" }, @@ -28,6 +29,7 @@ "domains": "Domains", "download": "Download", "editor": "Editor", + "enter_emails": "Enter E-Mails", "error": "Fehler", "group": "Group", "groups": "Groups", @@ -270,5 +272,10 @@ "keycloak_id": "Keycloak Id", "user": "User", "username": "Username" + }, + "user_space": { + "assign_users": "Users", + "invite_users": "Invite users", + "invited_users": "Invited users" } } \ No newline at end of file diff --git a/web/src/styles.scss b/web/src/styles.scss index ce42dc9..dec3d2c 100644 --- a/web/src/styles.scss +++ b/web/src/styles.scss @@ -68,6 +68,11 @@ body { padding: 0; } } + + .no-border { + border: 0 !important; + outline: none !important; + } } header { diff --git a/web/src/styles/theme.scss b/web/src/styles/theme.scss index c22df45..fe4e264 100644 --- a/web/src/styles/theme.scss +++ b/web/src/styles/theme.scss @@ -70,7 +70,7 @@ color: $headerColor; } - input, .p-checkbox-box, .p-dropdown { + input, .input, .p-checkbox-box, .p-dropdown { border: 1px solid $accentColor; }