Compare commits

..

11 Commits
master ... dev

Author SHA1 Message Date
254b03d601 Fixed mail
All checks were successful
Build on push / prepare (push) Successful in 6s
Build on push / build-api (push) Successful in 8s
Build on push / build-redirector (push) Successful in 8s
Build on push / build-web (push) Successful in 47s
2025-01-18 14:57:28 +01:00
ef17f800b1 New version format
All checks were successful
Build on push / prepare (push) Successful in 4s
Build on push / build-api (push) Successful in 10s
Build on push / build-redirector (push) Successful in 10s
Build on push / build-web (push) Successful in 47s
2025-01-17 20:23:06 +01:00
b72916a8d9 Manage group/role assignment
All checks were successful
Build dev on push / build-redirector (push) Successful in 28s
Build dev on push / build-api (push) Successful in 29s
Build dev on push / build-web (push) Successful in 51s
Closes #11
2025-01-17 17:30:56 +01:00
f87b60f128 Added domain strict mode & reset versioning
All checks were successful
Build dev on push / build-api (push) Successful in 32s
Build dev on push / build-redirector (push) Successful in 33s
Build dev on push / build-web (push) Successful in 51s
2025-01-16 18:32:10 +01:00
b71b68d2c3 Set new version
All checks were successful
Build dev on push / build-api (push) Successful in 23s
Build dev on push / build-redirector (push) Successful in 6s
Build dev on push / build-web (push) Successful in 51s
2025-01-14 16:51:13 +01:00
e9353f3e7f Added missing translations 2025-01-14 16:50:53 +01:00
cb3cd05cf2 Keycloak role management
All checks were successful
Build dev on push / build-api (push) Successful in 32s
Build dev on push / build-redirector (push) Successful in 33s
Build dev on push / build-web (push) Successful in 56s
Closes #10
2025-01-11 14:30:54 +01:00
854e00882b Redirector handle by request url
All checks were successful
Build dev on push / build-redirector (push) Successful in 27s
Build dev on push / build-api (push) Successful in 29s
Build dev on push / build-web (push) Successful in 46s
2025-01-11 01:31:26 +01:00
8d99cd5ccf Redirector domain support
All checks were successful
Build dev on push / build-redirector (push) Successful in 29s
Build dev on push / build-api (push) Successful in 29s
Build dev on push / build-web (push) Successful in 50s
2025-01-11 01:17:48 +01:00
1001b6db5f [WIP] Added domain management 2025-01-11 00:42:11 +01:00
865e6465cf Added theming
All checks were successful
Build dev on push / build-api (push) Successful in 7s
Build dev on push / build-redirector (push) Successful in 7s
Build dev on push / build-web (push) Successful in 48s
2025-01-10 21:57:47 +01:00
88 changed files with 2954 additions and 829 deletions

View File

@ -1,12 +1,12 @@
name: Build dev on push name: Build on push
run-name: Build dev on push run-name: Build on push
on: on:
push: push:
branches: branches:
- dev - dev
jobs: jobs:
build-api: prepare:
runs-on: [runner] runs-on: [runner]
container: git.sh-edraft.de/sh-edraft.de/act-runner:latest container: git.sh-edraft.de/sh-edraft.de/act-runner:latest
steps: steps:
@ -15,10 +15,48 @@ jobs:
with: with:
token: ${{ secrets.CI_ACCESS_TOKEN }} token: ${{ secrets.CI_ACCESS_TOKEN }}
- name: Get Date and Build Number
run: |
DATE=$(date +'%Y.%m.%d')
git fetch --tags
TAG_COUNT=$(git tag -l "${DATE}.*" | wc -l)
BUILD_NUMBER=$(($TAG_COUNT + 1))
BUILD_VERSION="${DATE}.${BUILD_NUMBER}-dev"
echo "$BUILD_VERSION" > version.txt
- name: Create Git Tag for Build
run: |
git config user.name "ci"
git config user.email "dev@sh-edraft.de"
git tag ${{ env.BUILD_VERSION }}
git push origin ${{ env.BUILD_VERSION }}
- name: Upload build version artifact
uses: actions/upload-artifact@v3
with:
name: version
path: version.txt
build-api:
runs-on: [runner]
needs: prepare
container: git.sh-edraft.de/sh-edraft.de/act-runner:latest
steps:
- name: Clone Repository
uses: https://github.com/actions/checkout@v3
with:
token: ${{ secrets.CI_ACCESS_TOKEN }}
- name: Download build version artifact
uses: actions/download-artifact@v3
with:
name: version
- name: Build docker - name: Build docker
run: | run: |
cd api cd api
docker build -t git.sh-edraft.de/sh-edraft.de/open-redirect-api-dev:$(cat ../version.txt) . echo "VERSION = \"$(cat version.txt)\"" > version.py
docker build -t git.sh-edraft.de/sh-edraft.de/open-redirect-api:$(cat ../version.txt) .
- name: Login to registry git.sh-edraft.de - name: Login to registry git.sh-edraft.de
uses: https://github.com/docker/login-action@v1 uses: https://github.com/docker/login-action@v1
@ -29,10 +67,11 @@ jobs:
- name: Push image - name: Push image
run: | run: |
docker push git.sh-edraft.de/sh-edraft.de/open-redirect-api-dev:$(cat version.txt) docker push git.sh-edraft.de/sh-edraft.de/open-redirect-api:$(cat version.txt)
build-redirector: build-redirector:
runs-on: [runner] runs-on: [runner]
needs: prepare
container: git.sh-edraft.de/sh-edraft.de/act-runner:latest container: git.sh-edraft.de/sh-edraft.de/act-runner:latest
steps: steps:
- name: Clone Repository - name: Clone Repository
@ -40,10 +79,15 @@ jobs:
with: with:
token: ${{ secrets.CI_ACCESS_TOKEN }} token: ${{ secrets.CI_ACCESS_TOKEN }}
- name: Download build version artifact
uses: actions/download-artifact@v3
with:
name: version
- name: Build docker - name: Build docker
run: | run: |
cd api cd api
docker build -f dockerfile_redirector -t git.sh-edraft.de/sh-edraft.de/open-redirect-redirector-dev:$(cat ../version.txt) . docker build -f dockerfile_redirector -t git.sh-edraft.de/sh-edraft.de/open-redirect-redirector:$(cat ../version.txt) .
- name: Login to registry git.sh-edraft.de - name: Login to registry git.sh-edraft.de
uses: https://github.com/docker/login-action@v1 uses: https://github.com/docker/login-action@v1
@ -54,10 +98,11 @@ jobs:
- name: Push image - name: Push image
run: | run: |
docker push git.sh-edraft.de/sh-edraft.de/open-redirect-redirector-dev:$(cat version.txt) docker push git.sh-edraft.de/sh-edraft.de/open-redirect-redirector:$(cat version.txt)
build-web: build-web:
runs-on: [runner] runs-on: [runner]
needs: prepare
container: git.sh-edraft.de/sh-edraft.de/act-runner:latest container: git.sh-edraft.de/sh-edraft.de/act-runner:latest
steps: steps:
- name: Clone Repository - name: Clone Repository
@ -65,6 +110,11 @@ jobs:
with: with:
token: ${{ secrets.CI_ACCESS_TOKEN }} token: ${{ secrets.CI_ACCESS_TOKEN }}
- name: Download build version artifact
uses: actions/download-artifact@v3
with:
name: version
- name: Prepare web build - name: Prepare web build
run: | run: |
cd web cd web
@ -78,7 +128,7 @@ jobs:
- name: Build docker - name: Build docker
run: | run: |
cd web cd web
docker build -t git.sh-edraft.de/sh-edraft.de/open-redirect-web-dev:$(cat ../version.txt) . docker build -t git.sh-edraft.de/sh-edraft.de/open-redirect-web:$(cat ../version.txt) .
- name: Login to registry git.sh-edraft.de - name: Login to registry git.sh-edraft.de
uses: https://github.com/docker/login-action@v1 uses: https://github.com/docker/login-action@v1
@ -89,4 +139,4 @@ jobs:
- name: Push image - name: Push image
run: | run: |
docker push git.sh-edraft.de/sh-edraft.de/open-redirect-web-dev:$(cat version.txt) docker push git.sh-edraft.de/sh-edraft.de/open-redirect-web:$(cat version.txt)

View File

@ -6,7 +6,7 @@ on:
- master - master
jobs: jobs:
build-api: prepare:
runs-on: [runner] runs-on: [runner]
container: git.sh-edraft.de/sh-edraft.de/act-runner:latest container: git.sh-edraft.de/sh-edraft.de/act-runner:latest
steps: steps:
@ -15,9 +15,47 @@ jobs:
with: with:
token: ${{ secrets.CI_ACCESS_TOKEN }} token: ${{ secrets.CI_ACCESS_TOKEN }}
- name: Get Date and Build Number
run: |
DATE=$(date +'%Y.%m.%d')
git fetch --tags
TAG_COUNT=$(git tag -l "${DATE}.*" | wc -l)
BUILD_NUMBER=$(($TAG_COUNT + 1))
BUILD_VERSION="${DATE}.${BUILD_NUMBER}"
echo "$BUILD_VERSION" > version.txt
- name: Create Git Tag for Build
run: |
git config user.name "ci"
git config user.email "dev@sh-edraft.de"
git tag ${{ env.BUILD_VERSION }}
git push origin ${{ env.BUILD_VERSION }}
- name: Upload build version artifact
uses: actions/upload-artifact@v3
with:
name: version
path: version.txt
build-api:
runs-on: [runner]
needs: prepare
container: git.sh-edraft.de/sh-edraft.de/act-runner:latest
steps:
- name: Clone Repository
uses: https://github.com/actions/checkout@v3
with:
token: ${{ secrets.CI_ACCESS_TOKEN }}
- name: Download build version artifact
uses: actions/download-artifact@v3
with:
name: version
- name: Build docker - name: Build docker
run: | run: |
cd api cd api
echo "VERSION = \"$(cat version.txt)\"" > version.py
docker build -t git.sh-edraft.de/sh-edraft.de/open-redirect-api:$(cat ../version.txt) . docker build -t git.sh-edraft.de/sh-edraft.de/open-redirect-api:$(cat ../version.txt) .
- name: Login to registry git.sh-edraft.de - name: Login to registry git.sh-edraft.de
@ -33,6 +71,7 @@ jobs:
build-redirector: build-redirector:
runs-on: [runner] runs-on: [runner]
needs: prepare
container: git.sh-edraft.de/sh-edraft.de/act-runner:latest container: git.sh-edraft.de/sh-edraft.de/act-runner:latest
steps: steps:
- name: Clone Repository - name: Clone Repository
@ -40,6 +79,11 @@ jobs:
with: with:
token: ${{ secrets.CI_ACCESS_TOKEN }} token: ${{ secrets.CI_ACCESS_TOKEN }}
- name: Download build version artifact
uses: actions/download-artifact@v3
with:
name: version
- name: Build docker - name: Build docker
run: | run: |
cd api cd api
@ -58,6 +102,7 @@ jobs:
build-web: build-web:
runs-on: [runner] runs-on: [runner]
needs: prepare
container: git.sh-edraft.de/sh-edraft.de/act-runner:latest container: git.sh-edraft.de/sh-edraft.de/act-runner:latest
steps: steps:
- name: Clone Repository - name: Clone Repository
@ -65,6 +110,11 @@ jobs:
with: with:
token: ${{ secrets.CI_ACCESS_TOKEN }} token: ${{ secrets.CI_ACCESS_TOKEN }}
- name: Download build version artifact
uses: actions/download-artifact@v3
with:
name: version
- name: Prepare web build - name: Prepare web build
run: | run: |
cd web cd web

View File

@ -52,7 +52,11 @@ class RouteUserExtension:
if not user_id: if not user_id:
return None return None
return await userDao.find_by_keycloak_id(user_id) user = await userDao.find_by_keycloak_id(user_id)
if user is None:
return None
return user
@classmethod @classmethod
async def get_dev_user(cls) -> Optional[User]: async def get_dev_user(cls) -> Optional[User]:
@ -67,6 +71,56 @@ class RouteUserExtension:
user = await cls.get_dev_user() user = await cls.get_dev_user()
return user return user
@classmethod
def _flatten_groups(cls, groups):
flat_list = []
for group in groups:
flat_list.append(group)
if "subGroups" in group and group["subGroups"]:
flat_list.extend(cls._flatten_groups(group["subGroups"]))
return flat_list
@classmethod
async def _map_keycloak_groups_with_roles(cls, user: User):
try:
roles = {x.name: x for x in await roleDao.get_all()}
groups = cls._flatten_groups(Keycloak.admin.get_groups(full_hierarchy=True))
groups_with_role = [x["name"] for x in groups if x["name"] in roles.keys()]
user_groups_with_role = [
x["name"]
for x in Keycloak.admin.get_user_groups(user.keycloak_id)
if x["name"] in roles.keys()
]
user_roles = set(
x.name for x in await user.roles if x.name in groups_with_role
)
missing_groups = set(user_groups_with_role) - set(user_roles)
missing_roles = set(user_roles) - set(user_groups_with_role)
if len(missing_groups) > 0:
await roleUserDao.create_many(
[
RoleUser(0, (await roleDao.get_by_name(group)).id, user.id)
for group in missing_groups
]
)
if len(missing_roles) > 0:
await roleUserDao.delete_many(
[
await roleUserDao.get_single_by(
[
{RoleUser.role_id: roles[role].id},
{RoleUser.user_id: user.id},
]
)
for role in missing_roles
]
)
except Exception as e:
logger.error("Failed to map user groups", e)
@classmethod @classmethod
async def _create_user(cls, kc_user: KeycloakUser): async def _create_user(cls, kc_user: KeycloakUser):
try: try:
@ -101,8 +155,11 @@ class RouteUserExtension:
user = await cls.get_user() user = await cls.get_user()
if user is None: if user is None:
await cls._create_user(KeycloakUser(user_info)) u_id = await cls._create_user(KeycloakUser(user_info))
await cls._map_keycloak_groups_with_roles(await userDao.get_by_id(u_id))
return True return True
else:
await cls._map_keycloak_groups_with_roles(user)
if user.deleted: if user.deleted:
return False return False

View File

@ -69,8 +69,10 @@ class QueryABC(ObjectType):
): ):
if len(permissions) > 0: if len(permissions) > 0:
user = await Route.get_authenticated_user_or_api_key_or_default() user = await Route.get_authenticated_user_or_api_key_or_default()
perms = await user.permissions
has_perms = [await user.has_permission(x) for x in permissions]
if user is not None and all( if user is not None and all(
[await user.has_permission(x) for x in permissions] has_perms
): ):
return return

View File

@ -0,0 +1,13 @@
from api_graphql.abc.db_model_filter_abc import DbModelFilterABC
from api_graphql.abc.filter.string_filter import StringFilter
class DomainFilter(DbModelFilterABC):
def __init__(
self,
obj: dict,
):
DbModelFilterABC.__init__(self, obj)
self.add_field("name", StringFilter)
self.add_field("description", StringFilter)

View File

@ -0,0 +1,53 @@
type DomainResult {
totalCount: Int
count: Int
nodes: [Domain]
}
type Domain implements DbModel {
id: ID
name: String
shortUrls: [ShortUrl]
deleted: Boolean
editor: User
createdUtc: String
updatedUtc: String
}
input DomainSort {
id: SortOrder
name: SortOrder
deleted: SortOrder
editorId: SortOrder
createdUtc: SortOrder
updatedUtc: SortOrder
}
input DomainFilter {
id: IntFilter
name: StringFilter
deleted: BooleanFilter
editor: IntFilter
createdUtc: DateFilter
updatedUtc: DateFilter
}
type DomainMutation {
create(input: DomainCreateInput!): Domain
update(input: DomainUpdateInput!): Domain
delete(id: ID!): Boolean
restore(id: ID!): Boolean
}
input DomainCreateInput {
name: String!
}
input DomainUpdateInput {
id: ID!
name: String
}

View File

@ -9,6 +9,7 @@ type Group implements DbModel {
name: String name: String
shortUrls: [ShortUrl] shortUrls: [ShortUrl]
roles: [Role]
deleted: Boolean deleted: Boolean
editor: User editor: User
@ -45,9 +46,11 @@ type GroupMutation {
input GroupCreateInput { input GroupCreateInput {
name: String! name: String!
roles: [ID]
} }
input GroupUpdateInput { input GroupUpdateInput {
id: ID! id: ID!
name: String name: String
roles: [ID]
} }

View File

@ -5,5 +5,6 @@ type Mutation {
role: RoleMutation role: RoleMutation
group: GroupMutation group: GroupMutation
domain: DomainMutation
shortUrl: ShortUrlMutation shortUrl: ShortUrlMutation
} }

View File

@ -11,6 +11,7 @@ type Query {
userHasAnyPermission(permissions: [String]!): Boolean userHasAnyPermission(permissions: [String]!): Boolean
notExistingUsersFromKeycloak: KeycloakUserResult notExistingUsersFromKeycloak: KeycloakUserResult
domains(filter: [DomainFilter], sort: [DomainSort], skip: Int, take: Int): DomainResult
groups(filter: [GroupFilter], sort: [GroupSort], skip: Int, take: Int): GroupResult groups(filter: [GroupFilter], sort: [GroupSort], skip: Int, take: Int): GroupResult
shortUrls(filter: [ShortUrlFilter], sort: [ShortUrlSort], skip: Int, take: Int): ShortUrlResult shortUrls(filter: [ShortUrlFilter], sort: [ShortUrlSort], skip: Int, take: Int): ShortUrlResult
} }

View File

@ -11,6 +11,7 @@ type ShortUrl implements DbModel {
description: String description: String
visits: Int visits: Int
group: Group group: Group
domain: Domain
loadingScreen: Boolean loadingScreen: Boolean
deleted: Boolean deleted: Boolean
@ -55,6 +56,7 @@ input ShortUrlCreateInput {
targetUrl: String! targetUrl: String!
description: String description: String
groupId: ID groupId: ID
domainId: ID
loadingScreen: Boolean loadingScreen: Boolean
} }
@ -64,5 +66,6 @@ input ShortUrlUpdateInput {
targetUrl: String targetUrl: String
description: String description: String
groupId: ID groupId: ID
domainId: ID
loadingScreen: Boolean loadingScreen: Boolean
} }

View File

@ -0,0 +1,13 @@
from api_graphql.abc.input_abc import InputABC
class DomainCreateInput(InputABC):
def __init__(self, src: dict):
InputABC.__init__(self, src)
self._name = self.option("name", str, required=True)
@property
def name(self) -> str:
return self._name

View File

@ -0,0 +1,18 @@
from api_graphql.abc.input_abc import InputABC
class DomainUpdateInput(InputABC):
def __init__(self, src: dict):
InputABC.__init__(self, src)
self._id = self.option("id", int, required=True)
self._name = self.option("name", str)
@property
def id(self) -> int:
return self._id
@property
def name(self) -> str:
return self._name

View File

@ -7,7 +7,12 @@ class GroupCreateInput(InputABC):
InputABC.__init__(self, src) InputABC.__init__(self, src)
self._name = self.option("name", str, required=True) self._name = self.option("name", str, required=True)
self._roles = self.option("roles", list[int])
@property @property
def name(self) -> str: def name(self) -> str:
return self._name return self._name
@property
def roles(self) -> list[int]:
return self._roles

View File

@ -8,6 +8,7 @@ class GroupUpdateInput(InputABC):
self._id = self.option("id", int, required=True) self._id = self.option("id", int, required=True)
self._name = self.option("name", str) self._name = self.option("name", str)
self._roles = self.option("roles", list[int])
@property @property
def id(self) -> int: def id(self) -> int:
@ -16,3 +17,7 @@ class GroupUpdateInput(InputABC):
@property @property
def name(self) -> str: def name(self) -> str:
return self._name return self._name
@property
def roles(self) -> list[int]:
return self._roles

View File

@ -12,6 +12,7 @@ class ShortUrlCreateInput(InputABC):
self._target_url = self.option("targetUrl", str, required=True) self._target_url = self.option("targetUrl", str, required=True)
self._description = self.option("description", str) self._description = self.option("description", str)
self._group_id = self.option("groupId", int) self._group_id = self.option("groupId", int)
self._domain_id = self.option("domainId", int)
self._loading_screen = self.option("loadingScreen", bool) self._loading_screen = self.option("loadingScreen", bool)
@property @property
@ -30,6 +31,10 @@ class ShortUrlCreateInput(InputABC):
def group_id(self) -> Optional[int]: def group_id(self) -> Optional[int]:
return self._group_id return self._group_id
@property
def domain_id(self) -> Optional[int]:
return self._domain_id
@property @property
def loading_screen(self) -> Optional[str]: def loading_screen(self) -> Optional[str]:
return self._loading_screen return self._loading_screen

