Fixed some issues
Some checks failed
Test before pr merge / test-lint (pull_request) Failing after 38s
Test before pr merge / test-translation-lint (pull_request) Successful in 1m11s
Test before pr merge / test-before-merge (pull_request) Failing after 1m48s

This commit is contained in:
Sven Heidemann 2025-03-08 09:49:24 +01:00
parent 9f68929b34
commit 86bd5fb545
15 changed files with 401 additions and 89 deletions

View File

@ -13,11 +13,11 @@ class MutationABC(QueryABC):
QueryABC.__init__(self, f"{name}Mutation") QueryABC.__init__(self, f"{name}Mutation")
def add_mutation_type( def add_mutation_type(
self, self,
name: str, name: str,
mutation_name: str, mutation_name: str,
require_any_permission: list[Permissions] = None, require_any_permission=None,
public: bool = False, public: bool = False,
): ):
""" """
Add mutation type (sub mutation) to the mutation object Add mutation type (sub mutation) to the mutation object
@ -27,6 +27,8 @@ class MutationABC(QueryABC):
:param bool public: Define if the field can resolve without authentication :param bool public: Define if the field can resolve without authentication
:return: :return:
""" """
if require_any_permission is None:
require_any_permission = []
from api_graphql.definition import QUERIES from api_graphql.definition import QUERIES
self.field( self.field(

View File

@ -7,4 +7,8 @@ type Mutation {
group: GroupMutation group: GroupMutation
domain: DomainMutation domain: DomainMutation
shortUrl: ShortUrlMutation shortUrl: ShortUrlMutation
setting: SettingMutation
userSetting: UserSettingMutation
featureFlag: FeatureFlagMutation
} }

View File

@ -0,0 +1,18 @@
from api_graphql.abc.input_abc import InputABC
class FeatureFlagInput(InputABC):
def __init__(self, src: dict):
InputABC.__init__(self, src)
self._key = self.option("key", str, required=True)
self._value = self.option("value", bool, required=True)
@property
def key(self) -> str:
return self._key
@property
def value(self) -> bool:
return self._value

View File

@ -0,0 +1,18 @@
from api_graphql.abc.input_abc import InputABC
class SettingInput(InputABC):
def __init__(self, src: dict):
InputABC.__init__(self, src)
self._key = self.option("key", str, required=True)
self._value = self.option("value", str, required=True)
@property
def key(self) -> str:
return self._key
@property
def value(self) -> str:
return self._value

View File

@ -0,0 +1,18 @@
from api_graphql.abc.input_abc import InputABC
class UserSettingInput(InputABC):
def __init__(self, src: dict):
InputABC.__init__(self, src)
self._key = self.option("key", str, required=True)
self._value = self.option("value", str, required=True)
@property
def key(self) -> str:
return self._key
@property
def value(self) -> str:
return self._value

View File

@ -60,3 +60,22 @@ class Mutation(MutationABC):
Permissions.short_urls_delete, Permissions.short_urls_delete,
], ],
) )
self.add_mutation_type(
"setting",
"Setting",
require_any_permission=[
Permissions.settings_update,
],
)
self.add_mutation_type(
"userSetting",
"UserSetting",
)
self.add_mutation_type(
"featureFlag",
"FeatureFlag",
require_any_permission=[
Permissions.administrator,
],
)

View File

@ -0,0 +1,32 @@
from api_graphql.abc.mutation_abc import MutationABC
from api_graphql.input.feature_flag_input import FeatureFlagInput
from core.logger import APILogger
from data.schemas.system.feature_flag import FeatureFlag
from data.schemas.system.feature_flag_dao import featureFlagDao
from service.permission.permissions_enum import Permissions
logger = APILogger(__name__)
class FeatureFlagMutation(MutationABC):
def __init__(self):
MutationABC.__init__(self, "FeatureFlag")
self.mutation(
"change",
self.resolve_change,
FeatureFlagInput,
require_any_permission=[Permissions.administrator],
)
@staticmethod
async def resolve_change(obj: FeatureFlagInput, *_):
logger.debug(f"create new feature flag: {input}")
setting = await featureFlagDao.find_single_by({FeatureFlag.key: obj.key})
if setting is None:
raise ValueError(f"FeatureFlag {obj.key} not found")
setting.value = obj.value
await featureFlagDao.update(setting)
return await featureFlagDao.get_by_id(setting.id)

View File

