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 }}
+
0" class="flex flex-wrap gap-1">
+
+
+
+ {{ '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;
}