View File

@ -13,6 +13,7 @@ class ShortUrlUpdateInput(InputABC):
self._target_url = self.option("targetUrl", str) self._target_url = self.option("targetUrl", str)
self._description = self.option("description", str) self._description = self.option("description", str)
self._group_id = self.option("groupId", int) self._group_id = self.option("groupId", int)
self._domain_id = self.option("domainId", int)
self._loading_screen = self.option("loadingScreen", bool) self._loading_screen = self.option("loadingScreen", bool)
@property @property
@ -35,6 +36,10 @@ class ShortUrlUpdateInput(InputABC):
def group_id(self) -> Optional[int]: def group_id(self) -> Optional[int]:
return self._group_id return self._group_id
@property
def domain_id(self) -> Optional[int]:
return self._domain_id
@property @property
def loading_screen(self) -> Optional[str]: def loading_screen(self) -> Optional[str]:
return self._loading_screen return self._loading_screen

View File

@ -33,6 +33,15 @@ class Mutation(MutationABC):
], ],
) )
self.add_mutation_type(
"domain",
"Domain",
require_any_permission=[
Permissions.domains_create,
Permissions.domains_update,
Permissions.domains_delete,
],
)
self.add_mutation_type( self.add_mutation_type(
"group", "group",
"Group", "Group",

View File

@ -0,0 +1,75 @@
from api_graphql.abc.mutation_abc import MutationABC
from api_graphql.input.domain_create_input import DomainCreateInput
from api_graphql.input.domain_update_input import DomainUpdateInput
from api_graphql.input.group_create_input import GroupCreateInput
from api_graphql.input.group_update_input import GroupUpdateInput
from core.logger import APILogger
from data.schemas.public.domain_dao import domainDao
from data.schemas.public.group import Group
from service.permission.permissions_enum import Permissions
logger = APILogger(__name__)
class DomainMutation(MutationABC):
def __init__(self):
MutationABC.__init__(self, "Domain")
self.mutation(
"create",
self.resolve_create,
DomainCreateInput,
require_any_permission=[Permissions.domains_create],
)
self.mutation(
"update",
self.resolve_update,
DomainUpdateInput,
require_any_permission=[Permissions.domains_update],
)
self.mutation(
"delete",
self.resolve_delete,
require_any_permission=[Permissions.domains_delete],
)
self.mutation(
"restore",
self.resolve_restore,
require_any_permission=[Permissions.domains_delete],
)
@staticmethod
async def resolve_create(obj: GroupCreateInput, *_):
logger.debug(f"create domain: {obj.__dict__}")
domain = Group(
0,
obj.name,
)
nid = await domainDao.create(domain)
return await domainDao.get_by_id(nid)
@staticmethod
async def resolve_update(obj: GroupUpdateInput, *_):
logger.debug(f"update domain: {input}")
if obj.name is not None:
domain = await domainDao.get_by_id(obj.id)
domain.name = obj.name
await domainDao.update(domain)
return await domainDao.get_by_id(obj.id)
@staticmethod
async def resolve_delete(*_, id: str):
logger.debug(f"delete domain: {id}")
domain = await domainDao.get_by_id(id)
await domainDao.delete(domain)
return True
@staticmethod
async def resolve_restore(*_, id: str):
logger.debug(f"restore domain: {id}")
domain = await domainDao.get_by_id(id)
await domainDao.restore(domain)
return True

View File

@ -1,9 +1,13 @@
from typing import Optional
from api_graphql.abc.mutation_abc import MutationABC from api_graphql.abc.mutation_abc import MutationABC
from api_graphql.input.group_create_input import GroupCreateInput from api_graphql.input.group_create_input import GroupCreateInput
from api_graphql.input.group_update_input import GroupUpdateInput from api_graphql.input.group_update_input import GroupUpdateInput
from core.logger import APILogger from core.logger import APILogger
from data.schemas.public.group import Group from data.schemas.public.group import Group
from data.schemas.public.group_dao import groupDao from data.schemas.public.group_dao import groupDao
from data.schemas.public.group_role_assignment import GroupRoleAssignment
from data.schemas.public.group_role_assignment_dao import groupRoleAssignmentDao
from service.permission.permissions_enum import Permissions from service.permission.permissions_enum import Permissions
logger = APILogger(__name__) logger = APILogger(__name__)
@ -37,25 +41,61 @@ class GroupMutation(MutationABC):
) )
@staticmethod @staticmethod
async def resolve_create(obj: GroupCreateInput, *_): async def _handle_group_role_assignments(gid: int, roles: Optional[list[int]]):
if roles is None:
return
existing_roles = await groupDao.get_roles(gid)
existing_role_ids = {role.id for role in existing_roles}
new_role_ids = set(roles)
roles_to_add = new_role_ids - existing_role_ids
roles_to_remove = existing_role_ids - new_role_ids
if roles_to_add:
group_role_assignments = [
GroupRoleAssignment(0, gid, role_id) for role_id in roles_to_add
]
await groupRoleAssignmentDao.create_many(group_role_assignments)
if roles_to_remove:
assignments_to_remove = await groupRoleAssignmentDao.find_by(
[
{GroupRoleAssignment.group_id: gid},
{GroupRoleAssignment.role_id: {"in": roles_to_remove}},
]
)
await groupRoleAssignmentDao.delete_many(assignments_to_remove)
@classmethod
async def resolve_create(cls, obj: GroupCreateInput, *_):
logger.debug(f"create group: {obj.__dict__}") logger.debug(f"create group: {obj.__dict__}")
group = Group( group = Group(
0, 0,
obj.name, obj.name,
) )
nid = await groupDao.create(group) gid = await groupDao.create(group)
return await groupDao.get_by_id(nid)
@staticmethod await cls._handle_group_role_assignments(gid, obj.roles)
async def resolve_update(obj: GroupUpdateInput, *_):
return await groupDao.get_by_id(gid)
@classmethod
async def resolve_update(cls, obj: GroupUpdateInput, *_):
logger.debug(f"update group: {input}") logger.debug(f"update group: {input}")
if await groupDao.find_by_id(obj.id) is None:
raise ValueError(f"Group with id {obj.id} not found")
if obj.name is not None: if obj.name is not None:
group = await groupDao.get_by_id(obj.id) group = await groupDao.get_by_id(obj.id)
group.name = obj.name group.name = obj.name
await groupDao.update(group) await groupDao.update(group)
await cls._handle_group_role_assignments(obj.id, obj.roles)
return await groupDao.get_by_id(obj.id) return await groupDao.get_by_id(obj.id)
@staticmethod @staticmethod

View File

@ -4,6 +4,7 @@ from api_graphql.abc.mutation_abc import MutationABC
from api_graphql.input.short_url_create_input import ShortUrlCreateInput from api_graphql.input.short_url_create_input import ShortUrlCreateInput
from api_graphql.input.short_url_update_input import ShortUrlUpdateInput from api_graphql.input.short_url_update_input import ShortUrlUpdateInput
from core.logger import APILogger from core.logger import APILogger
from data.schemas.public.domain_dao import domainDao
from data.schemas.public.group_dao import groupDao from data.schemas.public.group_dao import groupDao
from data.schemas.public.short_url import ShortUrl from data.schemas.public.short_url import ShortUrl
from data.schemas.public.short_url_dao import shortUrlDao from data.schemas.public.short_url_dao import shortUrlDao
@ -49,6 +50,7 @@ class ShortUrlMutation(MutationABC):
obj.target_url, obj.target_url,
obj.description, obj.description,
obj.group_id, obj.group_id,
obj.domain_id,
obj.loading_screen, obj.loading_screen,
) )
nid = await shortUrlDao.create(short_url) nid = await shortUrlDao.create(short_url)
@ -74,6 +76,16 @@ class ShortUrlMutation(MutationABC):
if group_by_id is None: if group_by_id is None:
raise NotFound(f"Group with id {obj.group_id} does not exist") raise NotFound(f"Group with id {obj.group_id} does not exist")
short_url.group_id = obj.group_id short_url.group_id = obj.group_id
else:
short_url.group_id = None
if obj.domain_id is not None:
domain_by_id = await domainDao.find_by_id(obj.domain_id)
if domain_by_id is None:
raise NotFound(f"Domain with id {obj.domain_id} does not exist")
short_url.domain_id = obj.domain_id
else:
short_url.domain_id = None
if obj.loading_screen is not None: if obj.loading_screen is not None:
short_url.loading_screen = obj.loading_screen short_url.loading_screen = obj.loading_screen

View File

@ -0,0 +1,17 @@
from api_graphql.abc.db_model_query_abc import DbModelQueryABC
from data.schemas.public.domain import Domain
from data.schemas.public.group import Group
from data.schemas.public.short_url import ShortUrl
from data.schemas.public.short_url_dao import shortUrlDao
class DomainQuery(DbModelQueryABC):
def __init__(self):
DbModelQueryABC.__init__(self, "Domain")
self.set_field("name", lambda x, *_: x.name)
self.set_field("shortUrls", self._get_urls)
@staticmethod
async def _get_urls(domain: Domain, *_):
return await shortUrlDao.find_by({ShortUrl.domain_id: domain.id})

View File

@ -1,7 +1,11 @@
from api_graphql.abc.db_model_query_abc import DbModelQueryABC from api_graphql.abc.db_model_query_abc import DbModelQueryABC
from api_graphql.field.resolver_field_builder import ResolverFieldBuilder
from api_graphql.require_any_resolvers import group_by_assignment_resolver
from data.schemas.public.group import Group from data.schemas.public.group import Group
from data.schemas.public.group_dao import groupDao
from data.schemas.public.short_url import ShortUrl from data.schemas.public.short_url import ShortUrl
from data.schemas.public.short_url_dao import shortUrlDao from data.schemas.public.short_url_dao import shortUrlDao
from service.permission.permissions_enum import Permissions
class GroupQuery(DbModelQueryABC): class GroupQuery(DbModelQueryABC):
@ -9,8 +13,19 @@ class GroupQuery(DbModelQueryABC):
DbModelQueryABC.__init__(self, "Group") DbModelQueryABC.__init__(self, "Group")
self.set_field("name", lambda x, *_: x.name) self.set_field("name", lambda x, *_: x.name)
self.set_field("shortUrls", self._get_urls) self.field(
ResolverFieldBuilder("shortUrls")
.with_resolver(self._get_urls)
.with_require_any([
Permissions.groups,
], [group_by_assignment_resolver])
)
self.set_field("roles", self._get_roles)
@staticmethod @staticmethod
async def _get_urls(group: Group, *_): async def _get_urls(group: Group, *_):
return await shortUrlDao.find_by({ShortUrl.group_id: group.id}) return await shortUrlDao.find_by({ShortUrl.group_id: group.id})
@staticmethod
async def _get_roles(group: Group, *_):
return await groupDao.get_roles(group.id)

View File

@ -9,5 +9,6 @@ class ShortUrlQuery(DbModelQueryABC):
self.set_field("targetUrl", lambda x, *_: x.target_url) self.set_field("targetUrl", lambda x, *_: x.target_url)
self.set_field("description", lambda x, *_: x.description) self.set_field("description", lambda x, *_: x.description)
self.set_field("group", lambda x, *_: x.group) self.set_field("group", lambda x, *_: x.group)
self.set_field("domain", lambda x, *_: x.domain)
self.set_field("visits", lambda x, *_: x.visit_count) self.set_field("visits", lambda x, *_: x.visit_count)
self.set_field("loadingScreen", lambda x, *_: x.loading_screen) self.set_field("loadingScreen", lambda x, *_: x.loading_screen)

View File

@ -5,11 +5,13 @@ from api_graphql.abc.sort_abc import Sort
from api_graphql.field.dao_field_builder import DaoFieldBuilder from api_graphql.field.dao_field_builder import DaoFieldBuilder
from api_graphql.field.resolver_field_builder import ResolverFieldBuilder from api_graphql.field.resolver_field_builder import ResolverFieldBuilder
from api_graphql.filter.api_key_filter import ApiKeyFilter from api_graphql.filter.api_key_filter import ApiKeyFilter
from api_graphql.filter.domain_filter import DomainFilter
from api_graphql.filter.group_filter import GroupFilter from api_graphql.filter.group_filter import GroupFilter
from api_graphql.filter.permission_filter import PermissionFilter from api_graphql.filter.permission_filter import PermissionFilter
from api_graphql.filter.role_filter import RoleFilter from api_graphql.filter.role_filter import RoleFilter
from api_graphql.filter.short_url_filter import ShortUrlFilter from api_graphql.filter.short_url_filter import ShortUrlFilter
from api_graphql.filter.user_filter import UserFilter from api_graphql.filter.user_filter import UserFilter
from api_graphql.require_any_resolvers import group_by_assignment_resolver
from data.schemas.administration.api_key import ApiKey from data.schemas.administration.api_key import ApiKey
from data.schemas.administration.api_key_dao import apiKeyDao from data.schemas.administration.api_key_dao import apiKeyDao
from data.schemas.administration.user import User from data.schemas.administration.user import User
@ -18,6 +20,8 @@ from data.schemas.permission.permission import Permission
from data.schemas.permission.permission_dao import permissionDao from data.schemas.permission.permission_dao import permissionDao
from data.schemas.permission.role import Role from data.schemas.permission.role import Role
from data.schemas.permission.role_dao import roleDao from data.schemas.permission.role_dao import roleDao
from data.schemas.public.domain import Domain
from data.schemas.public.domain_dao import domainDao
from data.schemas.public.group import Group from data.schemas.public.group import Group
from data.schemas.public.group_dao import groupDao from data.schemas.public.group_dao import groupDao
from data.schemas.public.short_url import ShortUrl from data.schemas.public.short_url import ShortUrl
@ -48,7 +52,15 @@ class Query(QueryABC):
.with_dao(roleDao) .with_dao(roleDao)
.with_filter(RoleFilter) .with_filter(RoleFilter)
.with_sort(Sort[Role]) .with_sort(Sort[Role])
.with_require_any_permission([Permissions.roles]) .with_require_any_permission(
[
Permissions.roles,
Permissions.users_create,
Permissions.users_update,
Permissions.groups_create,
Permissions.groups_update,
]
)
) )
self.field( self.field(
@ -81,25 +93,38 @@ class Query(QueryABC):
) )
self.field( self.field(
DaoFieldBuilder("groups") DaoFieldBuilder("domains")
.with_dao(groupDao) .with_dao(domainDao)
.with_filter(GroupFilter) .with_filter(DomainFilter)
.with_sort(Sort[Group]) .with_sort(Sort[Domain])
.with_require_any_permission( .with_require_any_permission(
[ [
Permissions.groups, Permissions.domains,
Permissions.short_urls_create, Permissions.short_urls_create,
Permissions.short_urls_update, Permissions.short_urls_update,
] ]
) )
) )
# partially public to load redirect if not resolved/redirected by api self.field(
DaoFieldBuilder("groups")
.with_dao(groupDao)
.with_filter(GroupFilter)
.with_sort(Sort[Group])
.with_require_any(
[
Permissions.groups,
Permissions.short_urls_create,
Permissions.short_urls_update,
],
[group_by_assignment_resolver]
)
)
self.field( self.field(
DaoFieldBuilder("shortUrls") DaoFieldBuilder("shortUrls")
.with_dao(shortUrlDao) .with_dao(shortUrlDao)
.with_filter(ShortUrlFilter) .with_filter(ShortUrlFilter)
.with_sort(Sort[ShortUrl]) .with_sort(Sort[ShortUrl])
.with_require_any_permission([Permissions.short_urls]) .with_require_any([Permissions.short_urls], [group_by_assignment_resolver])
) )
@staticmethod @staticmethod

View File

@ -0,0 +1,22 @@
from api_graphql.service.collection_result import CollectionResult
from api_graphql.service.query_context import QueryContext
from data.schemas.public.group_dao import groupDao
from service.permission.permissions_enum import Permissions
async def group_by_assignment_resolver(ctx: QueryContext) -> bool:
if not isinstance(ctx.data, CollectionResult):
return False
if ctx.has_permission(Permissions.short_urls_by_assignment):
groups = [await x.group for x in ctx.data.nodes]
role_ids = {x.id for x in await ctx.user.roles}
filtered_groups = [
g.id for g in groups if
g is not None and (roles := await groupDao.get_roles(g.id)) and all(r.id in role_ids for r in roles)
]
ctx.data.nodes = [node for node in ctx.data.nodes if (await node.group) is not None and (await node.group).id in filtered_groups]
return True
return True

View File

@ -14,7 +14,7 @@ class QueryContext:
self, self,
data: Any, data: Any,
user: Optional[User], user: Optional[User],
user_permissions: Optional[list[Permission]], user_permissions: Optional[list[Permissions]],
*args, *args,
**kwargs **kwargs
): ):
@ -23,7 +23,7 @@ class QueryContext:
self._user = user self._user = user
if user_permissions is None: if user_permissions is None:
user_permissions = [] user_permissions = []
self._user_permissions: list[str] = [x.name for x in user_permissions] self._user_permissions: list[str] = [x.value for x in user_permissions]
self._resolve_info = None self._resolve_info = None
for arg in args: for arg in args:

View File

@ -1,11 +1,15 @@
from collections.abc import Awaitable from collections.abc import Awaitable
from typing import Callable, Union, Optional from typing import Callable, Union, Optional, Coroutine, Any
from api_graphql.service.query_context import QueryContext from api_graphql.service.query_context import QueryContext
from service.permission.permissions_enum import Permissions from service.permission.permissions_enum import Permissions
TRequireAnyPermissions = Optional[list[Permissions]] TRequireAnyPermissions = Optional[list[Permissions]]
TRequireAnyResolvers = list[ TRequireAnyResolvers = list[
Union[Callable[[QueryContext], bool], Awaitable[[QueryContext], bool]] Union[
Callable[[QueryContext], bool],
Awaitable[[QueryContext], bool],
Callable[[QueryContext], Coroutine[Any, Any, bool]]
]
] ]
TRequireAny = tuple[TRequireAnyPermissions, TRequireAnyResolvers] TRequireAny = tuple[TRequireAnyPermissions, TRequireAnyResolvers]

View File

@ -370,6 +370,9 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
if isinstance(value, NoneType): if isinstance(value, NoneType):
return "NULL" return "NULL"
if value is None:
return "NULL"
if isinstance(value, Enum): if isinstance(value, Enum):
return str(value.value) return str(value.value)
@ -411,13 +414,13 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
) -> str: ) -> str:
query = f"SELECT * FROM {self._table_name}" query = f"SELECT * FROM {self._table_name}"
if filters and len(filters) > 0: if filters is not None and (not isinstance(filters, list) or len(filters) > 0):
query += f" WHERE {self._build_conditions(filters)}" query += f" WHERE {self._build_conditions(filters)}"
if sorts and len(sorts) > 0: if sorts is not None and (not isinstance(sorts, list) or len(sorts) > 0):
query += f" ORDER BY {self._build_order_by(sorts)}" query += f" ORDER BY {self._build_order_by(sorts)}"
if take: if take is not None:
query += f" LIMIT {take}" query += f" LIMIT {take}"
if skip: if skip is not None:
query += f" OFFSET {skip}" query += f" OFFSET {skip}"
return query return query
@ -452,14 +455,21 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
) )
else: else:
sub_conditions.append( sub_conditions.append(
f"{db_name} = {self._get_value_sql(value)}" self._get_value_validation_sql(db_name, value)
) )
conditions.append(f"({' OR '.join(sub_conditions)})") conditions.append(f"({' OR '.join(sub_conditions)})")
else: else:
conditions.append(f"{db_name} = {self._get_value_sql(values)}") conditions.append(self._get_value_validation_sql(db_name, values))
return " AND ".join(conditions) return " AND ".join(conditions)
def _get_value_validation_sql(self, field: str, value: Any):
value = self._get_value_sql(value)
if value == "NULL":
return f"{field} IS NULL"
return f"{field} = {value}"
def _build_condition(self, db_name: str, operator: str, value: Any) -> str: def _build_condition(self, db_name: str, operator: str, value: Any) -> str:
""" """
Build individual SQL condition based on the operator Build individual SQL condition based on the operator

View File

