diff --git a/web/src/app/modules/admin/short-urls/form-page/short-url-form-page.component.ts b/web/src/app/modules/admin/short-urls/form-page/short-url-form-page.component.ts
index fa7ea84..48ebe48 100644
--- a/web/src/app/modules/admin/short-urls/form-page/short-url-form-page.component.ts
+++ b/web/src/app/modules/admin/short-urls/form-page/short-url-form-page.component.ts
@@ -1,4 +1,4 @@
-import { Component } from '@angular/core';
+import { Component, OnInit } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';
import { ToastService } from 'src/app/service/toast.service';
import { FormPageBase } from 'src/app/core/base/form-page-base';
@@ -10,29 +10,45 @@ import {
import { ShortUrlsDataService } from 'src/app/modules/admin/short-urls/short-urls.data.service';
import { Group } from 'src/app/model/entities/group';
import { Domain } from 'src/app/model/entities/domain';
+import { FeatureFlagService } from 'src/app/service/feature-flag.service';
@Component({
selector: 'app-short-url-form-page',
templateUrl: './short-url-form-page.component.html',
styleUrl: './short-url-form-page.component.scss',
})
-export class ShortUrlFormPageComponent extends FormPageBase<
- ShortUrl,
- ShortUrlCreateInput,
- ShortUrlUpdateInput,
- ShortUrlsDataService
-> {
+export class ShortUrlFormPageComponent
+ extends FormPageBase<
+ ShortUrl,
+ ShortUrlCreateInput,
+ ShortUrlUpdateInput,
+ ShortUrlsDataService
+ >
+ implements OnInit
+{
groups: Group[] = [];
domains: Domain[] = [];
- constructor(private toast: ToastService) {
+ isPerUserSetup = true;
+
+ constructor(
+ private features: FeatureFlagService,
+ private toast: ToastService
+ ) {
super();
this.dataService.getAllGroups().subscribe(groups => {
this.groups = groups;
});
- this.dataService.getAllDomains().subscribe(domains => {
- this.domains = domains;
- });
+ }
+
+ async ngOnInit() {
+ this.isPerUserSetup = await this.features.get('PerUserSetup');
+
+ if (!this.isPerUserSetup) {
+ this.dataService.getAllDomains().subscribe(domains => {
+ this.domains = domains;
+ });
+ }
if (!this.nodeId) {
this.node = this.new();
diff --git a/web/src/app/modules/admin/short-urls/short-urls.data.service.ts b/web/src/app/modules/admin/short-urls/short-urls.data.service.ts
index 6fa766a..ddfe661 100644
--- a/web/src/app/modules/admin/short-urls/short-urls.data.service.ts
+++ b/web/src/app/modules/admin/short-urls/short-urls.data.service.ts
@@ -1,5 +1,5 @@
import { Injectable, Provider } from '@angular/core';
-import { merge, Observable } from 'rxjs';
+import { forkJoin, merge, Observable } from 'rxjs';
import {
Create,
Delete,
@@ -48,50 +48,88 @@ export class ShortUrlsDataService
skip?: number | undefined,
take?: number | undefined
): Observable
> {
- return this.apollo
- .query<{ shortUrls: QueryResult }>({
- query: gql`
- query getShortUrls($filter: [ShortUrlFilter], $sort: [ShortUrlSort]) {
- shortUrls(filter: $filter, sort: $sort) {
- count
- totalCount
- nodes {
+ const query1 = this.apollo.query<{ shortUrls: QueryResult }>({
+ query: gql`
+ query getShortUrls($filter: [ShortUrlFilter], $sort: [ShortUrlSort]) {
+ shortUrls(filter: $filter, sort: $sort) {
+ nodes {
+ id
+ shortUrl
+ targetUrl
+ description
+ loadingScreen
+ visits
+ group {
id
- shortUrl
- targetUrl
- description
- loadingScreen
- visits
- group {
- id
- name
- }
- domain {
- id
- name
- }
-
- ...DB_MODEL
+ name
}
+ domain {
+ id
+ name
+ }
+
+ ...DB_MODEL
}
}
+ }
- ${DB_MODEL_FRAGMENT}
- `,
- variables: {
- filter: [{ group: { deleted: { equal: false } } }, ...(filter ?? [])],
- sort: [{ id: SortOrder.DESC }, ...(sort ?? [])],
- skip: skip,
- take: take,
- },
+ ${DB_MODEL_FRAGMENT}
+ `,
+ variables: {
+ filter: [{ group: { deleted: { equal: false } } }, ...(filter ?? [])],
+ sort: [{ id: SortOrder.DESC }, ...(sort ?? [])],
+ skip,
+ take,
+ },
+ });
+
+ const query2 = this.apollo.query<{ shortUrls: QueryResult }>({
+ query: gql`
+ query getShortUrls($filter: [ShortUrlFilter], $sort: [ShortUrlSort]) {
+ shortUrls(filter: $filter, sort: $sort) {
+ nodes {
+ id
+ shortUrl
+ targetUrl
+ description
+ loadingScreen
+ visits
+ group {
+ id
+ name
+ }
+ domain {
+ id
+ name
+ }
+
+ ...DB_MODEL
+ }
+ }
+ }
+
+ ${DB_MODEL_FRAGMENT}
+ `,
+ variables: {
+ filter: [{ group: { isNull: true } }, ...(filter ?? [])],
+ sort: [{ id: SortOrder.DESC }, ...(sort ?? [])],
+ skip,
+ take,
+ },
+ });
+
+ return forkJoin([query1, query2]).pipe(
+ map(([result1, result2]) => {
+ const nodes = [
+ ...result1.data.shortUrls.nodes,
+ ...result2.data.shortUrls.nodes,
+ ];
+ const uniqueNodes = Array.from(
+ new Map(nodes.map(node => [node.id, node])).values()
+ );
+ return { ...result1.data.shortUrls, nodes: uniqueNodes };
})
- .pipe(
- catchError(err => {
- this.spinner.hide();
- throw err;
- })
- )
- .pipe(map(result => result.data.shortUrls));
+ );
}
loadById(id: number): Observable {
@@ -285,7 +323,7 @@ export class ShortUrlsDataService
return this.apollo
.mutate<{ shortUrl: { delete: boolean } }>({
mutation: gql`
- mutation deleteShortUrl($id: ID!) {
+ mutation deleteShortUrl($id: Int!) {
shortUrl {
delete(id: $id)
}
@@ -308,7 +346,7 @@ export class ShortUrlsDataService
return this.apollo
.mutate<{ shortUrl: { restore: boolean } }>({
mutation: gql`
- mutation restoreShortUrl($id: ID!) {
+ mutation restoreShortUrl($id: Int!) {
shortUrl {
restore(id: $id)
}
diff --git a/web/src/app/modules/admin/short-urls/short-urls.module.ts b/web/src/app/modules/admin/short-urls/short-urls.module.ts
index 4af7061..a144ca7 100644
--- a/web/src/app/modules/admin/short-urls/short-urls.module.ts
+++ b/web/src/app/modules/admin/short-urls/short-urls.module.ts
@@ -22,6 +22,7 @@ const routes: Routes = [
canActivate: [PermissionGuard],
data: {
permissions: [PermissionsEnum.shortUrlsCreate],
+ checkByPerUserSetup: true,
},
},
{
@@ -30,6 +31,7 @@ const routes: Routes = [
canActivate: [PermissionGuard],
data: {
permissions: [PermissionsEnum.shortUrlsUpdate],
+ checkByPerUserSetup: true,
},
},
{
@@ -37,7 +39,8 @@ const routes: Routes = [
component: HistoryComponent,
canActivate: [PermissionGuard],
data: {
- permissions: [PermissionsEnum.domains],
+ permissions: [PermissionsEnum.shortUrls],
+ checkByPerUserSetup: true,
},
},
],
diff --git a/web/src/app/modules/admin/short-urls/short-urls.page.html b/web/src/app/modules/admin/short-urls/short-urls.page.html
index 1ead5a3..e0c3333 100644
--- a/web/src/app/modules/admin/short-urls/short-urls.page.html
+++ b/web/src/app/modules/admin/short-urls/short-urls.page.html
@@ -41,7 +41,7 @@
icon="pi pi-pencil"
tooltipPosition="left"
pTooltip="{{ 'table.update' | translate }}"
- [disabled]="url?.deleted"
+ [disabled]="url.deleted"
routerLink="edit/{{ url.id }}">
{
return new Promise((resolve, reject) => {
this.http
- .get(`/assets/config/${environment.config}`)
+ .get(`/assets/config/${environment.config}`)
.pipe(
catchError(error => {
reject(error);
@@ -48,13 +52,17 @@ export class ConfigService {
})
)
.subscribe(settings => {
- this.settings = settings;
- if (this.settings.themes.length === 0) {
- this.settings.themes.push({
+ if (settings.themes.length === 0) {
+ settings.themes.push({
label: 'Maxlan',
name: 'maxlan',
});
}
+ if (settings.languages?.length === 0) {
+ settings.languages = ['en', 'de'];
+ }
+
+ this.settings = settings as AppSettings;
resolve();
});
});
diff --git a/web/src/app/service/data-privacy.service.ts b/web/src/app/service/data-privacy.service.ts
new file mode 100644
index 0000000..5b8ab8b
--- /dev/null
+++ b/web/src/app/service/data-privacy.service.ts
@@ -0,0 +1,77 @@
+import { Injectable } from '@angular/core';
+import { Apollo, gql } from 'apollo-angular';
+import { Observable } from 'rxjs';
+import { map } from 'rxjs/operators';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class DataPrivacyService {
+ constructor(private apollo: Apollo) {}
+
+ downloadDataExportJson(userId: number): void {
+ this.exportDataPrivacy(userId).subscribe(dataString => {
+ const jsonData = JSON.stringify(JSON.parse(dataString), null, 2); // Format nicely
+ const blob = new Blob([jsonData], { type: 'application/json' });
+ const url = window.URL.createObjectURL(blob);
+
+ const anchor = document.createElement('a');
+ anchor.href = url;
+ anchor.download = 'exported-data.json';
+ anchor.click();
+
+ window.URL.revokeObjectURL(url);
+ });
+ }
+
+ exportDataPrivacy(userId: number): Observable {
+ return this.apollo
+ .mutate<{ privacy: { exportData: string } }>({
+ mutation: gql`
+ mutation exportDataPrivacy($userId: Int!) {
+ privacy {
+ exportData(userId: $userId)
+ }
+ }
+ `,
+ variables: {
+ userId,
+ },
+ })
+ .pipe(map(result => result.data?.privacy.exportData ?? ''));
+ }
+
+ anonymizeData(userId: number): Observable {
+ return this.apollo
+ .mutate<{ privacy: { anonymizeData: boolean } }>({
+ mutation: gql`
+ mutation anonymizeDataPrivacy($userId: Int!) {
+ privacy {
+ anonymizeData(userId: $userId)
+ }
+ }
+ `,
+ variables: {
+ userId,
+ },
+ })
+ .pipe(map(result => result.data?.privacy.anonymizeData ?? false));
+ }
+
+ deleteData(userId: number): Observable {
+ return this.apollo
+ .mutate<{ privacy: { deleteData: boolean } }>({
+ mutation: gql`
+ mutation deleteDataPrivacy($userId: Int!) {
+ privacy {
+ deleteData(userId: $userId)
+ }
+ }
+ `,
+ variables: {
+ userId,
+ },
+ })
+ .pipe(map(result => result.data?.privacy.deleteData ?? false));
+ }
+}
diff --git a/web/src/app/service/sidebar.service.ts b/web/src/app/service/sidebar.service.ts
index b6f3a2d..49bff52 100644
--- a/web/src/app/service/sidebar.service.ts
+++ b/web/src/app/service/sidebar.service.ts
@@ -3,6 +3,7 @@ import { BehaviorSubject } from 'rxjs';
import { MenuElement } from 'src/app/model/view/menu-element';
import { AuthService } from 'src/app/service/auth.service';
import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum';
+import { FeatureFlagService } from 'src/app/service/feature-flag.service';
@Injectable({
providedIn: 'root',
@@ -11,7 +12,10 @@ export class SidebarService {
visible$ = new BehaviorSubject(true);
elements$ = new BehaviorSubject([]);
- constructor(private auth: AuthService) {
+ constructor(
+ private auth: AuthService,
+ private featureFlags: FeatureFlagService
+ ) {
this.auth.user$.subscribe(async () => {
await this.setElements();
});
@@ -40,16 +44,19 @@ export class SidebarService {
label: 'common.groups',
icon: 'pi pi-tags',
routerLink: ['/admin/groups'],
- visible: await this.auth.hasAnyPermissionLazy([PermissionsEnum.groups]),
+ visible:
+ (await this.auth.hasAnyPermissionLazy([PermissionsEnum.groups])) ||
+ (await this.featureFlags.get('PerUserSetup')),
},
{
label: 'common.urls',
icon: 'pi pi-tag',
routerLink: ['/admin/urls'],
- visible: await this.auth.hasAnyPermissionLazy([
- PermissionsEnum.shortUrls,
- PermissionsEnum.shortUrlsByAssignment,
- ]),
+ visible:
+ (await this.auth.hasAnyPermissionLazy([
+ PermissionsEnum.shortUrls,
+ PermissionsEnum.shortUrlsByAssignment,
+ ])) || (await this.featureFlags.get('PerUserSetup')),
},
await this.sectionAdmin(),
];
diff --git a/web/src/app/service/translation-preloader.service.ts b/web/src/app/service/translation-preloader.service.ts
new file mode 100644
index 0000000..be74ea4
--- /dev/null
+++ b/web/src/app/service/translation-preloader.service.ts
@@ -0,0 +1,25 @@
+import { Injectable } from '@angular/core';
+import { TranslateService } from '@ngx-translate/core';
+import { HttpClient } from '@angular/common/http';
+import { lastValueFrom } from 'rxjs';
+import { ConfigService } from 'src/app/service/config.service';
+
+@Injectable({ providedIn: 'root' })
+export class TranslationPreloaderService {
+ constructor(
+ private translate: TranslateService,
+ private http: HttpClient,
+ private config: ConfigService
+ ) {}
+
+ preloadLanguages(): Promise {
+ const loadRequests = this.config.settings.languages.map(async lang => {
+ const translations = await lastValueFrom(
+ this.http.get(`/assets/i18n/${lang}.json`)
+ );
+ this.translate.setTranslation(lang, translations, true); // merge = true
+ });
+
+ return Promise.all(loadRequests).then(() => {});
+ }
+}
diff --git a/web/src/app/service/user_settings.service.ts b/web/src/app/service/user-settings.service.ts
similarity index 100%
rename from web/src/app/service/user_settings.service.ts
rename to web/src/app/service/user-settings.service.ts
diff --git a/web/src/assets/i18n/de.json b/web/src/assets/i18n/de.json
index 839c5ad..aa96be0 100644
--- a/web/src/assets/i18n/de.json
+++ b/web/src/assets/i18n/de.json
@@ -76,7 +76,10 @@
"count_header": "Gruppe(n)"
},
"header": {
- "logout": "Ausloggen"
+ "edit_profile": "Profil bearbeiten",
+ "logout": "Ausloggen",
+ "privacy": "Datenschutz",
+ "profile": "Profil"
},
"history": {
"header": "Historie"
@@ -211,6 +214,12 @@
"weak": "Woche",
"weekHeader": "Wk"
},
+ "privacy": {
+ "delete_data": "Daten löschen",
+ "delete_data_header": "Bestätigung zur Löschung deiner personenbezogenen Daten",
+ "delete_data_message": "Bestätigung zur Löschung deiner personenbezogenen Daten
Du bist dabei, dein Konto dauerhaft zu löschen.
Damit werden deine personenbezogenen Daten unkenntlich gemacht.
Eine Wiederherstellung deines Kontos oder deiner Daten ist anschließend nicht mehr möglich.
Hinweis:
Aus rechtlichen oder betrieblichen Gründen (z. B. gesetzliche Aufbewahrungspflichten) können bestimmte Daten weiterhin in anonymisierter oder pseudonymisierter Form gespeichert bleiben. Diese enthalten keine Informationen mehr, die dich als Person identifizierbar machen.
Bitte bestätige, dass du dies verstanden hast und mit der Löschung fortfahren möchtest.",
+ "export_data": "Daten exportieren"
+ },
"role": {
"count_header": "Rolle(n)"
},
diff --git a/web/src/assets/i18n/en.json b/web/src/assets/i18n/en.json
index bc91320..4f95c94 100644
--- a/web/src/assets/i18n/en.json
+++ b/web/src/assets/i18n/en.json
@@ -76,7 +76,10 @@
"count_header": "Group(s)"
},
"header": {
- "logout": "Logout"
+ "edit_profile": "Edit profile",
+ "logout": "Logout",
+ "privacy": "Privacy",
+ "profile": "Profile"
},
"history": {
"header": "History"
@@ -211,6 +214,12 @@
"weak": "Weak",
"weekHeader": "Wk"
},
+ "privacy": {
+ "delete_data": "Delete data",
+ "delete_data_header": "Confirmation of deletion of your personal data",
+ "delete_data_message": "Confirmation of deletion of your personal data
You are about to permanently delete your account.
Your personal data will be anonymized and cannot be recovered.
It will no longer be possible to restore your account or associated data.
Note:
For legal or operational reasons (e.g. statutory retention obligations), certain data may continue to be stored in anonymized or pseudonymized form. This data no longer contains any information that can identify you as a person.
Please confirm that you understand this and wish to proceed with the deletion.",
+ "export_data": "Export data"
+ },
"role": {
"count_header": "Role(s)"
},