@ -0,0 +1,32 @@
from api_graphql.abc.mutation_abc import MutationABC
from api_graphql.input.setting_input import SettingInput
from core.logger import APILogger
from data.schemas.system.setting import Setting
from data.schemas.system.setting_dao import settingsDao
from service.permission.permissions_enum import Permissions
logger = APILogger(__name__)
class SettingMutation(MutationABC):
def __init__(self):
MutationABC.__init__(self, "Setting")
self.mutation(
"change",
self.resolve_change,
SettingInput,
require_any_permission=[Permissions.settings_update],
)
@staticmethod
async def resolve_change(obj: SettingInput, *_):
logger.debug(f"create new setting: {input}")
setting = await settingsDao.find_single_by({Setting.key: obj.key})
if setting is None:
raise ValueError(f"Setting with key {obj.key} not found")
setting.value = obj.value
await settingsDao.update(setting)
return await settingsDao.get_by_id(setting.id)

View File

@ -0,0 +1,40 @@
from api.route import Route
from api_graphql.abc.mutation_abc import MutationABC
from api_graphql.input.user_setting_input import UserSettingInput
from core.logger import APILogger
from data.schemas.public.user_setting import UserSetting
from data.schemas.public.user_setting_dao import userSettingsDao
from data.schemas.system.setting_dao import settingsDao
from service.permission.permissions_enum import Permissions
logger = APILogger(__name__)
class UserSettingMutation(MutationABC):
def __init__(self):
MutationABC.__init__(self, "UserSetting")
self.mutation(
"change",
self.resolve_change,
UserSettingInput,
require_any_permission=[Permissions.settings_update],
)
@staticmethod
async def resolve_change(obj: UserSettingInput, *_):
logger.debug(f"create new setting: {input}")
user = await Route.get_user_or_default()
if user is None:
logger.debug("user not authorized")
return None
setting = await userSettingsDao.find_single_by(
[{UserSetting.user_id: user.id}, {UserSetting.key: obj.key}]
)
if setting is None:
await userSettingsDao.create(UserSetting(0, user.id, obj.key, obj.value))
else:
setting.value = obj.value
await userSettingsDao.update(setting)
return await userSettingsDao.find_by_key(user, obj.key)

View File

@ -5,7 +5,7 @@ CREATE TABLE IF NOT EXISTS public.user_settings
Id SERIAL PRIMARY KEY, Id SERIAL PRIMARY KEY,
Key TEXT NOT NULL, Key TEXT NOT NULL,
Value TEXT NOT NULL, Value TEXT NOT NULL,
UserId INT NOT NULL REFERENCES public.user_settings (Id) ON DELETE CASCADE, UserId INT NOT NULL REFERENCES administration.users (Id) ON DELETE CASCADE,
-- for history -- for history
Deleted BOOLEAN NOT NULL DEFAULT FALSE, Deleted BOOLEAN NOT NULL DEFAULT FALSE,
EditorId INT NULL REFERENCES administration.users (Id), EditorId INT NULL REFERENCES administration.users (Id),

View File