@ -24,18 +24,25 @@ def get_value(
if key in source: if key in source:
value = source[key] value = source[key]
if isinstance(value, cast_type): if isinstance(
value,
cast_type if not hasattr(cast_type, "__origin__") else cast_type.__origin__,
):
return value return value
try: try:
if cast_type == bool: if cast_type == bool:
return value.lower() in ["true", "1"] return value.lower() in ["true", "1"]
if cast_type == list: if (
cast_type
if not hasattr(cast_type, "__origin__")
else cast_type.__origin__
) == list:
subtype = ( subtype = (
cast_type.__args__[0] if hasattr(cast_type, "__args__") else None cast_type.__args__[0] if hasattr(cast_type, "__args__") else None
) )
value = ast.literal_eval(value) value = value.split(list_delimiter)
return [ return [
subtype(item) if subtype is not None else item for item in value subtype(item) if subtype is not None else item for item in value
] ]

View File

@ -42,17 +42,9 @@ class User(DbModelABC):
@async_property @async_property
async def permissions(self): async def permissions(self):
from data.schemas.permission.role_user_dao import roleUserDao from data.schemas.administration.user_dao import userDao
from data.schemas.permission.role_permission_dao import rolePermissionDao
from data.schemas.permission.permission_dao import permissionDao
x = [ return await userDao.get_permissions(self.id)
rp.permission_id
for x in await roleUserDao.get_by_user_id(self.id)
for rp in await rolePermissionDao.get_by_role_id(x.role_id)
]
return await permissionDao.get_by({"id": {"in": x}})
async def has_permission(self, permission: Permissions) -> bool: async def has_permission(self, permission: Permissions) -> bool:
from data.schemas.administration.user_dao import userDao from data.schemas.administration.user_dao import userDao

View File

@ -33,13 +33,30 @@ class UserDao(DbModelDaoABC[User]):
SELECT COUNT(*) SELECT COUNT(*)
FROM permission.role_users ru FROM permission.role_users ru
JOIN permission.role_permissions rp ON ru.roleId = rp.roleId JOIN permission.role_permissions rp ON ru.roleId = rp.roleId
WHERE ru.userId = {user_id} AND rp.permissionId = {p.id}; WHERE ru.userId = {user_id}
AND rp.permissionId = {p.id}
AND ru.deleted = FALSE
AND rp.deleted = FALSE;
""" """
) )
if result is None or len(result) == 0: if result is None or len(result) == 0:
return False return False
return True return result[0]["count"] > 0
async def get_permissions(self, user_id: int) -> list[Permissions]:
result = await self._db.select_map(
f"""
SELECT p.*
FROM permission.permissions p
JOIN permission.role_permissions rp ON p.id = rp.permissionId
JOIN permission.role_users ru ON rp.roleId = ru.roleId
WHERE ru.userId = {user_id}
AND rp.deleted = FALSE
AND ru.deleted = FALSE;
"""
)
return [Permissions(p["name"]) for p in result]
userDao = UserDao() userDao = UserDao()

View File

@ -0,0 +1,27 @@
from datetime import datetime
from typing import Optional
from core.database.abc.db_model_abc import DbModelABC
from core.typing import SerialId
class Domain(DbModelABC):
def __init__(
self,
id: SerialId,
name: str,
deleted: bool = False,
editor_id: Optional[SerialId] = None,
created: Optional[datetime] = None,
updated: Optional[datetime] = None,
):
DbModelABC.__init__(self, id, deleted, editor_id, created, updated)
self._name = name
@property
def name(self) -> str:
return self._name
@name.setter
def name(self, value: str):
self._name = value

View File

@ -0,0 +1,22 @@
from core.logger import DBLogger
from data.schemas.public.domain import Domain
from data.schemas.public.group import Group
logger = DBLogger(__name__)
from core.database.abc.db_model_dao_abc import DbModelDaoABC
class DomainDao(DbModelDaoABC[Group]):
def __init__(self):
DbModelDaoABC.__init__(self, __name__, Group, "public.domains")
self.attribute(Domain.name, str)
async def get_by_name(self, name: str) -> Group:
result = await self._db.select_map(
f"SELECT * FROM {self._table_name} WHERE Name = '{name}'"
)
return self.to_object(result[0])
domainDao = DomainDao()

View File

@ -17,5 +17,19 @@ class GroupDao(DbModelDaoABC[Group]):
) )
return self.to_object(result[0]) return self.to_object(result[0])
async def get_roles(self, group_id: int):
result = await self._db.select_map(
f"""
SELECT r.*
FROM permission.roles r
JOIN public.group_role_assignments gra ON r.id = gra.roleId
WHERE gra.groupId = {group_id}
AND gra.deleted = FALSE
"""
)
from data.schemas.permission.role_dao import roleDao
return [roleDao.to_object(x) for x in result]
groupDao = GroupDao() groupDao = GroupDao()

View File

@ -0,0 +1,43 @@
from datetime import datetime
from typing import Optional
from async_property import async_property
from core.database.abc.db_model_abc import DbModelABC
from core.typing import SerialId
class GroupRoleAssignment(DbModelABC):
def __init__(
self,
id: SerialId,
group_id: SerialId,
role_id: SerialId,
deleted: bool = False,
editor_id: Optional[SerialId] = None,
created: Optional[datetime] = None,
updated: Optional[datetime] = None,
):
DbModelABC.__init__(self, id, deleted, editor_id, created, updated)
self._group_id = group_id
self._role_id = role_id
@property
def group_id(self) -> SerialId:
return self._group_id
@async_property
async def group(self):
from data.schemas.public.group_dao import groupDao
return await groupDao.get_by_id(self._group_id)
@property
def role_id(self) -> SerialId:
return self._role_id
@async_property
async def role(self):
from data.schemas.permission.role_dao import roleDao
return await roleDao.get_by_id(self._role_id)

View File

@ -0,0 +1,37 @@
from core.logger import DBLogger
from data.schemas.public.group_role_assignment import GroupRoleAssignment
logger = DBLogger(__name__)
from core.database.abc.db_model_dao_abc import DbModelDaoABC
class GroupRoleAssignmentDao(DbModelDaoABC[GroupRoleAssignment]):
def __init__(self):
DbModelDaoABC.__init__(
self, __name__, GroupRoleAssignment, "public.group_role_assignments"
)
self.attribute(GroupRoleAssignment.group_id, int)
self.attribute(GroupRoleAssignment.role_id, int)
async def get_by_group_id(
self, gid: int, with_deleted=False
) -> list[GroupRoleAssignment]:
f = [{GroupRoleAssignment.group_id: gid}]
if not with_deleted:
f.append({GroupRoleAssignment.deleted: False})
return await self.find_by(f)
async def get_by_role_id(
self, rid: int, with_deleted=False
) -> list[GroupRoleAssignment]:
f = [{GroupRoleAssignment.role_id: rid}]
if not with_deleted:
f.append({GroupRoleAssignment.deleted: False})
return await self.find_by(f)
groupRoleAssignmentDao = GroupRoleAssignmentDao()

View File

@ -16,6 +16,7 @@ class ShortUrl(DbModelABC):
target_url: str, target_url: str,
description: Optional[str], description: Optional[str],
group_id: Optional[SerialId], group_id: Optional[SerialId],
domain_id: Optional[SerialId],
loading_screen: Optional[str] = None, loading_screen: Optional[str] = None,
deleted: bool = False, deleted: bool = False,
editor_id: Optional[SerialId] = None, editor_id: Optional[SerialId] = None,
@ -27,6 +28,7 @@ class ShortUrl(DbModelABC):
self._target_url = target_url self._target_url = target_url
self._description = description self._description = description
self._group_id = group_id self._group_id = group_id
self._domain_id = domain_id
self._loading_screen = loading_screen self._loading_screen = loading_screen
@property @property
@ -70,6 +72,23 @@ class ShortUrl(DbModelABC):
return await groupDao.get_by_id(self._group_id) return await groupDao.get_by_id(self._group_id)
@property
def domain_id(self) -> SerialId:
return self._domain_id
@domain_id.setter
def domain_id(self, value: SerialId):
self._domain_id = value
@async_property
async def domain(self) -> Optional[Group]:
if self._domain_id is None:
return None
from data.schemas.public.domain_dao import domainDao
return await domainDao.get_by_id(self._domain_id)
@async_property @async_property
async def visit_count(self) -> int: async def visit_count(self) -> int:
from data.schemas.public.short_url_visit_dao import shortUrlVisitDao from data.schemas.public.short_url_visit_dao import shortUrlVisitDao

View File

@ -13,6 +13,7 @@ class ShortUrlDao(DbModelDaoABC[ShortUrl]):
self.attribute(ShortUrl.target_url, str) self.attribute(ShortUrl.target_url, str)
self.attribute(ShortUrl.description, str) self.attribute(ShortUrl.description, str)
self.attribute(ShortUrl.group_id, int) self.attribute(ShortUrl.group_id, int)
self.attribute(ShortUrl.domain_id, int)
self.attribute(ShortUrl.loading_screen, bool) self.attribute(ShortUrl.loading_screen, bool)

View File

@ -0,0 +1,32 @@
CREATE
SCHEMA IF NOT EXISTS public;
-- groups
CREATE TABLE IF NOT EXISTS public.domains
(
Id SERIAL PRIMARY KEY,
Name VARCHAR(255) NOT NULL,
-- for history
Deleted BOOLEAN NOT NULL DEFAULT FALSE,
EditorId INT NULL REFERENCES administration.users (Id),
CreatedUtc timestamptz NOT NULL DEFAULT NOW(),
UpdatedUtc timestamptz NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS public.domains_history
(
LIKE public.domains
);
CREATE TRIGGER domains_history_trigger
BEFORE INSERT OR UPDATE OR DELETE
ON public.domains
FOR EACH ROW
EXECUTE FUNCTION public.history_trigger_function();
ALTER TABLE public.short_urls
ADD COLUMN domainId INT NULL REFERENCES public.domains (Id);
ALTER TABLE public.short_urls_history
ADD COLUMN domainId INT NULL REFERENCES public.domains (Id);

View File

@ -0,0 +1,27 @@
CREATE
SCHEMA IF NOT EXISTS public;
-- groups
CREATE TABLE IF NOT EXISTS public.group_role_assignments
(
Id SERIAL PRIMARY KEY,
GroupId INT NOT NULL REFERENCES public.groups (Id),
RoleId INT NOT NULL REFERENCES permission.roles (Id),
-- for history
Deleted BOOLEAN NOT NULL DEFAULT FALSE,
EditorId INT NULL REFERENCES administration.users (Id),
CreatedUtc timestamptz NOT NULL DEFAULT NOW(),
UpdatedUtc timestamptz NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS public.group_role_assignments_history
(
LIKE public.group_role_assignments
);
CREATE TRIGGER group_role_assignment_history_trigger
BEFORE INSERT OR UPDATE OR DELETE
ON public.group_role_assignments
FOR EACH ROW
EXECUTE FUNCTION public.history_trigger_function();

View File

@ -25,7 +25,8 @@ class PermissionSeeder(DataSeederABC):
possible_permissions = [permission.value for permission in Permissions] possible_permissions = [permission.value for permission in Permissions]
if len(permissions) == len(possible_permissions): if len(permissions) == len(possible_permissions):
logger.info("Permissions already completed") logger.info("Permissions already existing")
await self._update_missing_descriptions()
return return
logger.warning("Permissions incomplete") logger.warning("Permissions incomplete")
@ -41,6 +42,7 @@ class PermissionSeeder(DataSeederABC):
if permission not in permission_names if permission not in permission_names
] ]
) )
await self._update_missing_descriptions()
await self._add_missing_to_role() await self._add_missing_to_role()
await self._add_missing_to_api_key() await self._add_missing_to_api_key()
@ -78,3 +80,28 @@ class PermissionSeeder(DataSeederABC):
if permission.id not in [x.permission_id for x in admin_permissions] if permission.id not in [x.permission_id for x in admin_permissions]
] ]
await apiKeyPermissionDao.create_many(to_assign) await apiKeyPermissionDao.create_many(to_assign)
@staticmethod
async def _update_missing_descriptions():
permissions = {
permission.name: permission
for permission in await permissionDao.find_by(
[{Permission.description: None}]
)
}
to_update = []
if len(permissions) == 0:
return
for key in PERMISSION_DESCRIPTIONS:
if key.value not in permissions:
continue
permissions[key.value].description = PERMISSION_DESCRIPTIONS[key]
to_update.append(permissions[key.value])
if len(to_update) == 0:
return
await permissionDao.update_many(to_update)

View File

@ -24,16 +24,35 @@ class Redirector(Flask):
app = Redirector(__name__) app = Redirector(__name__)
@app.route("/") @app.route("/")
def index(): def index():
return render_template("404.html"), 404 return render_template("404.html"), 404
@app.route("/<path:path>") @app.route("/<path:path>")
async def _handle_request(path: str): async def _handle_request(path: str):
short_url = await _find_short_url_by_url(path) short_url = await _find_short_url_by_url(path)
if short_url is None: if short_url is None:
return render_template("404.html"), 404 return render_template("404.html"), 404
domains = Environment.get("DOMAINS", list[str], [])
domain = await short_url.domain
logger.debug(
f"Domain: {domain.name if domain is not None else None}, request.host: {request.host}"
)
host = request.host
if ":" in host:
host = host.split(":")[0]
domain_strict_mode = Environment.get("DOMAIN_STRICT_MODE", bool, False)
if domain is not None and (
domain.name not in domains
or (domain_strict_mode and not host.endswith(domain.name))
):
return render_template("404.html"), 404
user_agent = request.headers.get("User-Agent", "").lower() user_agent = request.headers.get("User-Agent", "").lower()
if "wheregoes" in user_agent or "someothertool" in user_agent: if "wheregoes" in user_agent or "someothertool" in user_agent:
@ -59,6 +78,7 @@ async def _handle_short_url(path: str, short_url: ShortUrl):
return _do_redirect(short_url.target_url) return _do_redirect(short_url.target_url)
async def _track_visit(short_url: ShortUrl): async def _track_visit(short_url: ShortUrl):
try: try:
await shortUrlVisitDao.create( await shortUrlVisitDao.create(
@ -67,6 +87,7 @@ async def _track_visit(short_url: ShortUrl):
except Exception as e: except Exception as e:
logger.error(f"Failed to update short url {short_url.short_url} with error", e) logger.error(f"Failed to update short url {short_url.short_url} with error", e)
async def _find_short_url_by_url(url: str) -> ShortUrl: async def _find_short_url_by_url(url: str) -> ShortUrl:
return await shortUrlDao.find_single_by({ShortUrl.short_url: url}) return await shortUrlDao.find_single_by({ShortUrl.short_url: url})

View File

@ -31,6 +31,12 @@ class Permissions(Enum):
""" """
Public Public
""" """
# domains
domains = "domains"
domains_create = "domains.create"
domains_update = "domains.update"
domains_delete = "domains.delete"
# groups # groups
groups = "groups" groups = "groups"
groups_create = "groups.create" groups_create = "groups.create"
@ -39,6 +45,7 @@ class Permissions(Enum):
# short_urls # short_urls
short_urls = "short_urls" short_urls = "short_urls"
short_urls_by_assignment = "short_urls.by_assignment"
short_urls_create = "short_urls.create" short_urls_create = "short_urls.create"
short_urls_update = "short_urls.update" short_urls_update = "short_urls.update"
short_urls_delete = "short_urls.delete" short_urls_delete = "short_urls.delete"
@ -46,4 +53,6 @@ class Permissions(Enum):
PERMISSION_DESCRIPTIONS = { PERMISSION_DESCRIPTIONS = {
Permissions.users_update: "Edit users, including changing their roles", Permissions.users_update: "Edit users, including changing their roles",
Permissions.short_urls: "See all URLs",
Permissions.short_urls_by_assignment: "See all short urls assigned to a group by role",
} }

View File

@ -1 +0,0 @@
1.1.0

View File

@ -1,4 +1,4 @@
<main *ngIf="isLoggedIn && !hideUI; else home"> <main *ngIf="isLoggedIn && !hideUI; else home" [class]="theme">
<app-header></app-header> <app-header></app-header>
<div class="app"> <div class="app">

View File

@ -1,16 +1,17 @@
import { Component, OnDestroy } from "@angular/core"; import { Component, OnDestroy } from '@angular/core';
import { SidebarService } from "src/app/service/sidebar.service"; import { SidebarService } from 'src/app/service/sidebar.service';
import { Subject } from "rxjs"; import { Subject } from 'rxjs';
import { takeUntil } from "rxjs/operators"; import { takeUntil } from 'rxjs/operators';
import { AuthService } from "src/app/service/auth.service"; import { AuthService } from 'src/app/service/auth.service';
import { GuiService } from "src/app/service/gui.service"; import { GuiService } from 'src/app/service/gui.service';
@Component({ @Component({
selector: "app-root", selector: 'app-root',
templateUrl: "./app.component.html", templateUrl: './app.component.html',
styleUrl: "./app.component.scss", styleUrl: './app.component.scss',
}) })
export class AppComponent implements OnDestroy { export class AppComponent implements OnDestroy {
theme = 'open-redirect';
showSidebar = false; showSidebar = false;
hideUI = false; hideUI = false;
isLoggedIn = false; isLoggedIn = false;
@ -19,23 +20,27 @@ export class AppComponent implements OnDestroy {
constructor( constructor(
private sidebar: SidebarService, private sidebar: SidebarService,
private auth: AuthService, private auth: AuthService,
private gui: GuiService, private gui: GuiService
) { ) {
this.auth.loadUser(); this.auth.loadUser();
this.auth.user$.pipe(takeUntil(this.unsubscribe$)).subscribe((user) => { this.auth.user$.pipe(takeUntil(this.unsubscribe$)).subscribe(user => {
this.isLoggedIn = user !== null && user !== undefined; this.isLoggedIn = user !== null && user !== undefined;
}); });
this.sidebar.visible$ this.sidebar.visible$
.pipe(takeUntil(this.unsubscribe$)) .pipe(takeUntil(this.unsubscribe$))
.subscribe((visible) => { .subscribe(visible => {
this.showSidebar = visible; this.showSidebar = visible;
}); });
this.gui.hideGui$.pipe(takeUntil(this.unsubscribe$)).subscribe((hide) => { this.gui.hideGui$.pipe(takeUntil(this.unsubscribe$)).subscribe(hide => {
this.hideUI = hide; this.hideUI = hide;
}); });
this.gui.theme$.pipe(takeUntil(this.unsubscribe$)).subscribe(theme => {
this.theme = theme;
});
} }
ngOnDestroy() { ngOnDestroy() {

View File

@ -17,6 +17,18 @@
</div> </div>
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
<div class="flex items-center justify-center">
<p-button
type="button"
icon="pi pi-palette"
class="btn icon-btn p-button-text"
(onClick)="themeMenu.toggle($event)"></p-button>
<p-menu
#themeMenu
[popup]="true"
[model]="themeList"
class="lang-menu"></p-menu>
</div>
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
<p-button <p-button
type="button" type="button"

View File

@ -1,21 +1,24 @@
import { Component, OnDestroy, OnInit } from "@angular/core"; import { Component, OnDestroy, OnInit } from '@angular/core';
import { MenuItem, PrimeNGConfig } from "primeng/api"; import { MenuItem, PrimeNGConfig } from 'primeng/api';
import { Subject } from "rxjs"; import { Subject } from 'rxjs';
import { takeUntil } from "rxjs/operators"; import { takeUntil } from 'rxjs/operators';
import { TranslateService } from "@ngx-translate/core"; import { TranslateService } from '@ngx-translate/core';
import { GuiService } from "src/app/service/gui.service"; import { GuiService } from 'src/app/service/gui.service';
import { User } from "src/app/model/auth/user"; 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';
@Component({ @Component({
selector: "app-header", selector: 'app-header',
templateUrl: "./header.component.html", templateUrl: './header.component.html',
styleUrls: ["./header.component.scss"], styleUrls: ['./header.component.scss'],
}) })
export class HeaderComponent implements OnInit, OnDestroy { export class HeaderComponent implements OnInit, OnDestroy {
langList: MenuItem[] = []; langList: MenuItem[] = [];
themeList: MenuItem[] = [];
userMenuList!: MenuItem[]; userMenuList!: MenuItem[];
user: User | null = null; user: User | null = null;
private unsubscribe$ = new Subject<void>(); private unsubscribe$ = new Subject<void>();
@ -29,22 +32,30 @@ 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 settings: SettingsService
) { ) {
this.guiService.isMobile$ this.guiService.isMobile$
.pipe(takeUntil(this.unsubscribe$)) .pipe(takeUntil(this.unsubscribe$))
.subscribe((isMobile) => { .subscribe(isMobile => {
this.isMobile = isMobile; this.isMobile = isMobile;
if (isMobile) { if (isMobile) {
this.sidebarService.hide(); this.sidebarService.hide();
} }
}); });
this.auth.user$ this.auth.user$.pipe(takeUntil(this.unsubscribe$)).subscribe(async user => {
.pipe(takeUntil(this.unsubscribe$))
.subscribe(async (user) => {
this.user = user; this.user = user;
await this.initMenuLists(); await this.initMenuLists();
}); });
this.themeList = this.settings.settings.themes.map(theme => {
return {
label: theme.label,
command: () => {
this.guiService.theme$.next(theme.name);
},
};
});
} }
async ngOnInit() { async ngOnInit() {
@ -69,17 +80,17 @@ export class HeaderComponent implements OnInit, OnDestroy {
async initLangMenuList() { async initLangMenuList() {
this.langList = [ this.langList = [
{ {
label: "English", label: 'English',
command: () => { command: () => {
this.translate("en"); this.translate('en');
this.setLang("en"); this.setLang('en');
}, },
}, },
{ {
label: "Deutsch", label: 'Deutsch',
command: () => { command: () => {
this.translate("de"); this.translate('de');
this.setLang("de"); this.setLang('de');
}, },
}, },
]; ];
@ -96,13 +107,13 @@ export class HeaderComponent implements OnInit, OnDestroy {
separator: true, separator: true,
}, },
{ {
label: this.translateService.instant("header.logout"), label: this.translateService.instant('header.logout'),
command: () => { command: () => {
this.auth.logout().then(() => { this.auth.logout().then(() => {
console.log("logout"); console.log('logout');
}); });
}, },
icon: "pi pi-sign-out", icon: 'pi pi-sign-out',
}, },
]; ];
} }
@ -110,19 +121,19 @@ export class HeaderComponent implements OnInit, OnDestroy {
translate(lang: string) { translate(lang: string) {
this.translateService.use(lang); this.translateService.use(lang);
this.translateService this.translateService
.get("primeng") .get('primeng')
.subscribe((res) => this.config.setTranslation(res)); .subscribe(res => this.config.setTranslation(res));
} }
async loadLang() { async loadLang() {
const lang = "en"; const lang = 'en';
this.setLang(lang); this.setLang(lang);
this.translate(lang); this.translate(lang);
} }
setLang(lang: string) { setLang(lang: string) {
// this.settings.setSetting(`lang`, lang); // this.settings.setSetting(`lang`, lang);
console.log("setLang", lang); console.log('setLang', lang);
} }
toggleSidebar() { toggleSidebar() {

View File

@ -1,30 +1,36 @@
export enum PermissionsEnum { export enum PermissionsEnum {
// Administration // Administration
apiKeys = "api_keys", apiKeys = 'api_keys',
apiKeysCreate = "api_keys.create", apiKeysCreate = 'api_keys.create',
apiKeysUpdate = "api_keys.update", apiKeysUpdate = 'api_keys.update',
apiKeysDelete = "api_keys.delete", apiKeysDelete = 'api_keys.delete',
// Users // Users
users = "users", users = 'users',
usersCreate = "users.create", usersCreate = 'users.create',
usersUpdate = "users.update", usersUpdate = 'users.update',
usersDelete = "users.delete", usersDelete = 'users.delete',
// Permissions // Permissions
roles = "roles", roles = 'roles',
rolesCreate = "roles.create", rolesCreate = 'roles.create',
rolesUpdate = "roles.update", rolesUpdate = 'roles.update',
rolesDelete = "roles.delete", rolesDelete = 'roles.delete',
// Public // Public
groups = "groups", domains = 'domains',
groupsCreate = "groups.create", domainsCreate = 'domains.create',
groupsUpdate = "groups.update", domainsUpdate = 'domains.update',
groupsDelete = "groups.delete", domainsDelete = 'domains.delete',
shortUrls = "short_urls", groups = 'groups',
shortUrlsCreate = "short_urls.create", groupsCreate = 'groups.create',
shortUrlsUpdate = "short_urls.update", groupsUpdate = 'groups.update',
shortUrlsDelete = "short_urls.delete", groupsDelete = 'groups.delete',
shortUrls = 'short_urls',
shortUrlsByAssignment = 'short_urls.by_assignment',
shortUrlsCreate = 'short_urls.create',
shortUrlsUpdate = 'short_urls.update',
shortUrlsDelete = 'short_urls.delete',
} }

View File

@ -0,0 +1,14 @@
import { DbModel } from 'src/app/model/entities/db-model';
export interface Domain extends DbModel {
name: string;
}
export interface DomainCreateInput {
name: string;
}
export interface DomainUpdateInput {
id: number;
name: string;
}

View File

@ -1,14 +1,18 @@
import { DbModel } from 'src/app/model/entities/db-model'; import { DbModel } from 'src/app/model/entities/db-model';
import { Role } from 'src/app/model/entities/role';
export interface Group extends DbModel { export interface Group extends DbModel {
name: string; name: string;
roles: Role[];
} }
export interface GroupCreateInput { export interface GroupCreateInput {
name: string; name: string;
roles: Role[];
} }
export interface GroupUpdateInput { export interface GroupUpdateInput {
id: number; id: number;
name: string; name: string;
roles: Role[];
} }

View File

@ -1,5 +1,6 @@
import { DbModel } from 'src/app/model/entities/db-model'; import { DbModel } from 'src/app/model/entities/db-model';
import { Group } from 'src/app/model/entities/group'; import { Group } from 'src/app/model/entities/group';
import { Domain } from 'src/app/model/entities/domain';
export interface ShortUrl extends DbModel { export interface ShortUrl extends DbModel {
shortUrl: string; shortUrl: string;
@ -8,6 +9,7 @@ export interface ShortUrl extends DbModel {
loadingScreen: boolean; loadingScreen: boolean;
visits: number; visits: number;
group?: Group; group?: Group;
domain?: Domain;
} }
export interface ShortUrlDto { export interface ShortUrlDto {
@ -22,6 +24,7 @@ export interface ShortUrlCreateInput {
description: string; description: string;
loadingScreen: boolean; loadingScreen: boolean;
groupId: number; groupId: number;
domainId: number;
} }
export interface ShortUrlUpdateInput { export interface ShortUrlUpdateInput {
@ -31,4 +34,5 @@ export interface ShortUrlUpdateInput {
description: string; description: string;
loadingScreen: boolean; loadingScreen: boolean;
groupId: number; groupId: number;
domainId: number;
} }

View File

@ -6,6 +6,15 @@ import { PermissionGuard } from 'src/app/core/guard/permission.guard';
import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum'; import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum';
const routes: Routes = [ const routes: Routes = [
{
path: 'domains',
loadChildren: () =>
import('src/app/modules/admin/domains/domains.module').then(
m => m.DomainsModule
),
canActivate: [PermissionGuard],
data: { permissions: [PermissionsEnum.domains] },
},
{ {
path: 'groups', path: 'groups',
loadChildren: () => loadChildren: () =>
@ -22,7 +31,12 @@ const routes: Routes = [
m => m.ShortUrlsModule m => m.ShortUrlsModule
), ),
canActivate: [PermissionGuard], canActivate: [PermissionGuard],
data: { permissions: [PermissionsEnum.shortUrls] }, data: {
permissions: [
PermissionsEnum.shortUrls,
PermissionsEnum.shortUrlsByAssignment,
],
},
}, },
{ {
path: 'administration', path: 'administration',

View File

@ -17,11 +17,11 @@
<ng-template formPageContent> <ng-template formPageContent>
<div class="form-page-input"> <div class="form-page-input">
<p class="label">{{ 'common.id' | translate }}</p> <p class="label">{{ 'common.id' | translate }}</p>
<input pInputText class="value" type="number" formControlName="id" /> <input pInputText class="value" type="number" formControlName="id"/>
</div> </div>
<div class="form-page-input"> <div class="form-page-input">
<p class="label">{{ 'common.name' | translate }}</p> <p class="label">{{ 'common.name' | translate }}</p>
<input pInputText class="value" type="text" formControlName="name" /> <input pInputText class="value" type="text" formControlName="name"/>
</div> </div>
<div class="form-page-input"> <div class="form-page-input">
<p class="label">{{ 'common.description' | translate }}</p> <p class="label">{{ 'common.description' | translate }}</p>
@ -29,13 +29,13 @@
pInputText pInputText
class="value" class="value"
type="text" type="text"
formControlName="description" /> formControlName="description"/>
</div> </div>
<div class="divider"></div> <div class="divider"></div>
<div class="flex flex-col gap-5"> <div class="flex flex-col gap-5">
<div *ngFor="let group of Object.keys(permissionGroups)"> <div *ngFor="let group of Object.keys(permissionGroups)">
<div class="flex flex-col"> <div class="flex flex-col gap-2">
<div class="flex justify-between"> <div class="flex justify-between">
<label class="flex-1" for="roles.permission_groups.{{ group }}"> <label class="flex-1" for="roles.permission_groups.{{ group }}">
<h3> <h3>
@ -52,14 +52,20 @@
<div <div
*ngFor="let permission of permissionGroups[group]" *ngFor="let permission of permissionGroups[group]"
class="flex items-center justify-between w-full"> class="flex flex-col">
<div class="flex items-center justify-between w-full">
<label class="flex-1" for="{{ permission.name }}"> <label class="flex-1" for="{{ permission.name }}">
{{ 'permissions.' + permission.name | translate }} {{ 'permissions.' + permission.name | translate }}
</label> </label>
<p-inputSwitch [formControlName]="permission.name"> <p-inputSwitch class="flex items-center justify-center" [formControlName]="permission.name">
>
</p-inputSwitch> </p-inputSwitch>
</div> </div>
<div *ngIf="permission.description">
<p class="text-sm text-gray-500">
{{ permission.description }}
</p>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,21 +1,22 @@
import { Component } from "@angular/core"; import { Component } from '@angular/core';
import { import {
Permission, Permission,
Role, Role,
RoleCreateInput, RoleCreateInput,
RoleUpdateInput, RoleUpdateInput,
} from "src/app/model/entities/role"; } from 'src/app/model/entities/role';
import { InputSwitchChangeEvent } from "primeng/inputswitch"; import { InputSwitchChangeEvent } from 'primeng/inputswitch';
import { ToastService } from "src/app/service/toast.service"; import { ToastService } from 'src/app/service/toast.service';
import { firstValueFrom } from "rxjs"; import { firstValueFrom } from 'rxjs';
import { FormPageBase } from "src/app/core/base/form-page-base"; import { FormPageBase } from 'src/app/core/base/form-page-base';
import { RolesDataService } from "src/app/modules/admin/administration/roles/roles.data.service"; import { RolesDataService } from 'src/app/modules/admin/administration/roles/roles.data.service';
import { FormControl, FormGroup, Validators } from "@angular/forms"; import { FormControl, FormGroup, Validators } from '@angular/forms';
import { TranslateService } from '@ngx-translate/core';
@Component({ @Component({
selector: "app-role-form-page", selector: 'app-role-form-page',
templateUrl: "./role-form-page.component.html", templateUrl: './role-form-page.component.html',
styleUrl: "./role-form-page.component.scss", styleUrl: './role-form-page.component.scss',
}) })
export class RoleFormPageComponent extends FormPageBase< export class RoleFormPageComponent extends FormPageBase<
Role, Role,
@ -26,7 +27,10 @@ export class RoleFormPageComponent extends FormPageBase<
permissionGroups: { [key: string]: Permission[] } = {}; permissionGroups: { [key: string]: Permission[] } = {};
allPermissions: Permission[] = []; allPermissions: Permission[] = [];
constructor(private toast: ToastService) { constructor(
private toast: ToastService,
private translate: TranslateService
) {
super(); super();
this.initializePermissions().then(() => { this.initializePermissions().then(() => {
if (!this.nodeId) { if (!this.nodeId) {
@ -36,7 +40,7 @@ export class RoleFormPageComponent extends FormPageBase<
return; return;
} }
this.dataService.loadById(this.nodeId).subscribe((role) => { this.dataService.loadById(this.nodeId).subscribe(role => {
this.node = role; this.node = role;
this.setForm(this.node); this.setForm(this.node);
}); });
@ -45,12 +49,17 @@ export class RoleFormPageComponent extends FormPageBase<
async initializePermissions() { async initializePermissions() {
const permissions = await firstValueFrom( const permissions = await firstValueFrom(
this.dataService.getAllPermissions(), this.dataService.getAllPermissions()
); );
this.allPermissions = permissions; this.allPermissions = permissions.map(x => {
const key = `permission_descriptions.${x.name}`;
const description = this.translate.instant(key);
x.description = description === key ? undefined : description;
return x;
});
this.permissionGroups = permissions.reduce( this.permissionGroups = permissions.reduce(
(acc, p) => { (acc, p) => {
const group = p.name.includes(".") ? p.name.split(".")[0] : p.name; const group = p.name.includes('.') ? p.name.split('.')[0] : p.name;
if (!acc[group]) { if (!acc[group]) {
acc[group] = []; acc[group] = [];
@ -58,10 +67,10 @@ export class RoleFormPageComponent extends FormPageBase<
acc[group].push(p); acc[group].push(p);
return acc; return acc;
}, },
{} as { [key: string]: Permission[] }, {} as { [key: string]: Permission[] }
); );
permissions.forEach((p) => { permissions.forEach(p => {
this.form.addControl(p.name, new FormControl<boolean>(false)); this.form.addControl(p.name, new FormControl<boolean>(false));
}); });
} }
@ -76,48 +85,48 @@ export class RoleFormPageComponent extends FormPageBase<
name: new FormControl<string | undefined>(undefined, Validators.required), name: new FormControl<string | undefined>(undefined, Validators.required),
description: new FormControl<string | undefined>(undefined), description: new FormControl<string | undefined>(undefined),
}); });
this.form.controls["id"].disable(); this.form.controls['id'].disable();
} }
setForm(node?: Role) { setForm(node?: Role) {
this.form.controls["id"].setValue(node?.id); this.form.controls['id'].setValue(node?.id);
this.form.controls["name"].setValue(node?.name); this.form.controls['name'].setValue(node?.name);
this.form.controls["description"].setValue(node?.description); this.form.controls['description'].setValue(node?.description);
if (!node) return; if (!node) return;
const permissions = node.permissions ?? []; const permissions = node.permissions ?? [];
permissions.forEach((p) => { permissions.forEach(p => {
this.form.controls[p.name].setValue(true); this.form.controls[p.name].setValue(true);
}); });
} }
getCreateInput(): RoleCreateInput { getCreateInput(): RoleCreateInput {
return { return {
name: this.form.controls["name"].pristine name: this.form.controls['name'].pristine
? undefined ? undefined
: (this.form.controls["name"].value ?? undefined), : (this.form.controls['name'].value ?? undefined),
description: this.form.controls["description"].pristine description: this.form.controls['description'].pristine
? undefined ? undefined
: (this.form.controls["description"].value ?? undefined), : (this.form.controls['description'].value ?? undefined),
permissions: this.allPermissions.filter( permissions: this.allPermissions.filter(
(p) => this.form.controls[p.name].value, p => this.form.controls[p.name].value
), ),
}; };
} }
getUpdateInput(): RoleUpdateInput { getUpdateInput(): RoleUpdateInput {
return { return {
id: this.form.controls["id"].value, id: this.form.controls['id'].value,
name: this.form.controls["name"].pristine name: this.form.controls['name'].pristine
? undefined ? undefined
: this.form.controls["name"].value, : this.form.controls['name'].value,
description: this.form.controls["description"].pristine description: this.form.controls['description'].pristine
? undefined ? undefined
: this.form.controls["description"].value, : this.form.controls['description'].value,
permissions: this.allPermissions.filter( permissions: this.allPermissions.filter(
(p) => this.form.controls[p.name].value, p => this.form.controls[p.name].value
), ),
}; };
} }
@ -125,7 +134,7 @@ export class RoleFormPageComponent extends FormPageBase<
create(role: RoleCreateInput): void { create(role: RoleCreateInput): void {
this.dataService.create(role).subscribe(() => { this.dataService.create(role).subscribe(() => {
this.spinner.hide(); this.spinner.hide();
this.toast.success("action.created"); this.toast.success('action.created');
this.close(); this.close();
}); });
} }
@ -133,20 +142,20 @@ export class RoleFormPageComponent extends FormPageBase<
update(role: RoleUpdateInput): void { update(role: RoleUpdateInput): void {
this.dataService.update(role).subscribe(() => { this.dataService.update(role).subscribe(() => {
this.spinner.hide(); this.spinner.hide();
this.toast.success("action.created"); this.toast.success('action.created');
this.close(); this.close();
}); });
} }
toggleGroup(event: InputSwitchChangeEvent, group: string) { toggleGroup(event: InputSwitchChangeEvent, group: string) {
this.permissionGroups[group].forEach((p) => { this.permissionGroups[group].forEach(p => {
this.form.controls[p.name].setValue(event.checked); this.form.controls[p.name].setValue(event.checked);
}); });
} }
isGroupChecked(group: string) { isGroupChecked(group: string) {
return this.permissionGroups[group].every( return this.permissionGroups[group].every(
(p) => this.form.controls[p.name].value, p => this.form.controls[p.name].value
); );
} }

View File

@ -1,25 +1,25 @@
import { Injectable, Provider } from "@angular/core"; import { Injectable, Provider } from '@angular/core';
import { Observable } from "rxjs"; import { Observable } from 'rxjs';
import { import {
Create, Create,
Delete, Delete,
PageDataService, PageDataService,
Restore, Restore,
Update, Update,
} from "src/app/core/base/page.data.service"; } from 'src/app/core/base/page.data.service';
import { import {
Permission, Permission,
Role, Role,
RoleCreateInput, RoleCreateInput,
RoleUpdateInput, RoleUpdateInput,
} from "src/app/model/entities/role"; } from 'src/app/model/entities/role';
import { Filter } from "src/app/model/graphql/filter/filter.model"; import { Filter } from 'src/app/model/graphql/filter/filter.model';
import { Sort } from "src/app/model/graphql/filter/sort.model"; import { Sort } from 'src/app/model/graphql/filter/sort.model';
import { Apollo, gql } from "apollo-angular"; import { Apollo, gql } from 'apollo-angular';
import { QueryResult } from "src/app/model/entities/query-result"; import { QueryResult } from 'src/app/model/entities/query-result';
import { DB_MODEL_FRAGMENT } from "src/app/model/graphql/db-model.query"; import { DB_MODEL_FRAGMENT } from 'src/app/model/graphql/db-model.query';
import { catchError, map } from "rxjs/operators"; import { catchError, map } from 'rxjs/operators';
import { SpinnerService } from "src/app/service/spinner.service"; import { SpinnerService } from 'src/app/service/spinner.service';
@Injectable() @Injectable()
export class RolesDataService export class RolesDataService
@ -32,7 +32,7 @@ export class RolesDataService
{ {
constructor( constructor(
private spinner: SpinnerService, private spinner: SpinnerService,
private apollo: Apollo, private apollo: Apollo
) { ) {
super(); super();
} }
@ -41,7 +41,7 @@ export class RolesDataService
filter?: Filter[] | undefined, filter?: Filter[] | undefined,
sort?: Sort[] | undefined, sort?: Sort[] | undefined,
skip?: number | undefined, skip?: number | undefined,
take?: number | undefined, take?: number | undefined
): Observable<QueryResult<Role>> { ): Observable<QueryResult<Role>> {
return this.apollo return this.apollo
.query<{ roles: QueryResult<Role> }>({ .query<{ roles: QueryResult<Role> }>({
@ -75,12 +75,12 @@ export class RolesDataService
}, },
}) })
.pipe( .pipe(
catchError((err) => { catchError(err => {
this.spinner.hide(); this.spinner.hide();
throw err; throw err;
}), })
) )
.pipe(map((result) => result.data.roles)); .pipe(map(result => result.data.roles));
} }
loadById(id: number): Observable<Role> { loadById(id: number): Observable<Role> {
@ -112,12 +112,12 @@ export class RolesDataService
}, },
}) })
.pipe( .pipe(
catchError((err) => { catchError(err => {
this.spinner.hide(); this.spinner.hide();
throw err; throw err;
}), })
) )
.pipe(map((result) => result.data.roles.nodes[0])); .pipe(map(result => result.data.roles.nodes[0]));
} }
create(object: RoleCreateInput): Observable<Role | undefined> { create(object: RoleCreateInput): Observable<Role | undefined> {
@ -145,17 +145,17 @@ export class RolesDataService
input: { input: {
name: object.name, name: object.name,
description: object.description, description: object.description,
permissions: object.permissions?.map((x) => x.id), permissions: object.permissions?.map(x => x.id),
}, },
}, },
}) })
.pipe( .pipe(
catchError((err) => { catchError(err => {
this.spinner.hide(); this.spinner.hide();
throw err; throw err;
}), })
) )
.pipe(map((result) => result.data?.role.create)); .pipe(map(result => result.data?.role.create));
} }
update(object: RoleUpdateInput): Observable<Role | undefined> { update(object: RoleUpdateInput): Observable<Role | undefined> {
@ -184,17 +184,17 @@ export class RolesDataService
id: object.id, id: object.id,
name: object.name, name: object.name,
description: object.description, description: object.description,
permissions: object.permissions?.map((x) => x.id), permissions: object.permissions?.map(x => x.id),
}, },
}, },
}) })
.pipe( .pipe(
catchError((err) => { catchError(err => {
this.spinner.hide(); this.spinner.hide();
throw err; throw err;
}), })
) )
.pipe(map((result) => result.data?.role.update)); .pipe(map(result => result.data?.role.update));
} }
delete(object: Role): Observable<boolean> { delete(object: Role): Observable<boolean> {
@ -212,12 +212,12 @@ export class RolesDataService
}, },
}) })
.pipe( .pipe(
catchError((err) => { catchError(err => {
this.spinner.hide(); this.spinner.hide();
throw err; throw err;
}), })
) )
.pipe(map((result) => result.data?.role.delete ?? false)); .pipe(map(result => result.data?.role.delete ?? false));
} }
restore(object: Role): Observable<boolean> { restore(object: Role): Observable<boolean> {
@ -235,12 +235,12 @@ export class RolesDataService
}, },
}) })
.pipe( .pipe(
catchError((err) => { catchError(err => {
this.spinner.hide(); this.spinner.hide();
throw err; throw err;
}), })
) )
.pipe(map((result) => result.data?.role.restore ?? false)); .pipe(map(result => result.data?.role.restore ?? false));
} }
getAllPermissions(): Observable<Permission[]> { getAllPermissions(): Observable<Permission[]> {
@ -252,18 +252,19 @@ export class RolesDataService
nodes { nodes {
id id
name name
description
} }
} }
} }
`, `,
}) })
.pipe( .pipe(
catchError((err) => { catchError(err => {
this.spinner.hide(); this.spinner.hide();
throw err; throw err;
}), })
) )
.pipe(map((result) => result.data.permissions.nodes)); .pipe(map(result => result.data.permissions.nodes));
} }
static provide(): Provider[] { static provide(): Provider[] {

View File

@ -1,20 +1,21 @@
import { Component } from "@angular/core"; import { Component } from '@angular/core';
import { FormControl, FormGroup } from "@angular/forms"; import { FormControl, FormGroup } from '@angular/forms';
import { ToastService } from "src/app/service/toast.service"; import { ToastService } from 'src/app/service/toast.service';
import { FormPageBase } from "src/app/core/base/form-page-base"; import { FormPageBase } from 'src/app/core/base/form-page-base';
import { import {
NotExistingUser, NotExistingUser,
User, User,
UserCreateInput, UserCreateInput,
UserUpdateInput, UserUpdateInput,
} from "src/app/model/auth/user"; } from 'src/app/model/auth/user';
import { Role } from "src/app/model/entities/role"; import { Role } from 'src/app/model/entities/role';
import { UsersDataService } from "src/app/modules/admin/administration/users/users.data.service"; import { UsersDataService } from 'src/app/modules/admin/administration/users/users.data.service';
import { CommonDataService } from 'src/app/modules/shared/service/common-data.service';
@Component({ @Component({
selector: "app-user-form-page", selector: 'app-user-form-page',
templateUrl: "./user-form-page.component.html", templateUrl: './user-form-page.component.html',
styleUrl: "./user-form-page.component.scss", styleUrl: './user-form-page.component.scss',
}) })
export class UserFormPageComponent extends FormPageBase< export class UserFormPageComponent extends FormPageBase<
User, User,
@ -25,14 +26,17 @@ export class UserFormPageComponent extends FormPageBase<
notExistingUsers: NotExistingUser[] = []; notExistingUsers: NotExistingUser[] = [];
roles: Role[] = []; roles: Role[] = [];
constructor(private toast: ToastService) { constructor(
private toast: ToastService,
private cds: CommonDataService
) {
super(); super();
this.dataService.getAllRoles().subscribe((roles) => { this.cds.getAllRoles().subscribe(roles => {
this.roles = roles; this.roles = roles;
}); });
if (!this.nodeId) { if (!this.nodeId) {
this.dataService.getNotExistingUsersFromKeycloak().subscribe((users) => { this.dataService.getNotExistingUsersFromKeycloak().subscribe(users => {
this.notExistingUsers = users; this.notExistingUsers = users;
this.node = this.new(); this.node = this.new();
this.setForm(this.node); this.setForm(this.node);
@ -41,7 +45,7 @@ export class UserFormPageComponent extends FormPageBase<
return; return;
} }
this.dataService.loadById(this.nodeId).subscribe((user) => { this.dataService.loadById(this.nodeId).subscribe(user => {
this.node = user; this.node = user;
this.setForm(this.node); this.setForm(this.node);
}); });
@ -59,47 +63,47 @@ export class UserFormPageComponent extends FormPageBase<
email: new FormControl<string | undefined>(undefined), email: new FormControl<string | undefined>(undefined),
roles: new FormControl<Role[]>([]), roles: new FormControl<Role[]>([]),
}); });
this.form.controls["id"].disable(); this.form.controls['id'].disable();
this.form.controls["username"].disable(); this.form.controls['username'].disable();
this.form.controls["email"].disable(); this.form.controls['email'].disable();
} }
setForm(node?: User) { setForm(node?: User) {
this.form.controls["id"].setValue(node?.id); this.form.controls['id'].setValue(node?.id);
this.form.controls["username"].setValue(node?.username); this.form.controls['username'].setValue(node?.username);
this.form.controls["email"].setValue(node?.email); this.form.controls['email'].setValue(node?.email);
this.form.controls["roles"].setValue(node?.roles ?? []); this.form.controls['roles'].setValue(node?.roles ?? []);
if (this.notExistingUsers.length > 0) { if (this.notExistingUsers.length > 0) {
this.form.controls["id"].enable(); this.form.controls['id'].enable();
this.form.controls["keycloakId"].reset(undefined, { required: true }); this.form.controls['keycloakId'].reset(undefined, { required: true });
} }
} }
getCreateInput(): UserCreateInput { getCreateInput(): UserCreateInput {
return { return {
keycloakId: this.form.controls["keycloakId"].pristine keycloakId: this.form.controls['keycloakId'].pristine
? undefined ? undefined
: this.form.controls["keycloakId"].value, : this.form.controls['keycloakId'].value,
roles: this.form.controls["roles"].pristine roles: this.form.controls['roles'].pristine
? undefined ? undefined
: this.form.controls["roles"].value, : this.form.controls['roles'].value,
}; };
} }
getUpdateInput(): UserUpdateInput { getUpdateInput(): UserUpdateInput {
return { return {
id: this.form.controls["id"].value, id: this.form.controls['id'].value,
roles: this.form.controls["roles"].pristine roles: this.form.controls['roles'].pristine
? undefined ? undefined
: this.form.controls["roles"].value, : this.form.controls['roles'].value,
}; };
} }
create(user: UserCreateInput): void { create(user: UserCreateInput): void {
this.dataService.create(user).subscribe(() => { this.dataService.create(user).subscribe(() => {
this.spinner.hide(); this.spinner.hide();
this.toast.success("action.created"); this.toast.success('action.created');
this.close(); this.close();
}); });
} }
@ -107,7 +111,7 @@ export class UserFormPageComponent extends FormPageBase<
update(user: UserUpdateInput): void { update(user: UserUpdateInput): void {
this.dataService.update(user).subscribe(() => { this.dataService.update(user).subscribe(() => {
this.spinner.hide(); this.spinner.hide();
this.toast.success("action.created"); this.toast.success('action.created');
this.close(); this.close();
}); });
} }

View File

@ -1,26 +1,26 @@
import { Injectable, Provider } from "@angular/core"; import { Injectable, Provider } from '@angular/core';
import { Observable } from "rxjs"; import { Observable } from 'rxjs';
import { import {
Create, Create,
Delete, Delete,
PageDataService, PageDataService,
Restore, Restore,
Update, Update,
} from "src/app/core/base/page.data.service"; } from 'src/app/core/base/page.data.service';
import { Filter } from "src/app/model/graphql/filter/filter.model"; import { Filter } from 'src/app/model/graphql/filter/filter.model';
import { Sort } from "src/app/model/graphql/filter/sort.model"; import { Sort } from 'src/app/model/graphql/filter/sort.model';
import { Apollo, gql } from "apollo-angular"; import { Apollo, gql } from 'apollo-angular';
import { QueryResult } from "src/app/model/entities/query-result"; import { QueryResult } from 'src/app/model/entities/query-result';
import { DB_MODEL_FRAGMENT } from "src/app/model/graphql/db-model.query"; import { DB_MODEL_FRAGMENT } from 'src/app/model/graphql/db-model.query';
import { catchError, map } from "rxjs/operators"; import { catchError, map } from 'rxjs/operators';
import { SpinnerService } from "src/app/service/spinner.service"; import { SpinnerService } from 'src/app/service/spinner.service';
import { import {
NotExistingUser, NotExistingUser,
User, User,
UserCreateInput, UserCreateInput,
UserUpdateInput, UserUpdateInput,
} from "src/app/model/auth/user"; } from 'src/app/model/auth/user';
import { Role } from "src/app/model/entities/role"; import { Role } from 'src/app/model/entities/role';
@Injectable() @Injectable()
export class UsersDataService export class UsersDataService
@ -33,7 +33,7 @@ export class UsersDataService
{ {
constructor( constructor(
private spinner: SpinnerService, private spinner: SpinnerService,
private apollo: Apollo, private apollo: Apollo
) { ) {
super(); super();
} }
@ -42,7 +42,7 @@ export class UsersDataService
filter?: Filter[] | undefined, filter?: Filter[] | undefined,
sort?: Sort[] | undefined, sort?: Sort[] | undefined,
skip?: number | undefined, skip?: number | undefined,
take?: number | undefined, take?: number | undefined
): Observable<QueryResult<User>> { ): Observable<QueryResult<User>> {
return this.apollo return this.apollo
.query<{ users: QueryResult<User> }>({ .query<{ users: QueryResult<User> }>({
@ -77,12 +77,12 @@ export class UsersDataService
}, },
}) })
.pipe( .pipe(
catchError((err) => { catchError(err => {
this.spinner.hide(); this.spinner.hide();
throw err; throw err;
}), })
) )
.pipe(map((result) => result.data.users)); .pipe(map(result => result.data.users));
} }
loadById(id: number): Observable<User> { loadById(id: number): Observable<User> {
@ -115,12 +115,12 @@ export class UsersDataService
}, },
}) })
.pipe( .pipe(
catchError((err) => { catchError(err => {
this.spinner.hide(); this.spinner.hide();
throw err; throw err;
}), })
) )
.pipe(map((result) => result.data.users.nodes[0])); .pipe(map(result => result.data.users.nodes[0]));
} }
create(object: UserCreateInput): Observable<User | undefined> { create(object: UserCreateInput): Observable<User | undefined> {
@ -145,17 +145,17 @@ export class UsersDataService
variables: { variables: {
input: { input: {
keycloakId: object.keycloakId, keycloakId: object.keycloakId,
roles: object.roles?.map((x) => x.id), roles: object.roles?.map(x => x.id),
}, },
}, },
}) })
.pipe( .pipe(
catchError((err) => { catchError(err => {
this.spinner.hide(); this.spinner.hide();
throw err; throw err;
}), })
) )
.pipe(map((result) => result.data?.user.create)); .pipe(map(result => result.data?.user.create));
} }
update(object: UserUpdateInput): Observable<User | undefined> { update(object: UserUpdateInput): Observable<User | undefined> {
@ -180,17 +180,17 @@ export class UsersDataService
variables: { variables: {
input: { input: {
id: object.id, id: object.id,
roles: object.roles?.map((x) => x.id), roles: object.roles?.map(x => x.id),
}, },
}, },
}) })
.pipe( .pipe(
catchError((err) => { catchError(err => {
this.spinner.hide(); this.spinner.hide();
throw err; throw err;
}), })
) )
.pipe(map((result) => result.data?.user.update)); .pipe(map(result => result.data?.user.update));
} }
delete(object: User): Observable<boolean> { delete(object: User): Observable<boolean> {
@ -208,12 +208,12 @@ export class UsersDataService
}, },
}) })
.pipe( .pipe(
catchError((err) => { catchError(err => {
this.spinner.hide(); this.spinner.hide();
throw err; throw err;
}), })
) )
.pipe(map((result) => result.data?.user.delete ?? false)); .pipe(map(result => result.data?.user.delete ?? false));
} }
restore(object: User): Observable<boolean> { restore(object: User): Observable<boolean> {
@ -231,35 +231,12 @@ export class UsersDataService
}, },
}) })
.pipe( .pipe(
catchError((err) => { catchError(err => {
this.spinner.hide(); this.spinner.hide();
throw err; throw err;
}),
)
.pipe(map((result) => result.data?.user.restore ?? false));
}
getAllRoles(): Observable<Role[]> {
return this.apollo
.query<{ roles: QueryResult<Role> }>({
query: gql`
query getRoles {
roles {
nodes {
id
name
}
}
}
`,
}) })
.pipe(
catchError((err) => {
this.spinner.hide();
throw err;
}),
) )
.pipe(map((result) => result.data.roles.nodes)); .pipe(map(result => result.data?.user.restore ?? false));
} }
getNotExistingUsersFromKeycloak(): Observable<NotExistingUser[]> { getNotExistingUsersFromKeycloak(): Observable<NotExistingUser[]> {
@ -277,12 +254,12 @@ export class UsersDataService
`, `,
}) })
.pipe( .pipe(
catchError((err) => { catchError(err => {
this.spinner.hide(); this.spinner.hide();
throw err; throw err;
}), })
) )
.pipe(map((result) => result.data.notExistingUsersFromKeycloak.nodes)); .pipe(map(result => result.data.notExistingUsersFromKeycloak.nodes));
} }
static provide(): Provider[] { static provide(): Provider[] {

View File

@ -0,0 +1,35 @@
import { Injectable, Provider } from '@angular/core';
import {
DB_MODEL_COLUMNS,
ID_COLUMN,
PageColumns,
} from 'src/app/core/base/page.columns';
import { TableColumn } from 'src/app/modules/shared/components/table/table.model';
import { Domain } from 'src/app/model/entities/domain';
@Injectable()
export class DomainsColumns extends PageColumns<Domain> {
get(): TableColumn<Domain>[] {
return [
ID_COLUMN,
{
name: 'name',
label: 'common.name',
type: 'text',
filterable: true,
value: (row: Domain) => row.name,
},
...DB_MODEL_COLUMNS,
];
}
static provide(): Provider[] {
return [
{
provide: PageColumns,
useClass: DomainsColumns,
},
DomainsColumns,
];
}
}

View File

@ -0,0 +1,232 @@
import { Injectable, Provider } from '@angular/core';
import { Observable } from 'rxjs';
import {
Create,
Delete,
PageDataService,
Restore,
Update,
} from 'src/app/core/base/page.data.service';
import { Filter } from 'src/app/model/graphql/filter/filter.model';
import { Sort } from 'src/app/model/graphql/filter/sort.model';
import { Apollo, gql } from 'apollo-angular';
import { QueryResult } from 'src/app/model/entities/query-result';
import { DB_MODEL_FRAGMENT } from 'src/app/model/graphql/db-model.query';
import { catchError, map } from 'rxjs/operators';
import { SpinnerService } from 'src/app/service/spinner.service';
import {
Domain,
DomainCreateInput,
DomainUpdateInput,
} from 'src/app/model/entities/domain';
@Injectable()
export class DomainsDataService
extends PageDataService<Domain>
implements
Create<Domain, DomainCreateInput>,
Update<Domain, DomainUpdateInput>,
Delete<Domain>,
Restore<Domain>
{
constructor(
private spinner: SpinnerService,
private apollo: Apollo
) {
super();
}
load(
filter?: Filter[] | undefined,
sort?: Sort[] | undefined,
skip?: number | undefined,
take?: number | undefined
): Observable<QueryResult<Domain>> {
return this.apollo
.query<{ domains: QueryResult<Domain> }>({
query: gql`
query getDomains(
$filter: [DomainFilter]
$sort: [DomainSort]
$skip: Int
$take: Int
) {
domains(filter: $filter, sort: $sort, skip: $skip, take: $take) {
count
totalCount
nodes {
id
name
...DB_MODEL
}
}
}
${DB_MODEL_FRAGMENT}
`,
variables: {
filter: filter,
sort: sort,
skip: skip,
take: take,
},
})
.pipe(
catchError(err => {
this.spinner.hide();
throw err;
})
)
.pipe(map(result => result.data.domains));
}
loadById(id: number): Observable<Domain> {
return this.apollo
.query<{ domains: QueryResult<Domain> }>({
query: gql`
query getDomain($id: Int) {
domain(filter: { id: { equal: $id } }) {
id
name
...DB_MODEL
}
}
${DB_MODEL_FRAGMENT}
`,
variables: {
id: id,
},
})
.pipe(
catchError(err => {
this.spinner.hide();
throw err;
})
)
.pipe(map(result => result.data.domains.nodes[0]));
}
create(object: DomainCreateInput): Observable<Domain | undefined> {
return this.apollo
.mutate<{ domain: { create: Domain } }>({
mutation: gql`
mutation createDomain($input: DomainCreateInput!) {
domain {
create(input: $input) {
id
name
...DB_MODEL
}
}
}
${DB_MODEL_FRAGMENT}
`,
variables: {
input: {
name: object.name,
},
},
})
.pipe(
catchError(err => {
this.spinner.hide();
throw err;
})
)
.pipe(map(result => result.data?.domain.create));
}
update(object: DomainUpdateInput): Observable<Domain | undefined> {
return this.apollo
.mutate<{ domain: { update: Domain } }>({
mutation: gql`
mutation updateDomain($input: DomainUpdateInput!) {
domain {
update(input: $input) {
id
name
...DB_MODEL
}
}
}
${DB_MODEL_FRAGMENT}
`,
variables: {
input: {
id: object.id,
name: object.name,
},
},
})
.pipe(
catchError(err => {
this.spinner.hide();
throw err;
})
)
.pipe(map(result => result.data?.domain.update));
}
delete(object: Domain): Observable<boolean> {
return this.apollo
.mutate<{ domain: { delete: boolean } }>({
mutation: gql`
mutation deleteDomain($id: ID!) {
domain {
delete(id: $id)
}
}
`,
variables: {
id: object.id,
},
})
.pipe(
catchError(err => {
this.spinner.hide();
throw err;
})
)
.pipe(map(result => result.data?.domain.delete ?? false));
}
restore(object: Domain): Observable<boolean> {
return this.apollo
.mutate<{ domain: { restore: boolean } }>({
mutation: gql`
mutation restoreDomain($id: ID!) {
domain {
restore(id: $id)
}
}
`,
variables: {
id: object.id,
},
})
.pipe(
catchError(err => {
this.spinner.hide();
throw err;
})
)
.pipe(map(result => result.data?.domain.restore ?? false));
}
static provide(): Provider[] {
return [
{
provide: PageDataService,
useClass: DomainsDataService,
},
DomainsDataService,
];
}
}

View File

@ -0,0 +1,43 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SharedModule } from 'src/app/modules/shared/shared.module';
import { RouterModule, Routes } from '@angular/router';
import { PermissionGuard } from 'src/app/core/guard/permission.guard';
import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum';
import { DomainsPage } from 'src/app/modules/admin/domains/domains.page';
import { DomainFormPageComponent } from 'src/app/modules/admin/domains/form-page/domain-form-page.component';
import { DomainsDataService } from 'src/app/modules/admin/domains/domains.data.service';
import { DomainsColumns } from 'src/app/modules/admin/domains/domains.columns';
const routes: Routes = [
{
path: '',
title: 'Domains',
component: DomainsPage,
children: [
{
path: 'create',
component: DomainFormPageComponent,
canActivate: [PermissionGuard],
data: {
permissions: [PermissionsEnum.apiKeysCreate],
},
},
{
path: 'edit/:id',
component: DomainFormPageComponent,
canActivate: [PermissionGuard],
data: {
permissions: [PermissionsEnum.apiKeysUpdate],
},
},
],
},
];
@NgModule({
declarations: [DomainsPage, DomainFormPageComponent],
imports: [CommonModule, SharedModule, RouterModule.forChild(routes)],
providers: [DomainsDataService.provide(), DomainsColumns.provide()],
})
export class DomainsModule {}

View File

@ -0,0 +1,19 @@
<app-table
[rows]="result.nodes"
[columns]="columns"
[rowsPerPageOptions]="rowsPerPageOptions"
[totalCount]="result.totalCount"
[requireAnyPermissions]="requiredPermissions"
countHeaderTranslation="domain.count_header"
[loading]="loading"
[(filter)]="filter"
[(sort)]="sort"
[(skip)]="skip"
[(take)]="take"
(load)="load()"
[create]="true"
[update]="true"
(delete)="delete($event)"
(restore)="restore($event)"></app-table>
<router-outlet></router-outlet>

View File

@ -0,0 +1,51 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { ApiKeysPage } from "src/app/modules/admin/administration/api-keys/api-keys.page";
import { SharedModule } from "src/app/modules/shared/shared.module";
import { TranslateModule } from "@ngx-translate/core";
import { AuthService } from "src/app/service/auth.service";
import { KeycloakService } from "keycloak-angular";
import { ErrorHandlingService } from "src/app/service/error-handling.service";
import { ToastService } from "src/app/service/toast.service";
import { ConfirmationService, MessageService } from "primeng/api";
import { ActivatedRoute } from "@angular/router";
import { of } from "rxjs";
import { PageDataService } from "src/app/core/base/page.data.service";
import { ApiKeysDataService } from "src/app/modules/admin/administration/api-keys/api-keys.data.service";
describe("ApiKeysComponent", () => {
let component: ApiKeysPage;
let fixture: ComponentFixture<ApiKeysPage>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ApiKeysPage],
imports: [SharedModule, TranslateModule.forRoot()],
providers: [
AuthService,
KeycloakService,
ErrorHandlingService,
ToastService,
MessageService,
ConfirmationService,
{
provide: ActivatedRoute,
useValue: {
snapshot: { params: of({}) },
},
},
{
provide: PageDataService,
useClass: ApiKeysDataService,
},
],
}).compileComponents();
fixture = TestBed.createComponent(ApiKeysPage);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should create", () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,72 @@
import { Component } from '@angular/core';
import { PageBase } from 'src/app/core/base/page-base';
import { ToastService } from 'src/app/service/toast.service';
import { ConfirmationDialogService } from 'src/app/service/confirmation-dialog.service';
import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum';
import { DomainsDataService } from 'src/app/modules/admin/domains/domains.data.service';
import { DomainsColumns } from 'src/app/modules/admin/domains/domains.columns';
import { Domain } from 'src/app/model/entities/domain';
@Component({
selector: 'app-domains',
templateUrl: './domains.page.html',
styleUrl: './domains.page.scss',
})
export class DomainsPage extends PageBase<
Domain,
DomainsDataService,
DomainsColumns
> {
constructor(
private toast: ToastService,
private confirmation: ConfirmationDialogService
) {
super(true, {
read: [PermissionsEnum.domains],
create: [PermissionsEnum.domainsCreate],
update: [PermissionsEnum.domainsUpdate],
delete: [PermissionsEnum.domainsDelete],
restore: [PermissionsEnum.domainsDelete],
});
}
load(): void {
this.loading = true;
this.dataService
.load(this.filter, this.sort, this.skip, this.take)
.subscribe(result => {
this.result = result;
this.loading = false;
});
}
delete(domain: Domain): void {
this.confirmation.confirmDialog({
header: 'dialog.delete.header',
message: 'dialog.delete.message',
accept: () => {
this.loading = true;
this.dataService.delete(domain).subscribe(() => {
this.toast.success('action.deleted');
this.load();
});
},
messageParams: { entity: domain.name },
});
}
restore(domain: Domain): void {
this.confirmation.confirmDialog({
header: 'dialog.restore.header',
message: 'dialog.restore.message',
accept: () => {
this.loading = true;
this.dataService.restore(domain).subscribe(() => {
this.toast.success('action.restored');
this.load();
});
},
messageParams: { entity: domain.name },
});
}
}