@ -8,8 +8,9 @@ import { User } from 'src/app/model/auth/user';
import { AuthService } from 'src/app/service/auth.service'; import { AuthService } from 'src/app/service/auth.service';
import { MenuElement } from 'src/app/model/view/menu-element'; import { MenuElement } from 'src/app/model/view/menu-element';
import { SidebarService } from 'src/app/service/sidebar.service'; import { SidebarService } from 'src/app/service/sidebar.service';
import { SettingsService } from 'src/app/service/settings.service';
import { ConfigService } from 'src/app/service/config.service'; import { ConfigService } from 'src/app/service/config.service';
import { UserSettingsService } from 'src/app/service/user_settings.service';
import { SettingsService } from 'src/app/service/settings.service';
@Component({ @Component({
selector: 'app-header', selector: 'app-header',
@ -33,7 +34,9 @@ export class HeaderComponent implements OnInit, OnDestroy {
private guiService: GuiService, private guiService: GuiService,
private auth: AuthService, private auth: AuthService,
private sidebarService: SidebarService, private sidebarService: SidebarService,
private config: ConfigService private config: ConfigService,
private settings: SettingsService,
private userSettings: UserSettingsService
) { ) {
this.guiService.isMobile$ this.guiService.isMobile$
.pipe(takeUntil(this.unsubscribe$)) .pipe(takeUntil(this.unsubscribe$))
@ -47,13 +50,18 @@ export class HeaderComponent implements OnInit, OnDestroy {
this.auth.user$.pipe(takeUntil(this.unsubscribe$)).subscribe(async user => { this.auth.user$.pipe(takeUntil(this.unsubscribe$)).subscribe(async user => {
this.user = user; this.user = user;
await this.initMenuLists(); await this.initMenuLists();
if (user) {
await this.loadTheme();
await this.loadLang();
}
}); });
this.themeList = this.config.settings.themes.map(theme => { this.themeList = this.config.settings.themes.map(theme => {
return { return {
label: theme.label, label: theme.label,
command: () => { command: async () => {
this.guiService.theme$.next(theme.name); this.guiService.theme$.next(theme.name);
await this.userSettings.set('theme', theme.name);
}, },
}; };
}); });
@ -61,7 +69,6 @@ export class HeaderComponent implements OnInit, OnDestroy {
async ngOnInit() { async ngOnInit() {
await this.initMenuLists(); await this.initMenuLists();
await this.loadLang();
} }
ngOnDestroy() { ngOnDestroy() {
@ -74,24 +81,49 @@ export class HeaderComponent implements OnInit, OnDestroy {
} }
async initMenuLists() { async initMenuLists() {
await this.initMenuList();
await this.initLangMenuList(); await this.initLangMenuList();
await this.initUserMenuList(); await this.initUserMenuList();
} }
async initMenuList() {
this.menu = [
{
label: 'common.news',
routerLink: ['/'],
icon: 'pi pi-home',
},
{
label: 'header.menu.about',
routerLink: ['/about'],
icon: 'pi pi-info',
},
];
if (this.auth.user$.value) {
this.menu.push({
label: 'header.menu.admin',
routerLink: ['/admin'],
icon: 'pi pi-cog',
visible: await this.auth.isAdmin(),
});
}
}
async initLangMenuList() { async initLangMenuList() {
this.langList = [ this.langList = [
{ {
label: 'English', label: 'English',
command: () => { command: async () => {
this.translate('en'); this.translate('en');
this.setLang('en'); await this.setLang('en');
}, },
}, },
{ {
label: 'Deutsch', label: 'Deutsch',
command: () => { command: async () => {
this.translate('de'); this.translate('de');
this.setLang('de'); await this.setLang('de');
}, },
}, },
]; ];
@ -109,10 +141,8 @@ export class HeaderComponent implements OnInit, OnDestroy {
}, },
{ {
label: this.translateService.instant('header.logout'), label: this.translateService.instant('header.logout'),
command: () => { command: async () => {
this.auth.logout().then(() => { await this.auth.logout();
console.log('logout');
});
}, },
icon: 'pi pi-sign-out', icon: 'pi pi-sign-out',
}, },
@ -126,15 +156,33 @@ export class HeaderComponent implements OnInit, OnDestroy {
.subscribe(res => this.ngConfig.setTranslation(res)); .subscribe(res => this.ngConfig.setTranslation(res));
} }
async loadLang() { async loadTheme() {
const lang = 'en'; const defaultTheme = (await this.settings.get('default_theme')) ?? 'maxlan';
this.setLang(lang); const userTheme = await this.userSettings.get('theme');
this.translate(lang); const theme = userTheme ?? defaultTheme;
this.guiService.theme$.next(theme);
if (!userTheme) {
await this.userSettings.set('theme', theme);
}
} }
setLang(lang: string) { async loadLang() {
// this.settings.setSetting(`lang`, lang); const defaultLang = (await this.settings.get('default_language')) ?? 'en';
console.log('setLang', lang); const userLang = await this.userSettings.get('language');
const lang = userLang ?? defaultLang;
this.translate(lang);
if (userLang) {
return;
}
await this.userSettings.set('language', lang);
}
async setLang(lang: string) {
await this.userSettings.set('language', lang);
} }
toggleSidebar() { toggleSidebar() {

View File

@ -103,6 +103,14 @@ export class AuthService {
return this.keycloakService.logout(); return this.keycloakService.logout();
} }
isLoggedIn(): boolean {
return (
this.keycloakService.isLoggedIn() &&
!this.keycloakService.isTokenExpired() &&
!!this.user$.value
);
}
async isAdmin(): Promise<boolean> { async isAdmin(): Promise<boolean> {
return await this.hasAnyPermissionLazy(this.anyPermissionForAdminPage); return await this.hasAnyPermissionLazy(this.anyPermissionForAdminPage);
} }

View File

@ -0,0 +1,111 @@
import { Injectable } from '@angular/core';
import { throwError } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { Setting } from 'src/app/model/entities/setting';
import { Apollo, gql } from 'apollo-angular';
import { Logger } from 'src/app/service/logger.service';
import { AuthService } from 'src/app/service/auth.service';
const log = new Logger('UserSettings');
@Injectable({
providedIn: 'root',
})
export class UserSettingsService {
constructor(
private apollo: Apollo,
private auth: AuthService
) {}
get(key: string): Promise<string | undefined> {
if (!this.auth.isLoggedIn()) {
log.debug('get', key, 'not logged in');
return Promise.resolve(undefined);
}
log.debug('get', key);
return new Promise((resolve, reject) => {
this.apollo
.query<{ userSettings: Setting[] }>({
query: gql`
query getUserSetting($key: String!) {
userSettings(key: $key) {
key
value
}
}
`,
variables: {
key,
},
})
.pipe(
catchError(err => {
throw err;
})
)
.pipe(
map(result =>
result.data.userSettings?.length > 0
? result.data.userSettings[0]
: undefined
)
)
.pipe(
catchError(err => {
reject(err);
return throwError(() => err);
})
)
.subscribe(setting => {
log.debug('Got', key, setting);
resolve(setting?.value);
});
});
}
set(key: string, value: string): Promise<void> {
if (!this.auth.isLoggedIn()) {
log.debug('set', key, value, 'not logged in');
return Promise.resolve(undefined);
}
log.debug('set', key, value);
return new Promise((resolve, reject) => {
this.apollo
.mutate<{ userSetting: { change: Setting } }>({
mutation: gql`
mutation changeUserSetting($input: UserSettingInput!) {
userSetting {
change(input: $input) {
key
value
}
}
}
`,
variables: {
input: {
key: key,
value: value,
},
},
})
.pipe(
catchError(err => {
throw err;
})
)
.pipe(map(result => result.data?.userSetting.change))
.pipe(
catchError(err => {
reject(err);
return throwError(() => err);
})
)
.subscribe(() => {
resolve();
});
});
}
}

View File

@ -16,7 +16,6 @@
"all": "Alle", "all": "Alle",
"api_error": "API Fehler", "api_error": "API Fehler",
"api_key": "API Key", "api_key": "API Key",
"by": "von",
"cancel": "Abbrechen", "cancel": "Abbrechen",
"choose": "Auswählen", "choose": "Auswählen",
"close": "Schließen", "close": "Schließen",
@ -28,7 +27,6 @@
"domain": "Domain", "domain": "Domain",
"domains": "Domains", "domains": "Domains",
"download": "Herunterladen", "download": "Herunterladen",
"edited_at": "Bearbeitet am",
"editor": "Bearbeiter", "editor": "Bearbeiter",
"error": "Fehler", "error": "Fehler",
"group": "Gruppe", "group": "Gruppe",
@ -37,7 +35,6 @@
"identifier": "Identifier", "identifier": "Identifier",
"key": "Schlüssel", "key": "Schlüssel",
"name": "Name", "name": "Name",
"news": "News",
"role": "Rolle", "role": "Rolle",
"save": "Speichern", "save": "Speichern",
"short_url": "Url", "short_url": "Url",
@ -48,9 +45,7 @@
}, },
"dialog": { "dialog": {
"abort": "Abbrechen", "abort": "Abbrechen",
"back": "Zurück",
"confirm": "Bestätigen", "confirm": "Bestätigen",
"continue": "Fortsetzen",
"delete": { "delete": {
"header": "Löschen bestätigen", "header": "Löschen bestätigen",
"message": "Sind Sie sicher, dass Sie {{entity}} löschen möchten?" "message": "Sind Sie sicher, dass Sie {{entity}} löschen möchten?"
@ -58,22 +53,18 @@
"restore": { "restore": {
"header": "Wiederherstellung bestätigen", "header": "Wiederherstellung bestätigen",
"message": "Sind Sie sicher, dass Sie {{entity}} wiederherstellen möchten?" "message": "Sind Sie sicher, dass Sie {{entity}} wiederherstellen möchten?"
}, }
"save": "Speichern"
}, },
"domain": { "domain": {
"count_header": "Domain(s)" "count_header": "Domain(s)"
}, },
"error": { "error": {
"404": "404 - Nicht gefunden", "404": "404 - Nicht gefunden",
"create_failed": "Erstellung fehlgeschlagen",
"delete_failed": "Löschen fehlgeschlagen",
"missing_permissions": "Fehlende Berechtigungen: {{permissions}}", "missing_permissions": "Fehlende Berechtigungen: {{permissions}}",
"network_error": "Netzwerkfehler",
"permission_denied": "Fehlende Berechtigung", "permission_denied": "Fehlende Berechtigung",
"restore_failed": "Wiederherstellung fehlgeschlagen", "retry": "Erneut versuchen",
"unauthorized": "Nicht autorisiert", "server_unavailable": "Server nicht erreichbar",
"update_failed": "Bearbeitung fehlgeschlagen" "unauthorized": "Nicht autorisiert"
}, },
"footer": { "footer": {
"imprint": "Impressum", "imprint": "Impressum",
@ -84,11 +75,12 @@
"count_header": "Gruppe(n)" "count_header": "Gruppe(n)"
}, },
"header": { "header": {
"logout": "Ausloggen", "logout": "Ausloggen"
"menu": { },
"about": "Über uns", "permission_descriptions": {
"admin": "Admin" "short_urls": "Alle URLs sehen",
} "short_urls.by_assignment": "Alle Kurz-URLs anzeigen, die einer Gruppe nach Rolle zugewiesen sind",
"users.update": "Benutzer inkl. der Rollen ändern"
}, },
"permissions": { "permissions": {
"api_keys": "API Keys", "api_keys": "API Keys",
@ -107,10 +99,6 @@
"ip_list.create": "Erstellen", "ip_list.create": "Erstellen",
"ip_list.delete": "Löschen", "ip_list.delete": "Löschen",
"ip_list.update": "Bearbeiten", "ip_list.update": "Bearbeiten",
"news": "News",
"news.create": "Erstellen",
"news.delete": "Löschen",
"news.update": "Bearbeiten",
"roles": "Rollen", "roles": "Rollen",
"roles.create": "Erstellen", "roles.create": "Erstellen",
"roles.delete": "Löschen", "roles.delete": "Löschen",
@ -125,19 +113,10 @@
"users.delete": "Löschen", "users.delete": "Löschen",
"users.update": "Bearbeiten" "users.update": "Bearbeiten"
}, },
"qr": {
"width": "Breite"
},
"permission_descriptions": {
"users.update": "Benutzer inkl. der Rollen ändern",
"short_urls": "Alle URLs sehen",
"short_urls.by_assignment": "Alle Kurz-URLs anzeigen, die einer Gruppe nach Rolle zugewiesen sind"
},
"role": { "role": {
"count_header": "Rolle(n)" "count_header": "Rolle(n)"
}, },
"short_url": { "short_url": {
"count_header": "Url(s)",
"loading_screen": "Ladebildschirm", "loading_screen": "Ladebildschirm",
"short_url": "URL", "short_url": "URL",
"target_url": "Ziel", "target_url": "Ziel",
@ -146,12 +125,13 @@
"sidebar": { "sidebar": {
"administration": "Administration", "administration": "Administration",
"api_keys": "API Keys", "api_keys": "API Keys",
"feature_flags": "Funktionen",
"header": { "header": {
"create": "erstellen", "create": "erstellen",
"update": "bearbeiten" "update": "bearbeiten"
}, },
"public": "Öffentlich",
"roles": "Rollen", "roles": "Rollen",
"settings": "Einstellungen",
"users": "Benutzer" "users": "Benutzer"
}, },
"table": { "table": {
@ -163,6 +143,7 @@
"reset_filters": "Filter zurücksetzen", "reset_filters": "Filter zurücksetzen",
"reset_sort": "Sortierung zurücksetzen", "reset_sort": "Sortierung zurücksetzen",
"restore": "Wiederherstellen", "restore": "Wiederherstellen",
"select_columns": "Spalten auswählen",
"show_deleted": "Gelöschte anzeigen", "show_deleted": "Gelöschte anzeigen",
"to": "bis", "to": "bis",
"update": "Bearbeiten" "update": "Bearbeiten"

View File

@ -16,7 +16,6 @@
"all": "All", "all": "All",
"api_error": "API error", "api_error": "API error",
"api_key": "API Key", "api_key": "API Key",
"by": "by",
"cancel": "Cancel", "cancel": "Cancel",
"choose": "Choose", "choose": "Choose",
"close": "Close", "close": "Close",
@ -28,7 +27,6 @@
"domain": "Domain", "domain": "Domain",
"domains": "Domains", "domains": "Domains",
"download": "Download", "download": "Download",
"edited_at": "Edited at",
"editor": "Editor", "editor": "Editor",
"error": "Fehler", "error": "Fehler",
"group": "Group", "group": "Group",
@ -37,7 +35,6 @@
"identifier": "Identifier", "identifier": "Identifier",
"key": "Key", "key": "Key",
"name": "Name", "name": "Name",
"news": "News",
"role": "Role", "role": "Role",
"save": "Save", "save": "Save",
"short_url": "Url", "short_url": "Url",
@ -48,9 +45,7 @@
}, },
"dialog": { "dialog": {
"abort": "Abort", "abort": "Abort",
"back": "Back",
"confirm": "Confirm", "confirm": "Confirm",
"continue": "Continue",
"delete": { "delete": {
"header": "Confirm delete", "header": "Confirm delete",
"message": "Are you sure you want to delete {{entity}}?" "message": "Are you sure you want to delete {{entity}}?"
@ -58,22 +53,18 @@
"restore": { "restore": {
"header": "Confirm restore", "header": "Confirm restore",
"message": "Are you sure you want to restore {{entity}}?" "message": "Are you sure you want to restore {{entity}}?"
}, }
"save": "Save"
}, },
"domain": { "domain": {
"count_header": "Domain(s)" "count_header": "Domain(s)"
}, },
"error": { "error": {
"404": "404 - Not found", "404": "404 - Not found",
"create_failed": "Create failed",
"delete_failed": "Delete failed",
"missing_permissions": "Missing permissions: {{permissions}}", "missing_permissions": "Missing permissions: {{permissions}}",
"network_error": "Network Error",
"permission_denied": "Permission Denied", "permission_denied": "Permission Denied",
"restore_failed": "Restore failed", "retry": "Retry",
"unauthorized": "Unauthorized", "server_unavailable": "Server unavailable",
"update_failed": "Update failed" "unauthorized": "Unauthorized"
}, },
"footer": { "footer": {
"imprint": "Imprint", "imprint": "Imprint",
@ -84,11 +75,12 @@
"count_header": "Group(s)" "count_header": "Group(s)"
}, },
"header": { "header": {
"logout": "Logout", "logout": "Logout"
"menu": { },
"about": "About", "permission_descriptions": {
"admin": "Admin" "short_urls": "See all URLs",
} "short_urls.by_assignment": "See all short urls assigned to a group by role",
"users.update": "Change users including their roles"
}, },
"permissions": { "permissions": {
"api_keys": "API Keys", "api_keys": "API Keys",
@ -107,10 +99,6 @@
"ip_list.create": "Create", "ip_list.create": "Create",
"ip_list.delete": "Delete", "ip_list.delete": "Delete",
"ip_list.update": "Update", "ip_list.update": "Update",
"news": "News",
"news.create": "Create",
"news.delete": "Delete",
"news.update": "Update",
"roles": "Roles", "roles": "Roles",
"roles.create": "Create", "roles.create": "Create",
"roles.delete": "Delete", "roles.delete": "Delete",
@ -125,19 +113,10 @@
"users.delete": "Delete", "users.delete": "Delete",
"users.update": "Update" "users.update": "Update"
}, },
"permission_descriptions": {
"users.update": "Change users including their roles",
"short_urls": "See all URLs",
"short_urls.by_assignment": "See all short urls assigned to a group by role"
},
"qr": {
"width": "Width"
},
"role": { "role": {
"count_header": "Role(s)" "count_header": "Role(s)"
}, },
"short_url": { "short_url": {
"count_header": "Url(s)",
"loading_screen": "Loading screen", "loading_screen": "Loading screen",
"short_url": "URL", "short_url": "URL",
"target_url": "Target", "target_url": "Target",
@ -146,12 +125,13 @@
"sidebar": { "sidebar": {
"administration": "Administration", "administration": "Administration",
"api_keys": "API Keys", "api_keys": "API Keys",
"feature_flags": "Features",
"header": { "header": {
"create": "create", "create": "create",
"update": "update" "update": "update"
}, },
"public": "Public",
"roles": "Roles", "roles": "Roles",
"settings": "Settings",
"users": "Users" "users": "Users"
}, },
"table": { "table": {
@ -163,6 +143,7 @@
"reset_filters": "Reset filters", "reset_filters": "Reset filters",
"reset_sort": "Reset sort", "reset_sort": "Reset sort",
"restore": "Restore", "restore": "Restore",
"select_columns": "Select columns",
"show_deleted": "Show deleted", "show_deleted": "Show deleted",
"to": "to", "to": "to",
"update": "Update" "update": "Update"