View File

@ -0,0 +1,31 @@
<app-form-page
*ngIf="node"
[formGroup]="form"
[isUpdate]="isUpdate"
(onSave)="save()"
(onClose)="close()">
<ng-template formPageHeader let-isUpdate>
<h2>
{{ 'common.group' | translate }}
{{
(isUpdate ? 'sidebar.header.update' : 'sidebar.header.create')
| translate
}}
</h2>
</ng-template>
<ng-template formPageContent>
<div class="form-page-input">
<p class="label">{{ 'common.id' | translate }}</p>
<input pInputText class="value" type="number" formControlName="id"/>
</div>
<div class="form-page-input">
<p class="label">{{ 'common.name' | translate }}</p>
<input
pInputText
class="value"
type="text"
formControlName="name"/>
</div>
</ng-template>
</app-form-page>

View File

@ -0,0 +1,50 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { RoleFormPageComponent } from "src/app/modules/admin/administration/roles/form-page/role-form-page.component";
import { SharedModule } from "src/app/modules/shared/shared.module";
import { TranslateModule } from "@ngx-translate/core";
import { AuthService } from "src/app/service/auth.service";
import { ErrorHandlingService } from "src/app/service/error-handling.service";
import { ToastService } from "src/app/service/toast.service";
import { ConfirmationService, MessageService } from "primeng/api";
import { ActivatedRoute } from "@angular/router";
import { of } from "rxjs";
import { ApiKeysDataService } from "src/app/modules/admin/administration/api-keys/api-keys.data.service";
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
describe("ApiKeyFormpageComponent", () => {
let component: RoleFormPageComponent;
let fixture: ComponentFixture<RoleFormPageComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [RoleFormPageComponent],
imports: [
BrowserAnimationsModule,
SharedModule,
TranslateModule.forRoot(),
],
providers: [
AuthService,
ErrorHandlingService,
ToastService,
MessageService,
ConfirmationService,
{
provide: ActivatedRoute,
useValue: {
snapshot: { params: of({}) },
},
},
ApiKeysDataService,
],
}).compileComponents();
fixture = TestBed.createComponent(RoleFormPageComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should create", () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,93 @@
import { Component } 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';
import {
Domain,
DomainCreateInput,
DomainUpdateInput,
} from 'src/app/model/entities/domain';
import { DomainsDataService } from 'src/app/modules/admin/domains/domains.data.service';
@Component({
selector: 'app-domain-form-page',
templateUrl: './domain-form-page.component.html',
styleUrl: './domain-form-page.component.scss',
})
export class DomainFormPageComponent extends FormPageBase<
Domain,
DomainCreateInput,
DomainUpdateInput,
DomainsDataService
> {
constructor(private toast: ToastService) {
super();
if (!this.nodeId) {
this.node = this.new();
this.setForm(this.node);
return;
}
this.dataService
.load([{ id: { equal: this.nodeId } }])
.subscribe(apiKey => {
this.node = apiKey.nodes[0];
this.setForm(this.node);
});
}
new(): Domain {
return {} as Domain;
}
buildForm() {
this.form = new FormGroup({
id: new FormControl<number | undefined>(undefined),
name: new FormControl<string | undefined>(undefined, Validators.required),
});
this.form.controls['id'].disable();
}
setForm(node?: Domain) {
this.form.controls['id'].setValue(node?.id);
this.form.controls['name'].setValue(node?.name);
}
getCreateInput(): DomainCreateInput {
return {
name: this.form.controls['name'].pristine
? undefined
: (this.form.controls['name'].value ?? undefined),
};
}
getUpdateInput(): DomainUpdateInput {
if (!this.node?.id) {
throw new Error('Node id is missing');
}
return {
id: this.form.controls['id'].value,
name: this.form.controls['name'].pristine
? undefined
: (this.form.controls['name'].value ?? undefined),
};
}
create(apiKey: DomainCreateInput): void {
this.dataService.create(apiKey).subscribe(() => {
this.spinner.hide();
this.toast.success('action.created');
this.close();
});
}
update(apiKey: DomainUpdateInput): void {
this.dataService.update(apiKey).subscribe(() => {
this.spinner.hide();
this.toast.success('action.created');
this.close();
});
}
}

View File

@ -27,5 +27,13 @@
type="text" type="text"
formControlName="name"/> formControlName="name"/>
</div> </div>
<div class="divider"></div>
<p-multiSelect
[options]="roles"
formControlName="roles"
optionLabel="name"
placeholder="{{ 'user.assign_roles' | translate }}"
display="chip"
[showClear]="true" />
</ng-template> </ng-template>
</app-form-page> </app-form-page>

View File

@ -1,18 +1,20 @@
import { Component } from "@angular/core"; import { Component } from '@angular/core';
import { FormControl, FormGroup, Validators } from "@angular/forms"; import { FormControl, FormGroup, Validators } from '@angular/forms';
import { ToastService } from "src/app/service/toast.service"; import { ToastService } from 'src/app/service/toast.service';
import { FormPageBase } from "src/app/core/base/form-page-base"; import { FormPageBase } from 'src/app/core/base/form-page-base';
import { import {
Group, Group,
GroupCreateInput, GroupCreateInput,
GroupUpdateInput, GroupUpdateInput,
} from "src/app/model/entities/group"; } from 'src/app/model/entities/group';
import { GroupsDataService } from "src/app/modules/admin/groups/groups.data.service"; import { GroupsDataService } from 'src/app/modules/admin/groups/groups.data.service';
import { Role } from 'src/app/model/entities/role';
import { CommonDataService } from 'src/app/modules/shared/service/common-data.service';
@Component({ @Component({
selector: "app-group-form-page", selector: 'app-group-form-page',
templateUrl: "./group-form-page.component.html", templateUrl: './group-form-page.component.html',
styleUrl: "./group-form-page.component.scss", styleUrl: './group-form-page.component.scss',
}) })
export class GroupFormPageComponent extends FormPageBase< export class GroupFormPageComponent extends FormPageBase<
Group, Group,
@ -20,8 +22,17 @@ export class GroupFormPageComponent extends FormPageBase<
GroupUpdateInput, GroupUpdateInput,
GroupsDataService GroupsDataService
> { > {
constructor(private toast: ToastService) { roles: Role[] = [];
constructor(
private toast: ToastService,
private cds: CommonDataService
) {
super(); super();
this.cds.getAllRoles().subscribe(roles => {
this.roles = roles;
});
if (!this.nodeId) { if (!this.nodeId) {
this.node = this.new(); this.node = this.new();
this.setForm(this.node); this.setForm(this.node);
@ -31,7 +42,7 @@ export class GroupFormPageComponent extends FormPageBase<
this.dataService this.dataService
.load([{ id: { equal: this.nodeId } }]) .load([{ id: { equal: this.nodeId } }])
.subscribe((apiKey) => { .subscribe(apiKey => {
this.node = apiKey.nodes[0]; this.node = apiKey.nodes[0];
this.setForm(this.node); this.setForm(this.node);
}); });
@ -45,40 +56,48 @@ export class GroupFormPageComponent extends FormPageBase<
this.form = new FormGroup({ this.form = new FormGroup({
id: new FormControl<number | undefined>(undefined), id: new FormControl<number | undefined>(undefined),
name: new FormControl<string | undefined>(undefined, Validators.required), name: new FormControl<string | undefined>(undefined, Validators.required),
roles: new FormControl<Role[]>([]),
}); });
this.form.controls["id"].disable(); this.form.controls['id'].disable();
} }
setForm(node?: Group) { setForm(node?: Group) {
this.form.controls["id"].setValue(node?.id); this.form.controls['id'].setValue(node?.id);
this.form.controls["name"].setValue(node?.name); this.form.controls['name'].setValue(node?.name);
this.form.controls['roles'].setValue(node?.roles ?? []);
} }
getCreateInput(): GroupCreateInput { getCreateInput(): GroupCreateInput {
return { return {
name: this.form.controls["name"].pristine name: this.form.controls['name'].pristine
? undefined ? undefined
: (this.form.controls["name"].value ?? undefined), : (this.form.controls['name'].value ?? undefined),
roles: this.form.controls['roles'].pristine
? undefined
: this.form.controls['roles'].value,
}; };
} }
getUpdateInput(): GroupUpdateInput { getUpdateInput(): GroupUpdateInput {
if (!this.node?.id) { if (!this.node?.id) {
throw new Error("Node id is missing"); throw new Error('Node id is missing');
} }
return { return {
id: this.form.controls["id"].value, id: this.form.controls['id'].value,
name: this.form.controls["name"].pristine name: this.form.controls['name'].pristine
? undefined ? undefined
: (this.form.controls["name"].value ?? undefined), : (this.form.controls['name'].value ?? undefined),
roles: this.form.controls['roles'].pristine
? undefined
: this.form.controls['roles'].value,
}; };
} }
create(apiKey: GroupCreateInput): void { create(apiKey: GroupCreateInput): void {
this.dataService.create(apiKey).subscribe(() => { this.dataService.create(apiKey).subscribe(() => {
this.spinner.hide(); this.spinner.hide();
this.toast.success("action.created"); this.toast.success('action.created');
this.close(); this.close();
}); });
} }
@ -86,7 +105,7 @@ export class GroupFormPageComponent extends FormPageBase<
update(apiKey: GroupUpdateInput): void { update(apiKey: GroupUpdateInput): void {
this.dataService.update(apiKey).subscribe(() => { this.dataService.update(apiKey).subscribe(() => {
this.spinner.hide(); this.spinner.hide();
this.toast.success("action.created"); this.toast.success('action.created');
this.close(); this.close();
}); });
} }

View File

@ -1,24 +1,24 @@
import { Injectable, Provider } from "@angular/core"; import { Injectable, Provider } from '@angular/core';
import { Observable } from "rxjs"; import { Observable } from 'rxjs';
import { import {
Create, Create,
Delete, Delete,
PageDataService, PageDataService,
Restore, Restore,
Update, Update,
} from "src/app/core/base/page.data.service"; } from 'src/app/core/base/page.data.service';
import { Filter } from "src/app/model/graphql/filter/filter.model"; import { Filter } from 'src/app/model/graphql/filter/filter.model';
import { Sort } from "src/app/model/graphql/filter/sort.model"; import { Sort } from 'src/app/model/graphql/filter/sort.model';
import { Apollo, gql } from "apollo-angular"; import { Apollo, gql } from 'apollo-angular';
import { QueryResult } from "src/app/model/entities/query-result"; import { QueryResult } from 'src/app/model/entities/query-result';
import { DB_MODEL_FRAGMENT } from "src/app/model/graphql/db-model.query"; import { DB_MODEL_FRAGMENT } from 'src/app/model/graphql/db-model.query';
import { catchError, map } from "rxjs/operators"; import { catchError, map } from 'rxjs/operators';
import { SpinnerService } from "src/app/service/spinner.service"; import { SpinnerService } from 'src/app/service/spinner.service';
import { import {
Group, Group,
GroupCreateInput, GroupCreateInput,
GroupUpdateInput, GroupUpdateInput,
} from "src/app/model/entities/group"; } from 'src/app/model/entities/group';
@Injectable() @Injectable()
export class GroupsDataService export class GroupsDataService
@ -31,7 +31,7 @@ export class GroupsDataService
{ {
constructor( constructor(
private spinner: SpinnerService, private spinner: SpinnerService,
private apollo: Apollo, private apollo: Apollo
) { ) {
super(); super();
} }
@ -40,7 +40,7 @@ export class GroupsDataService
filter?: Filter[] | undefined, filter?: Filter[] | undefined,
sort?: Sort[] | undefined, sort?: Sort[] | undefined,
skip?: number | undefined, skip?: number | undefined,
take?: number | undefined, take?: number | undefined
): Observable<QueryResult<Group>> { ): Observable<QueryResult<Group>> {
return this.apollo return this.apollo
.query<{ groups: QueryResult<Group> }>({ .query<{ groups: QueryResult<Group> }>({
@ -57,6 +57,10 @@ export class GroupsDataService
nodes { nodes {
id id
name name
roles {
id
name
}
...DB_MODEL ...DB_MODEL
} }
@ -73,12 +77,12 @@ export class GroupsDataService
}, },
}) })
.pipe( .pipe(
catchError((err) => { catchError(err => {
this.spinner.hide(); this.spinner.hide();
throw err; throw err;
}), })
) )
.pipe(map((result) => result.data.groups)); .pipe(map(result => result.data.groups));
} }
loadById(id: number): Observable<Group> { loadById(id: number): Observable<Group> {
@ -89,6 +93,10 @@ export class GroupsDataService
group(filter: { id: { equal: $id } }) { group(filter: { id: { equal: $id } }) {
id id
name name
roles {
id
name
}
...DB_MODEL ...DB_MODEL
} }
@ -101,12 +109,12 @@ export class GroupsDataService
}, },
}) })
.pipe( .pipe(
catchError((err) => { catchError(err => {
this.spinner.hide(); this.spinner.hide();
throw err; throw err;
}), })
) )
.pipe(map((result) => result.data.groups.nodes[0])); .pipe(map(result => result.data.groups.nodes[0]));
} }
create(object: GroupCreateInput): Observable<Group | undefined> { create(object: GroupCreateInput): Observable<Group | undefined> {
@ -129,16 +137,17 @@ export class GroupsDataService
variables: { variables: {
input: { input: {
name: object.name, name: object.name,
roles: object.roles?.map(x => x.id),
}, },
}, },
}) })
.pipe( .pipe(
catchError((err) => { catchError(err => {
this.spinner.hide(); this.spinner.hide();
throw err; throw err;
}), })
) )
.pipe(map((result) => result.data?.group.create)); .pipe(map(result => result.data?.group.create));
} }
update(object: GroupUpdateInput): Observable<Group | undefined> { update(object: GroupUpdateInput): Observable<Group | undefined> {
@ -162,16 +171,17 @@ export class GroupsDataService
input: { input: {
id: object.id, id: object.id,
name: object.name, name: object.name,
roles: object.roles?.map(x => x.id),
}, },
}, },
}) })
.pipe( .pipe(
catchError((err) => { catchError(err => {
this.spinner.hide(); this.spinner.hide();
throw err; throw err;
}), })
) )
.pipe(map((result) => result.data?.group.update)); .pipe(map(result => result.data?.group.update));
} }
delete(object: Group): Observable<boolean> { delete(object: Group): Observable<boolean> {
@ -189,12 +199,12 @@ export class GroupsDataService
}, },
}) })
.pipe( .pipe(
catchError((err) => { catchError(err => {
this.spinner.hide(); this.spinner.hide();
throw err; throw err;
}), })
) )
.pipe(map((result) => result.data?.group.delete ?? false)); .pipe(map(result => result.data?.group.delete ?? false));
} }
restore(object: Group): Observable<boolean> { restore(object: Group): Observable<boolean> {
@ -212,12 +222,12 @@ export class GroupsDataService
}, },
}) })
.pipe( .pipe(
catchError((err) => { catchError(err => {
this.spinner.hide(); this.spinner.hide();
throw err; throw err;
}), })
) )
.pipe(map((result) => result.data?.group.restore ?? false)); .pipe(map(result => result.data?.group.restore ?? false));
} }
static provide(): Provider[] { static provide(): Provider[] {

View File

@ -6,7 +6,7 @@
(onClose)="close()"> (onClose)="close()">
<ng-template formPageHeader let-isUpdate> <ng-template formPageHeader let-isUpdate>
<h2> <h2>
{{ 'common.group' | translate }} {{ 'common.short_url' | translate }}
{{ {{
(isUpdate ? 'sidebar.header.update' : 'sidebar.header.create') (isUpdate ? 'sidebar.header.update' : 'sidebar.header.create')
| translate | translate
@ -65,5 +65,20 @@
></p-dropdown> ></p-dropdown>
</div> </div>
</div> </div>
<div class="form-page-input">
<p class="label">{{ 'common.domain' | translate }}</p>
<div
class="value">
<p-dropdown
[options]="domains"
formControlName="domainId"
[showClear]="true"
[filter]="true"
filterBy="name"
optionLabel="name"
optionValue="id"
></p-dropdown>
</div>
</div>
</ng-template> </ng-template>
</app-form-page> </app-form-page>

View File

@ -9,6 +9,7 @@ import {
} from 'src/app/model/entities/short-url'; } from 'src/app/model/entities/short-url';
import { ShortUrlsDataService } from 'src/app/modules/admin/short-urls/short-urls.data.service'; import { ShortUrlsDataService } from 'src/app/modules/admin/short-urls/short-urls.data.service';
import { Group } from 'src/app/model/entities/group'; import { Group } from 'src/app/model/entities/group';
import { Domain } from 'src/app/model/entities/domain';
@Component({ @Component({
selector: 'app-short-url-form-page', selector: 'app-short-url-form-page',
@ -22,12 +23,16 @@ export class ShortUrlFormPageComponent extends FormPageBase<
ShortUrlsDataService ShortUrlsDataService
> { > {
groups: Group[] = []; groups: Group[] = [];
domains: Domain[] = [];
constructor(private toast: ToastService) { constructor(private toast: ToastService) {
super(); super();
this.dataService.getAllGroups().subscribe(groups => { this.dataService.getAllGroups().subscribe(groups => {
this.groups = groups; this.groups = groups;
}); });
this.dataService.getAllDomains().subscribe(domains => {
this.domains = domains;
});
if (!this.nodeId) { if (!this.nodeId) {
this.node = this.new(); this.node = this.new();
@ -62,6 +67,7 @@ export class ShortUrlFormPageComponent extends FormPageBase<
description: new FormControl<string | undefined>(undefined), description: new FormControl<string | undefined>(undefined),
loadingScreen: new FormControl<boolean | undefined>(undefined), loadingScreen: new FormControl<boolean | undefined>(undefined),
groupId: new FormControl<number | undefined>(undefined), groupId: new FormControl<number | undefined>(undefined),
domainId: new FormControl<number | undefined>(undefined),
}); });
this.form.controls['id'].disable(); this.form.controls['id'].disable();
} }
@ -86,6 +92,7 @@ export class ShortUrlFormPageComponent extends FormPageBase<
this.form.controls['description'].setValue(node?.description); this.form.controls['description'].setValue(node?.description);
this.form.controls['loadingScreen'].setValue(node?.loadingScreen); this.form.controls['loadingScreen'].setValue(node?.loadingScreen);
this.form.controls['groupId'].setValue(node?.group?.id); this.form.controls['groupId'].setValue(node?.group?.id);
this.form.controls['domainId'].setValue(node?.domain?.id);
} }
getCreateInput(): ShortUrlCreateInput { getCreateInput(): ShortUrlCreateInput {
@ -103,6 +110,9 @@ export class ShortUrlFormPageComponent extends FormPageBase<
groupId: this.form.controls['groupId'].pristine groupId: this.form.controls['groupId'].pristine
? undefined ? undefined
: (this.form.controls['groupId'].value ?? undefined), : (this.form.controls['groupId'].value ?? undefined),
domainId: this.form.controls['domainId'].pristine
? undefined
: (this.form.controls['domainId'].value ?? undefined),
}; };
} }
@ -128,6 +138,9 @@ export class ShortUrlFormPageComponent extends FormPageBase<
groupId: this.form.controls['groupId'].pristine groupId: this.form.controls['groupId'].pristine
? undefined ? undefined
: (this.form.controls['groupId'].value ?? undefined), : (this.form.controls['groupId'].value ?? undefined),
domainId: this.form.controls['domainId'].pristine
? undefined
: (this.form.controls['domainId'].value ?? undefined),
}; };
} }

View File

@ -20,6 +20,7 @@ import {
ShortUrlUpdateInput, ShortUrlUpdateInput,
} from 'src/app/model/entities/short-url'; } from 'src/app/model/entities/short-url';
import { Group } from 'src/app/model/entities/group'; import { Group } from 'src/app/model/entities/group';
import { Domain } from 'src/app/model/entities/domain';
@Injectable() @Injectable()
export class ShortUrlsDataService export class ShortUrlsDataService
@ -66,6 +67,10 @@ export class ShortUrlsDataService
id id
name name
} }
domain {
id
name
}
...DB_MODEL ...DB_MODEL
} }
@ -106,6 +111,10 @@ export class ShortUrlsDataService
id id
name name
} }
domain {
id
name
}
...DB_MODEL ...DB_MODEL
} }
@ -150,6 +159,7 @@ export class ShortUrlsDataService
description: object.description, description: object.description,
loadingScreen: object.loadingScreen, loadingScreen: object.loadingScreen,
groupId: object.groupId, groupId: object.groupId,
domainId: object.domainId,
}, },
}, },
}) })
@ -187,6 +197,7 @@ export class ShortUrlsDataService
description: object.description, description: object.description,
loadingScreen: object.loadingScreen, loadingScreen: object.loadingScreen,
groupId: object.groupId, groupId: object.groupId,
domainId: object.domainId,
}, },
}, },
}) })
@ -268,6 +279,29 @@ export class ShortUrlsDataService
.pipe(map(result => result.data.groups.nodes)); .pipe(map(result => result.data.groups.nodes));
} }
getAllDomains() {
return this.apollo
.query<{ domains: QueryResult<Domain> }>({
query: gql`
query getGroups {
domains {
nodes {
id
name
}
}
}
`,
})
.pipe(
catchError(err => {
this.spinner.hide();
throw err;
})
)
.pipe(map(result => result.data.domains.nodes));
}
static provide(): Provider[] { static provide(): Provider[] {
return [ return [
{ {

View File

@ -22,6 +22,10 @@
<div class="pi pi-{{ url.loadingScreen ? 'check-circle' : 'times-circle' }}"></div> <div class="pi pi-{{ url.loadingScreen ? 'check-circle' : 'times-circle' }}"></div>
</span> </span>
</div> </div>
<div class="grid-container" *ngIf="url.domain">
<span class="grid-label font-bold">{{ 'common.domain' | translate }}:</span>
<span class="grid-value">{{ url.domain?.name }}</span>
</div>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<div class="flex"> <div class="flex">
@ -105,7 +109,7 @@
</div> </div>
<div class="divider"></div> <div class="divider"></div>
<div class="bg2 rounded-xl p-3"> <div class="bg2 rounded-xl p-3" *ngIf="shortUrlsWithoutGroup.length > 0">
<div class="flex flex-wrap gap-1.5"> <div class="flex flex-wrap gap-1.5">
<ng-template *ngFor="let url of shortUrlsWithoutGroup" [ngTemplateOutlet]="shortUrl" <ng-template *ngFor="let url of shortUrlsWithoutGroup" [ngTemplateOutlet]="shortUrl"
[ngTemplateOutletContext]="{ $implicit: url }"></ng-template> [ngTemplateOutletContext]="{ $implicit: url }"></ng-template>

View File

@ -0,0 +1,40 @@
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { Role } from 'src/app/model/entities/role';
import { QueryResult } from 'src/app/model/entities/query-result';
import { Apollo, gql } from 'apollo-angular';
import { catchError, map } from 'rxjs/operators';
import { SpinnerService } from 'src/app/service/spinner.service';
@Injectable({
providedIn: 'root',
})
export class CommonDataService {
constructor(
private spinner: SpinnerService,
private apollo: Apollo
) {}
getAllRoles(): Observable<Role[]> {
return this.apollo
.query<{ roles: QueryResult<Role> }>({
query: gql`
query getRoles {
roles {
nodes {
id
name
}
}
}
`,
})
.pipe(
catchError(err => {
this.spinner.hide();
throw err;
})
)
.pipe(map(result => result.data.roles.nodes));
}
}

View File

@ -1,16 +1,16 @@
import { Injectable } from "@angular/core"; import { Injectable } from '@angular/core';
import { User } from "src/app/model/auth/user"; import { User } from 'src/app/model/auth/user';
import { BehaviorSubject, firstValueFrom, Observable } from "rxjs"; import { BehaviorSubject, concatWith, firstValueFrom, Observable } from 'rxjs';
import { Apollo, gql } from "apollo-angular"; import { Apollo, gql } from 'apollo-angular';
import { PermissionsEnum } from "src/app/model/auth/permissionsEnum"; import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum';
import { KeycloakService } from "keycloak-angular"; import { KeycloakService } from 'keycloak-angular';
import { map } from "rxjs/operators"; import { map } from 'rxjs/operators';
import { Logger } from "src/app/service/logger.service"; import { Logger } from 'src/app/service/logger.service';
const log = new Logger("AuthService"); const log = new Logger('AuthService');
@Injectable({ @Injectable({
providedIn: "root", providedIn: 'root',
}) })
export class AuthService { export class AuthService {
protected anyPermissionForAdminPage = [ protected anyPermissionForAdminPage = [
@ -23,7 +23,7 @@ export class AuthService {
constructor( constructor(
private apollo: Apollo, private apollo: Apollo,
private keycloakService: KeycloakService, private keycloakService: KeycloakService
) {} ) {}
private requestUser() { private requestUser() {
@ -60,11 +60,11 @@ export class AuthService {
permission, permission,
}, },
}) })
.pipe(map((result) => result.data.userHasPermission)); .pipe(map(result => result.data.userHasPermission));
} }
private userHasAnyPermission( private userHasAnyPermission(
permissions: PermissionsEnum[], permissions: PermissionsEnum[]
): Observable<boolean> { ): Observable<boolean> {
return this.apollo return this.apollo
.query<{ userHasAnyPermission: boolean }>({ .query<{ userHasAnyPermission: boolean }>({
@ -77,7 +77,7 @@ export class AuthService {
permissions, permissions,
}, },
}) })
.pipe(map((result) => result.data.userHasAnyPermission)); .pipe(map(result => result.data.userHasAnyPermission));
} }
loadUser() { loadUser() {
@ -88,8 +88,8 @@ export class AuthService {
return; return;
} }
this.requestUser().subscribe((result) => { this.requestUser().subscribe(result => {
log.info("User loaded"); log.info('User loaded');
this.user$.next(result.data.user); this.user$.next(result.data.user);
}); });
} }
@ -111,18 +111,15 @@ export class AuthService {
if (!this.user$.value) return false; if (!this.user$.value) return false;
const userPermissions = this.user$.value.roles const userPermissions = this.user$.value.roles
.map((role) => (role.permissions ?? []).map((p) => p.name)) .map(role => (role.permissions ?? []).map(p => p.name))
.flat(); .flat();
return permissions.every((permission) => return permissions.some(permission => userPermissions.includes(permission));
userPermissions.includes(permission),
);
} }
async hasAnyPermissionLazy(permissions: PermissionsEnum[]): Promise<boolean> { async hasAnyPermissionLazy(permissions: PermissionsEnum[]): Promise<boolean> {
if (this.user$.value && this.user$.value.roles) { if (this.user$.value && this.user$.value.roles) {
return this.hasAnyPermission(permissions); return this.hasAnyPermission(permissions);
} }
return await firstValueFrom(this.userHasAnyPermission(permissions)); return await firstValueFrom(this.userHasAnyPermission(permissions));
} }
@ -130,7 +127,7 @@ export class AuthService {
if (!this.user$.value) return false; if (!this.user$.value) return false;
const permissions = this.user$.value.roles const permissions = this.user$.value.roles
.map((role) => (role.permissions ?? []).map((p) => p.name)) .map(role => (role.permissions ?? []).map(p => p.name))
.flat(); .flat();
return permissions.includes(permission); return permissions.includes(permission);
} }

View File

@ -1,22 +1,22 @@
import { TranslateService } from "@ngx-translate/core"; import { TranslateService } from '@ngx-translate/core';
import { Logger } from "src/app/service/logger.service"; import { Logger } from 'src/app/service/logger.service';
import { ErrorHandler, Injectable } from "@angular/core"; import { ErrorHandler, Injectable } from '@angular/core';
import { ToastService } from "src/app/service/toast.service"; import { ToastService } from 'src/app/service/toast.service';
import { HttpErrorResponse } from "@angular/common/http"; import { HttpErrorResponse } from '@angular/common/http';
import { ApolloError } from "@apollo/client/errors"; import { ApolloError } from '@apollo/client/errors';
import { MissingPermissionException } from "src/app/model/utils/error"; import { MissingPermissionException } from 'src/app/model/utils/error';
import { GraphQLError } from "graphql/error/GraphQLError"; import { GraphQLError } from 'graphql/error/GraphQLError';
import { ToastOptions } from "src/app/model/utils/toast-options"; import { ToastOptions } from 'src/app/model/utils/toast-options';
const logger = new Logger("ErrorHandler"); const logger = new Logger('ErrorHandler');
@Injectable({ @Injectable({
providedIn: "root", providedIn: 'root',
}) })
export class ErrorHandlingService implements ErrorHandler { export class ErrorHandlingService implements ErrorHandler {
constructor( constructor(
private t: TranslateService, private t: TranslateService,
private toast: ToastService, private toast: ToastService
) {} ) {}
handleError(error: HttpErrorResponse | ApolloError) { handleError(error: HttpErrorResponse | ApolloError) {
@ -30,7 +30,7 @@ export class ErrorHandlingService implements ErrorHandler {
return; return;
} }
console.error(error); console.error(error);
// this.handleHttpError(error); this.handleHttpError(error);
} }
private handleHttpError(e: HttpErrorResponse) { private handleHttpError(e: HttpErrorResponse) {
@ -40,14 +40,14 @@ export class ErrorHandlingService implements ErrorHandler {
error?: string; error?: string;
options?: ToastOptions; options?: ToastOptions;
} = { } = {
summary: this.t.instant("common.error"), summary: this.t.instant('common.error'),
detail: e.message, detail: e.message,
}; };
if (e.status === 401) { if (e.status === 401) {
toast = { toast = {
summary: this.t.instant("common.error"), summary: this.t.instant('common.error'),
detail: this.t.instant("error.unauthorized"), detail: this.t.instant('error.unauthorized'),
}; };
} }
@ -57,15 +57,15 @@ export class ErrorHandlingService implements ErrorHandler {
.permissions; .permissions;
toast = { toast = {
summary: this.t.instant("common.error"), summary: this.t.instant('common.error'),
detail: this.t.instant("error.missing_permissions", { detail: this.t.instant('error.missing_permissions', {
permissions: missingPermissions.join(", "), permissions: missingPermissions.join(', '),
}), }),
}; };
} else { } else {
toast = { toast = {
summary: this.t.instant("common.error"), summary: this.t.instant('common.error'),
detail: this.t.instant("error.permission_denied"), detail: this.t.instant('error.permission_denied'),
}; };
} }
} }
@ -73,7 +73,7 @@ export class ErrorHandlingService implements ErrorHandler {
if (e.status === 500 && e.error && e.error.errors) { if (e.status === 500 && e.error && e.error.errors) {
if (e.error.errors.length > 0) { if (e.error.errors.length > 0) {
toast = { toast = {
summary: this.t.instant("common.api_error"), summary: this.t.instant('common.api_error'),
detail: e.error.errors[0].message, detail: e.error.errors[0].message,
}; };
} }
@ -86,8 +86,8 @@ export class ErrorHandlingService implements ErrorHandler {
private handleGraphQlErrors(errors: GraphQLError[]) { private handleGraphQlErrors(errors: GraphQLError[]) {
errors.forEach((e: GraphQLError) => { errors.forEach((e: GraphQLError) => {
this.toast.error( this.toast.error(
this.t.instant("common.api_error"), this.t.instant('common.api_error'),
`${e.message}${e.path ? " " + e.path.join(".") : ""}`, `${e.message}${e.path ? ' ' + e.path.join('.') : ''}`
); );
}); });
} }

View File

@ -3,6 +3,7 @@ import { BehaviorSubject, filter } from 'rxjs';
import { Logger } from 'src/app/service/logger.service'; import { Logger } from 'src/app/service/logger.service';
import { NavigationEnd, Router } from '@angular/router'; import { NavigationEnd, Router } from '@angular/router';
import { SidebarService } from 'src/app/service/sidebar.service'; import { SidebarService } from 'src/app/service/sidebar.service';
import { SettingsService } from 'src/app/service/settings.service';
const logger = new Logger('GuiService'); const logger = new Logger('GuiService');
@ -14,10 +15,14 @@ export class GuiService {
isTablet$ = new BehaviorSubject<boolean>(this.isTabletByWindowWith()); isTablet$ = new BehaviorSubject<boolean>(this.isTabletByWindowWith());
hideGui$ = new BehaviorSubject<boolean>(false); hideGui$ = new BehaviorSubject<boolean>(false);
theme$ = new BehaviorSubject<string>(
this.settingsService.settings.themes[0].name
);
constructor( constructor(
private router: Router, private router: Router,
private sidebarService: SidebarService private sidebarService: SidebarService,
private settingsService: SettingsService
) { ) {
this.router.events this.router.events
.pipe(filter(event => event instanceof NavigationEnd)) .pipe(filter(event => event instanceof NavigationEnd))

View File

@ -1,12 +1,12 @@
import { HttpClient } from "@angular/common/http"; import { HttpClient } from '@angular/common/http';
import { Injectable } from "@angular/core"; import { Injectable } from '@angular/core';
import { throwError } from "rxjs"; import { throwError } from 'rxjs';
import { catchError } from "rxjs/operators"; import { catchError } from 'rxjs/operators';
import { AppSettings } from "src/app/model/config/app-settings"; import { AppSettings } from 'src/app/model/config/app-settings';
import { environment } from "src/environments/environment"; import { environment } from 'src/environments/environment';
@Injectable({ @Injectable({
providedIn: "root", providedIn: 'root',
}) })
export class SettingsService { export class SettingsService {
settings!: AppSettings; settings!: AppSettings;
@ -18,13 +18,19 @@ export class SettingsService {
this.http this.http
.get<AppSettings>(`/assets/config/${environment.config}`) .get<AppSettings>(`/assets/config/${environment.config}`)
.pipe( .pipe(
catchError((error) => { catchError(error => {
reject(error); reject(error);
return throwError(() => error); return throwError(() => error);
}), })
) )
.subscribe((settings) => { .subscribe(settings => {
this.settings = settings; this.settings = settings;
if (this.settings.themes.length === 0) {
this.settings.themes.push({
label: 'Open-redirect',
name: 'open-redirect',
});
}
resolve(); resolve();
}); });
}); });

View File

@ -30,6 +30,14 @@ export class SidebarService {
// trust me, you'll need this async // trust me, you'll need this async
async setElements() { async setElements() {
const elements: MenuElement[] = [ const elements: MenuElement[] = [
{
label: 'common.domains',
icon: 'pi pi-sitemap',
routerLink: ['/admin/domains'],
visible: await this.auth.hasAnyPermissionLazy([
PermissionsEnum.domains,
]),
},
{ {
label: 'common.groups', label: 'common.groups',
icon: 'pi pi-tags', icon: 'pi pi-tags',
@ -42,6 +50,7 @@ export class SidebarService {
routerLink: ['/admin/urls'], routerLink: ['/admin/urls'],
visible: await this.auth.hasAnyPermissionLazy([ visible: await this.auth.hasAnyPermissionLazy([
PermissionsEnum.shortUrls, PermissionsEnum.shortUrls,
PermissionsEnum.shortUrlsByAssignment,
]), ]),
}, },
await this.groupAdministration(), await this.groupAdministration(),

View File

@ -5,7 +5,11 @@
"themes": [ "themes": [
{ {
"label": "Open-redirect", "label": "Open-redirect",
"name": "Open-redirect" "name": "open-redirect"
},
{
"label": "Maxlan",
"name": "maxlan"
} }
], ],
"api": { "api": {

View File

@ -25,6 +25,8 @@
"created": "Erstellt", "created": "Erstellt",
"deleted": "Gelöscht", "deleted": "Gelöscht",
"description": "Beschreibung", "description": "Beschreibung",
"domain": "Domain",
"domains": "Domains",
"download": "Herunterladen", "download": "Herunterladen",
"edited_at": "Bearbeitet am", "edited_at": "Bearbeitet am",
"editor": "Bearbeiter", "editor": "Bearbeiter",
@ -38,6 +40,7 @@
"news": "News", "news": "News",
"role": "Rolle", "role": "Rolle",
"save": "Speichern", "save": "Speichern",
"short_url": "Url",
"updated": "Bearbeitet", "updated": "Bearbeitet",
"upload": "Hochladen", "upload": "Hochladen",
"urls": "URLs", "urls": "URLs",
@ -58,6 +61,9 @@
}, },
"save": "Speichern" "save": "Speichern"
}, },
"domain": {
"count_header": "Domain(s)"
},
"error": { "error": {
"404": "404 - Nicht gefunden", "404": "404 - Nicht gefunden",
"create_failed": "Erstellung fehlgeschlagen", "create_failed": "Erstellung fehlgeschlagen",
@ -89,6 +95,14 @@
"api_keys.create": "Erstellen", "api_keys.create": "Erstellen",
"api_keys.delete": "Löschen", "api_keys.delete": "Löschen",
"api_keys.update": "Bearbeiten", "api_keys.update": "Bearbeiten",
"domains": "Domains",
"domains.create": "Erstellen",
"domains.delete": "Löschen",
"domains.update": "Bearbeiten",
"groups": "Gruppen",
"groups.create": "Erstellen",
"groups.delete": "Löschen",
"groups.update": "Bearbeiten",
"ip_list": "IP Liste", "ip_list": "IP Liste",
"ip_list.create": "Erstellen", "ip_list.create": "Erstellen",
"ip_list.delete": "Löschen", "ip_list.delete": "Löschen",
@ -101,6 +115,11 @@
"roles.create": "Erstellen", "roles.create": "Erstellen",
"roles.delete": "Löschen", "roles.delete": "Löschen",
"roles.update": "Bearbeiten", "roles.update": "Bearbeiten",
"short_urls": "Kurz-URLs",
"short_urls.by_assignment": "Zuweisung",
"short_urls.create": "Erstellen",
"short_urls.delete": "Löschen",
"short_urls.update": "Bearbeiten",
"users": "Benutzer", "users": "Benutzer",
"users.create": "Erstellen", "users.create": "Erstellen",
"users.delete": "Löschen", "users.delete": "Löschen",
@ -109,6 +128,11 @@
"qr": { "qr": {
"width": "Breite" "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)"
}, },
@ -144,6 +168,7 @@
"update": "Bearbeiten" "update": "Bearbeiten"
}, },
"user": { "user": {
"assign_roles": "Rollen zuweisen",
"count_header": "Benutzer", "count_header": "Benutzer",
"email": "E-Mail", "email": "E-Mail",
"user": "Benutzer", "user": "Benutzer",

View File

@ -25,6 +25,8 @@
"created": "Created", "created": "Created",
"deleted": "Deleted", "deleted": "Deleted",
"description": "Description", "description": "Description",
"domain": "Domain",
"domains": "Domains",
"download": "Download", "download": "Download",
"edited_at": "Edited at", "edited_at": "Edited at",
"editor": "Editor", "editor": "Editor",
@ -38,6 +40,7 @@
"news": "News", "news": "News",
"role": "Role", "role": "Role",
"save": "Save", "save": "Save",
"short_url": "Url",
"updated": "Updated", "updated": "Updated",
"upload": "Upload", "upload": "Upload",
"urls": "URLs", "urls": "URLs",
@ -58,6 +61,9 @@
}, },
"save": "Save" "save": "Save"
}, },
"domain": {
"count_header": "Domain(s)"
},
"error": { "error": {
"404": "404 - Not found", "404": "404 - Not found",
"create_failed": "Create failed", "create_failed": "Create failed",
@ -89,6 +95,14 @@
"api_keys.create": "Create", "api_keys.create": "Create",
"api_keys.delete": "Delete", "api_keys.delete": "Delete",
"api_keys.update": "Update", "api_keys.update": "Update",
"domains": "Domains",
"domains.create": "Create",
"domains.delete": "Delete",
"domains.update": "Update",
"groups": "Groups",
"groups.create": "Create",
"groups.delete": "Delete",
"groups.update": "Update",
"ip_list": "IP list", "ip_list": "IP list",
"ip_list.create": "Create", "ip_list.create": "Create",
"ip_list.delete": "Delete", "ip_list.delete": "Delete",
@ -101,11 +115,21 @@
"roles.create": "Create", "roles.create": "Create",
"roles.delete": "Delete", "roles.delete": "Delete",
"roles.update": "Update", "roles.update": "Update",
"short_urls": "Short URLs",
"short_urls.by_assignment": "By assignment",
"short_urls.create": "Create",
"short_urls.delete": "Delete",
"short_urls.update": "Update",
"users": "Users", "users": "Users",
"users.create": "Create", "users.create": "Create",
"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": { "qr": {
"width": "Width" "width": "Width"
}, },
@ -144,6 +168,7 @@
"update": "Update" "update": "Update"
}, },
"user": { "user": {
"assign_roles": "Assign roles",
"count_header": "User(s)", "count_header": "User(s)",
"email": "E-Mail", "email": "E-Mail",
"user": "User", "user": "User",

View File

@ -10,6 +10,7 @@
@import 'tailwindcss/utilities'; @import 'tailwindcss/utilities';
@import 'styles/theme'; @import 'styles/theme';
@import 'styles/theme_maxlan';
@import 'styles/tablet'; @import 'styles/tablet';
@import 'styles/mobile'; @import 'styles/mobile';
@ -58,7 +59,6 @@ body {
input, input,
.p-checkbox-box, .p-checkbox-box,
.p-dropdown { .p-dropdown {
border: 1px solid $accentColor;
padding: 10px; padding: 10px;
} }
@ -70,16 +70,6 @@ body {
} }
} }
@layer utilities {
.divider {
border-bottom: 1px solid $accentColor;
}
.v-divider {
border-left: 1px solid $accentColor;
}
}
header { header {
height: $headerHeight; height: $headerHeight;
display: flex; display: flex;
@ -129,7 +119,6 @@ main {
.component { .component {
margin: 0 10px; margin: 0 10px;
overflow: auto; overflow: auto;
background-color: $backgroundColor;
flex: 1; flex: 1;
padding: 10px; padding: 10px;
} }
@ -141,7 +130,6 @@ footer {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
color: $textColor;
} }
.btn { .btn {

View File

@ -1,25 +1,24 @@
$headerColor: #a2271f; .open-redirect {
$headerColor: #ef9d0d;
// generated with https://colorffy.com/dark-theme-generator?colors=314390-121212 // generated with https://colorffy.com/dark-theme-generator?colors=314390-121212
$textColor: #fff; $textColor: #fff;
$textColorHighlight: #314390; $textColorHighlight: #ef9d0d;
$textColorHighlight2: #a2271f; $textColorHighlight2: #b76f00;
// https://tailwindcss.com/docs/customizing-colors // https://tailwindcss.com/docs/customizing-colors
$backgroundColor: #1e293b; // slate-800 $backgroundColor: #1e293b; // slate-800
$backgroundColor2: #0f172a; // slate-900 $backgroundColor2: #0f172a; // slate-900
$backgroundColor3: #334155; // slate-700 $backgroundColor3: #334155; // slate-700
$accentColor: #475569; // slate-600 $accentColor: #475569; // slate-600
$infoColor: #1ea97c; $infoColor: #1ea97c;
$warningColor: #ffc107; $warningColor: #ffc107;
$errorColor: #ff5252; $errorColor: #ff5252;
body {
background-color: $backgroundColor2; background-color: $backgroundColor2;
}
@layer utilities { @layer utilities {
.highlight { .highlight {
color: $textColorHighlight; color: $textColorHighlight;
} }
@ -59,21 +58,29 @@ body {
.deleted { .deleted {
color: $accentColor !important; color: $accentColor !important;
} }
}
h1, .divider {
h2, border-bottom: 1px solid $accentColor;
h3 { }
}
h1,
h2,
h3 {
color: $headerColor; color: $headerColor;
} }
.app { input, .p-checkbox-box, .p-dropdown {
border: 1px solid $accentColor;
}
.app {
.component { .component {
background-color: $backgroundColor; background-color: $backgroundColor;
} }
} }
.btn-base { .btn-base {
color: $textColor; color: $textColor;
background: $textColorHighlight !important; background: $textColorHighlight !important;
border: 1px solid $textColorHighlight; border: 1px solid $textColorHighlight;
@ -85,15 +92,15 @@ h3 {
&:hover { &:hover {
background: transparent !important; background: transparent !important;
} }
} }
.btn { .btn {
.p-button { .p-button {
@extend .btn-base; @extend .btn-base;
} }
} }
.icon-btn { .icon-btn {
background-color: transparent !important; background-color: transparent !important;
border: none; border: none;
@ -106,9 +113,9 @@ h3 {
color: $textColorHighlight; color: $textColorHighlight;
} }
} }
} }
.text-btn { .text-btn {
background-color: transparent !important; background-color: transparent !important;
.p-button { .p-button {
@ -121,22 +128,22 @@ h3 {
background-color: transparent !important; background-color: transparent !important;
} }
} }
} }
.icon-btn-without-hover { .icon-btn-without-hover {
&:hover { &:hover {
background-color: transparent !important; background-color: transparent !important;
} }
} }
.icon-btn { .icon-btn {
&:hover { &:hover {
background-color: transparent !important; background-color: transparent !important;
} }
} }
.danger-btn, .danger-btn,
.danger-icon-btn { .danger-icon-btn {
background-color: transparent !important; background-color: transparent !important;
.p-button { .p-button {
@ -147,64 +154,64 @@ h3 {
color: $errorColor; color: $errorColor;
} }
} }
} }
.hidden-columns-select { .hidden-columns-select {
.active-while-panel-open { .active-while-panel-open {
.p-button { .p-button {
color: $textColorHighlight; color: $textColorHighlight;
} }
} }
} }
.custom-spinner { .custom-spinner {
.p-progress-spinner-circle { .p-progress-spinner-circle {
stroke: $textColorHighlight !important; stroke: $textColorHighlight !important;
} }
} }
p-paginator { p-paginator {
.p-paginator-element { .p-paginator-element {
&:hover { &:hover {
color: $textColorHighlight; color: $textColorHighlight;
} }
} }
} }
.p-highlight { .p-highlight {
color: $textColorHighlight2; color: $textColorHighlight2;
box-shadow: none !important; box-shadow: none !important;
&:hover { &:hover {
color: $textColorHighlight2 !important; color: $textColorHighlight2 !important;
} }
} }
.p-multiselect, .p-multiselect,
.p-paginator, .p-paginator,
.p-dropdown, .p-dropdown,
input { input {
background-color: $backgroundColor; background-color: $backgroundColor;
} }
.p-inputtext:enabled:focus, .p-inputtext:enabled:focus,
.p-inputtext:enabled:hover, .p-inputtext:enabled:hover,
.p-multiselect:not(.p-disabled):hover, .p-multiselect:not(.p-disabled):hover,
.p-multiselect:not(.p-disabled).p-focus, .p-multiselect:not(.p-disabled).p-focus,
.p-dropdown:not(.p-disabled):hover, .p-dropdown:not(.p-disabled):hover,
.p-checkbox:not(.p-checkbox-disabled) .p-checkbox-box:hover, .p-checkbox:not(.p-checkbox-disabled) .p-checkbox-box:hover,
.p-checkbox:not(.p-checkbox-disabled) .p-checkbox-box.p-focus { .p-checkbox:not(.p-checkbox-disabled) .p-checkbox-box.p-focus {
border-color: $textColorHighlight; border-color: $textColorHighlight;
box-shadow: none !important; box-shadow: none !important;
} }
.p-checkbox .p-checkbox-box.p-highlight { .p-checkbox .p-checkbox-box.p-highlight {
border-color: $textColorHighlight; border-color: $textColorHighlight;
background: $textColorHighlight; background: $textColorHighlight;
box-shadow: none !important; box-shadow: none !important;
} }
p-checkbox { p-checkbox {
.p-checkbox { .p-checkbox {
.p-checkbox-box { .p-checkbox-box {
background-color: $backgroundColor; background-color: $backgroundColor;
@ -215,9 +222,9 @@ p-checkbox {
background-color: $textColorHighlight; background-color: $textColorHighlight;
} }
} }
} }
p-inputSwitch { p-inputSwitch {
.p-inputswitch { .p-inputswitch {
&.p-focus .p-inputswitch-slider { &.p-focus .p-inputswitch-slider {
box-shadow: none !important; box-shadow: none !important;
@ -243,9 +250,9 @@ p-inputSwitch {
} }
} }
} }
} }
p-panel-menu { p-panel-menu {
.p-panelmenu { .p-panelmenu {
.p-panelmenu-content .p-panelmenu-content
.p-menuitem:not(.p-highlight):not(.p-disabled) .p-menuitem:not(.p-highlight):not(.p-disabled)
@ -274,9 +281,9 @@ p-panel-menu {
} }
} }
} }
} }
p-menubar { p-menubar {
.p-menubar { .p-menubar {
background-color: $backgroundColor2; background-color: $backgroundColor2;
} }
@ -303,9 +310,9 @@ p-menubar {
background-color: $backgroundColor2; background-color: $backgroundColor2;
} }
} }
} }
p-dropdown { p-dropdown {
.p-dropdown:not(.p-disabled).p-focus { .p-dropdown:not(.p-disabled).p-focus {
box-shadow: none !important; box-shadow: none !important;
border-color: $headerColor !important; border-color: $headerColor !important;
@ -314,16 +321,16 @@ p-dropdown {
.p-dropdown-panel { .p-dropdown-panel {
background-color: $backgroundColor2; background-color: $backgroundColor2;
} }
} }
p-multiselect { p-multiselect {
.p-multiselect-panel, .p-multiselect-panel,
.p-multiselect-panel .p-multiselect-header { .p-multiselect-panel .p-multiselect-header {
background-color: $backgroundColor2; background-color: $backgroundColor2;
} }
} }
p-table { p-table {
.p-datatable { .p-datatable {
.p-datatable-header, .p-datatable-header,
.p-datatable-footer, .p-datatable-footer,
@ -343,20 +350,20 @@ p-table {
color: $textColorHighlight; color: $textColorHighlight;
} }
} }
} }
p-sidebar { p-sidebar {
.p-sidebar { .p-sidebar {
background-color: $backgroundColor; background-color: $backgroundColor;
} }
} }
.p-steps .p-steps-item.p-highlight .p-steps-number { .p-steps .p-steps-item.p-highlight .p-steps-number {
background-color: $headerColor; background-color: $headerColor;
color: $textColor; color: $textColor;
} }
.p-dialog { .p-dialog {
.p-dialog-header { .p-dialog-header {
background-color: $backgroundColor; background-color: $backgroundColor;
} }
@ -369,23 +376,23 @@ p-sidebar {
.p-dialog-footer { .p-dialog-footer {
background-color: $backgroundColor; background-color: $backgroundColor;
} }
} }
.p-badge { .p-badge {
background: $headerColor; background: $headerColor;
color: $textColor; color: $textColor;
} }
p-password { p-password {
background-color: transparent; background-color: transparent;
border: none; border: none;
.p-password-panel { .p-password-panel {
background-color: transparent; background-color: transparent;
} }
} }
p-slider { p-slider {
.p-slider { .p-slider {
.p-slider-range { .p-slider-range {
background: $headerColor; background: $headerColor;
@ -403,4 +410,5 @@ p-slider {
} }
} }
} }
}
} }

View File

@ -0,0 +1,414 @@
.maxlan {
$headerColor: #a2271f;
// generated with https://colorffy.com/dark-theme-generator?colors=314390-121212
$textColor: #fff;
$textColorHighlight: #314390;
$textColorHighlight2: #a2271f;
// https://tailwindcss.com/docs/customizing-colors
$backgroundColor: #1e293b; // slate-800
$backgroundColor2: #0f172a; // slate-900
$backgroundColor3: #334155; // slate-700
$accentColor: #475569; // slate-600
$infoColor: #1ea97c;
$warningColor: #ffc107;
$errorColor: #ff5252;
background-color: $backgroundColor2;
@layer utilities {
.highlight {
color: $textColorHighlight;
}
.highlight2 {
color: $textColorHighlight2 !important;
}
.bg {
background-color: $backgroundColor;
}
.bg2 {
background-color: $backgroundColor2;
}
.bg3 {
background-color: $backgroundColor3;
}
.accent {
color: $accentColor;
}
.info {
color: $infoColor;
}
.warning {
color: $warningColor;
}
.error {
color: $errorColor;
}
.deleted {
color: $accentColor !important;
}
.divider {
border-bottom: 1px solid $accentColor;
}
}
h1,
h2,
h3 {
color: $headerColor;
}
input, .p-checkbox-box, .p-dropdown {
border: 1px solid $accentColor;
}
.app {
.component {
background-color: $backgroundColor;
}
}
.btn-base {
color: $textColor;
background: $textColorHighlight !important;
border: 1px solid $textColorHighlight;
&:focus {
box-shadow: none !important;
}
&:hover {
background: transparent !important;
}
}
.btn {
.p-button {
@extend .btn-base;
}
}
.icon-btn {
background-color: transparent !important;
border: none;
.p-button {
color: $textColor;
background: transparent !important;
padding: 0;
&:hover {
color: $textColorHighlight;
}
}
}
.text-btn {
background-color: transparent !important;
.p-button {
color: $textColor;
background: transparent !important;
border: none !important;
&:hover {
color: $textColorHighlight;
background-color: transparent !important;
}
}
}
.icon-btn-without-hover {
&:hover {
background-color: transparent !important;
}
}
.icon-btn {
&:hover {
background-color: transparent !important;
}
}
.danger-btn,
.danger-icon-btn {
background-color: transparent !important;
.p-button {
color: $textColor;
background: transparent !important;
&:hover {
color: $errorColor;
}
}
}
.hidden-columns-select {
.active-while-panel-open {
.p-button {
color: $textColorHighlight;
}
}
}
.custom-spinner {
.p-progress-spinner-circle {
stroke: $textColorHighlight !important;
}
}
p-paginator {
.p-paginator-element {
&:hover {
color: $textColorHighlight;
}
}
}
.p-highlight {
color: $textColorHighlight2;
box-shadow: none !important;
&:hover {
color: $textColorHighlight2 !important;
}
}
.p-multiselect,
.p-paginator,
.p-dropdown,
input {
background-color: $backgroundColor;
}
.p-inputtext:enabled:focus,
.p-inputtext:enabled:hover,
.p-multiselect:not(.p-disabled):hover,
.p-multiselect:not(.p-disabled).p-focus,
.p-dropdown:not(.p-disabled):hover,
.p-checkbox:not(.p-checkbox-disabled) .p-checkbox-box:hover,
.p-checkbox:not(.p-checkbox-disabled) .p-checkbox-box.p-focus {
border-color: $textColorHighlight;
box-shadow: none !important;
}
.p-checkbox .p-checkbox-box.p-highlight {
border-color: $textColorHighlight;
background: $textColorHighlight;
box-shadow: none !important;
}
p-checkbox {
.p-checkbox {
.p-checkbox-box {
background-color: $backgroundColor;
}
.p-checkbox-box.p-highlight {
border-color: $textColorHighlight;
background-color: $textColorHighlight;
}
}
}
p-inputSwitch {
.p-inputswitch {
&.p-focus .p-inputswitch-slider {
box-shadow: none !important;
}
.p-inputswitch-slider {
background-color: $headerColor;
&.p-highlight {
border-color: $textColorHighlight;
background: $textColorHighlight;
box-shadow: none !important;
}
}
&.p-inputswitch-checked {
.p-inputswitch-slider {
background-color: $headerColor;
&:before {
background-color: $textColor;
}
}
}
}
}
p-panel-menu {
.p-panelmenu {
.p-panelmenu-content
.p-menuitem:not(.p-highlight):not(.p-disabled)
> .p-menuitem-content:hover
.p-menuitem-link
.p-menuitem-text,
.p-panelmenu-content
.p-menuitem:not(.p-highlight):not(.p-disabled)
> .p-menuitem-content:hover
.p-menuitem-link
.p-menuitem-icon,
.p-panelmenu
.p-panelmenu-content
.p-menuitem:not(.p-highlight):not(.p-disabled)
> .p-menuitem-content:hover
.p-menuitem-link
.p-submenu-icon {
color: $textColorHighlight;
}
.p-panelmenu-header-content {
&:hover {
.p-panelmenu-header-action {
color: $textColorHighlight;
}
}
}
}
}
p-menubar {
.p-menubar {
background-color: $backgroundColor2;
}
.p-menubar-root-list {
display: flex;
gap: 10px;
}
.p-menuitem {
border-radius: 0.5rem;
&:hover {
.p-menuitem-content .p-menuitem-link .p-menuitem-text {
color: $textColorHighlight2;
}
}
}
.p-focus {
background-color: $backgroundColor2;
.p-menuitem-content {
background-color: $backgroundColor2;
}
}
}
p-dropdown {
.p-dropdown:not(.p-disabled).p-focus {
box-shadow: none !important;
border-color: $headerColor !important;
}
.p-dropdown-panel {
background-color: $backgroundColor2;
}
}
p-multiselect {
.p-multiselect-panel,
.p-multiselect-panel .p-multiselect-header {
background-color: $backgroundColor2;
}
}
p-table {
.p-datatable {
.p-datatable-header,
.p-datatable-footer,
.p-datatable-thead > tr > th,
.p-datatable-tbody > tr {
background-color: $backgroundColor;
border-bottom: 1px solid $accentColor;
}
.p-sortable-column.p-highlight,
.p-sortable-column:not(.p-highlight):hover {
background-color: transparent !important;
}
.p-sortable-column:not(.p-highlight):hover,
.p-sortable-column:not(.p-highlight):hover .p-sortable-column-icon {
color: $textColorHighlight;
}
}
}
p-sidebar {
.p-sidebar {
background-color: $backgroundColor;
}
}
.p-steps .p-steps-item.p-highlight .p-steps-number {
background-color: $headerColor;
color: $textColor;
}
.p-dialog {
.p-dialog-header {
background-color: $backgroundColor;
}
.p-dialog-content {
background-color: $backgroundColor;
padding-top: 1rem;
}
.p-dialog-footer {
background-color: $backgroundColor;
}
}
.p-badge {
background: $headerColor;
color: $textColor;
}
p-password {
background-color: transparent;
border: none;
.p-password-panel {
background-color: transparent;
}
}
p-slider {
.p-slider {
.p-slider-range {
background: $headerColor;
}
.p-slider-handle {
border-color: $headerColor;
&:hover {
background: $headerColor;
}
&:focus {
box-shadow: none !important;
}
}
}
}
}