feat(admin): implement group-centric relations and system/module/company linkage views

This commit is contained in:
Chris
2026-03-30 19:38:49 +08:00
parent 37a69081e3
commit ea5285501a
15 changed files with 753 additions and 263 deletions

View File

@@ -13,6 +13,10 @@ from app.repositories.users_repo import UsersRepository
from app.schemas.catalog import ( from app.schemas.catalog import (
CompanyCreateRequest, CompanyCreateRequest,
CompanyItem, CompanyItem,
GroupBindingSnapshot,
GroupBindingUpdateRequest,
GroupRelationItem,
MemberRelationItem,
CompanyUpdateRequest, CompanyUpdateRequest,
MemberItem, MemberItem,
MemberPermissionGroupsResponse, MemberPermissionGroupsResponse,
@@ -69,6 +73,14 @@ def _resolve_scope_ids(db: Session, scope_type: str, scope_id: str) -> tuple[str
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_scope_type") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_scope_type")
def _split_module_key(payload_module: str | None) -> str:
if not payload_module:
return "__system__"
if "." in payload_module:
return payload_module.split(".", 1)[1]
return payload_module
def _sync_member_to_authentik( def _sync_member_to_authentik(
*, *,
authentik_sub: str, authentik_sub: str,
@@ -195,6 +207,96 @@ def update_module(
return ModuleItem(id=row.id, system_key=system_key, module_key=row.module_key, name=row.name, status=row.status) return ModuleItem(id=row.id, system_key=system_key, module_key=row.module_key, name=row.name, status=row.status)
@router.get("/systems/{system_key}/groups")
def list_system_groups(
system_key: str,
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
) -> dict[str, list[dict]]:
systems_repo = SystemsRepository(db)
groups_repo = PermissionGroupsRepository(db)
if not systems_repo.get_by_key(system_key):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="system_not_found")
groups = groups_repo.list_system_groups(system_key)
return {
"items": [
GroupRelationItem(group_key=g.group_key, group_name=g.name, status=g.status).model_dump()
for g in groups
]
}
@router.get("/systems/{system_key}/members")
def list_system_members(
system_key: str,
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
) -> dict[str, list[dict]]:
systems_repo = SystemsRepository(db)
groups_repo = PermissionGroupsRepository(db)
if not systems_repo.get_by_key(system_key):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="system_not_found")
members = groups_repo.list_system_members(system_key)
return {
"items": [
MemberRelationItem(
authentik_sub=m.authentik_sub,
email=m.email,
display_name=m.display_name,
is_active=m.is_active,
).model_dump()
for m in members
]
}
@router.get("/modules/{module_key}/groups")
def list_module_groups(
module_key: str,
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
) -> dict[str, list[dict]]:
modules_repo = ModulesRepository(db)
groups_repo = PermissionGroupsRepository(db)
module = modules_repo.get_by_key(module_key)
if not module:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="module_not_found")
system_key, module_name = module.module_key.split(".", 1) if "." in module.module_key else ("", module.module_key)
groups = groups_repo.list_module_groups(system_key, module_name)
return {
"items": [
GroupRelationItem(group_key=g.group_key, group_name=g.name, status=g.status).model_dump()
for g in groups
]
}
@router.get("/modules/{module_key}/members")
def list_module_members(
module_key: str,
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
) -> dict[str, list[dict]]:
modules_repo = ModulesRepository(db)
groups_repo = PermissionGroupsRepository(db)
module = modules_repo.get_by_key(module_key)
if not module:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="module_not_found")
system_key, module_name = module.module_key.split(".", 1) if "." in module.module_key else ("", module.module_key)
members = groups_repo.list_module_members(system_key, module_name)
return {
"items": [
MemberRelationItem(
authentik_sub=m.authentik_sub,
email=m.email,
display_name=m.display_name,
is_active=m.is_active,
).model_dump()
for m in members
]
}
@router.get("/companies") @router.get("/companies")
def list_companies( def list_companies(
_: ApiClient = Depends(require_api_client), _: ApiClient = Depends(require_api_client),
@@ -236,6 +338,32 @@ def update_company(
return CompanyItem(id=row.id, company_key=row.company_key, name=row.name, status=row.status) return CompanyItem(id=row.id, company_key=row.company_key, name=row.name, status=row.status)
@router.get("/companies/{company_key}/sites")
def list_company_sites(
company_key: str,
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
) -> dict[str, list[dict]]:
companies_repo = CompaniesRepository(db)
sites_repo = SitesRepository(db)
company = companies_repo.get_by_key(company_key)
if not company:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="company_not_found")
items, _ = sites_repo.list(company_id=company.id, limit=1000, offset=0)
return {
"items": [
SiteItem(
id=i.id,
site_key=i.site_key,
company_key=company.company_key,
name=i.name,
status=i.status,
).model_dump()
for i in items
]
}
@router.get("/sites") @router.get("/sites")
def list_sites( def list_sites(
_: ApiClient = Depends(require_api_client), _: ApiClient = Depends(require_api_client),
@@ -481,16 +609,78 @@ def list_permission_group_permissions(
PermissionGroupPermissionItem( PermissionGroupPermissionItem(
id=r.id, id=r.id,
system=r.system, system=r.system,
module=r.module, module=("" if r.module == "__system__" else (r.module if "." in r.module else f"{r.system}.{r.module}")),
action=r.action, action=r.action,
scope_type=r.scope_type, scope_type=r.scope_type,
scope_id=r.scope_id, scope_id=r.scope_id,
).model_dump() ).model_dump()
for r in rows for r in rows
if r.action in {"view", "edit"} and r.scope_type == "site"
] ]
} }
@router.get("/permission-groups/{group_key}/bindings", response_model=GroupBindingSnapshot)
def get_permission_group_bindings(
group_key: str,
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
) -> GroupBindingSnapshot:
repo = PermissionGroupsRepository(db)
group = repo.get_by_key(group_key)
if not group:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="group_not_found")
snapshot = repo.get_group_binding_snapshot(group.id, group_key)
return GroupBindingSnapshot(**snapshot)
@router.put("/permission-groups/{group_key}/bindings", response_model=GroupBindingSnapshot)
def replace_permission_group_bindings(
group_key: str,
payload: GroupBindingUpdateRequest,
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
) -> GroupBindingSnapshot:
repo = PermissionGroupsRepository(db)
sites_repo = SitesRepository(db)
systems_repo = SystemsRepository(db)
modules_repo = ModulesRepository(db)
group = repo.get_by_key(group_key)
if not group:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="group_not_found")
site_keys = list(dict.fromkeys(payload.site_keys))
system_keys = list(dict.fromkeys(payload.system_keys))
module_keys = list(dict.fromkeys(payload.module_keys))
valid_sites = {s.site_key for s in sites_repo.list(limit=10000, offset=0)[0]}
missing_sites = [k for k in site_keys if k not in valid_sites]
if missing_sites:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"site_not_found:{','.join(missing_sites)}")
valid_systems = {s.system_key for s in systems_repo.list(limit=10000, offset=0)[0]}
missing_systems = [k for k in system_keys if k not in valid_systems]
if missing_systems:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"system_not_found:{','.join(missing_systems)}")
valid_modules = {m.module_key for m in modules_repo.list(limit=10000, offset=0)[0]}
missing_modules = [k for k in module_keys if k not in valid_modules]
if missing_modules:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"module_not_found:{','.join(missing_modules)}")
repo.replace_group_bindings(
group_id=group.id,
site_keys=site_keys,
system_keys=system_keys,
module_keys=module_keys,
member_subs=payload.member_subs,
actions=payload.actions,
)
snapshot = repo.get_group_binding_snapshot(group.id, group_key)
return GroupBindingSnapshot(**snapshot)
@router.post("/permission-groups", response_model=PermissionGroupItem) @router.post("/permission-groups", response_model=PermissionGroupItem)
def create_permission_group( def create_permission_group(
payload: PermissionGroupCreateRequest, payload: PermissionGroupCreateRequest,
@@ -562,11 +752,11 @@ def grant_group_permission(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="group_not_found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="group_not_found")
_resolve_module_id(db, payload.system, payload.module) _resolve_module_id(db, payload.system, payload.module)
_resolve_scope_ids(db, payload.scope_type, payload.scope_id) _resolve_scope_ids(db, payload.scope_type, payload.scope_id)
module_key = f"{payload.system}.{payload.module}" if payload.module else f"{payload.system}.__system__" module_name = _split_module_key(payload.module)
row = groups_repo.grant_group_permission( row = groups_repo.grant_group_permission(
group_id=group.id, group_id=group.id,
system=payload.system, system=payload.system,
module=module_key, module=module_name,
action=payload.action, action=payload.action,
scope_type=payload.scope_type, scope_type=payload.scope_type,
scope_id=payload.scope_id, scope_id=payload.scope_id,
@@ -587,11 +777,11 @@ def revoke_group_permission(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="group_not_found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="group_not_found")
_resolve_module_id(db, payload.system, payload.module) _resolve_module_id(db, payload.system, payload.module)
_resolve_scope_ids(db, payload.scope_type, payload.scope_id) _resolve_scope_ids(db, payload.scope_type, payload.scope_id)
module_key = f"{payload.system}.{payload.module}" if payload.module else f"{payload.system}.__system__" module_name = _split_module_key(payload.module)
deleted = groups_repo.revoke_group_permission( deleted = groups_repo.revoke_group_permission(
group_id=group.id, group_id=group.id,
system=payload.system, system=payload.system,
module=module_key, module=module_name,
action=payload.action, action=payload.action,
scope_type=payload.scope_type, scope_type=payload.scope_type,
scope_id=payload.scope_id, scope_id=payload.scope_id,

View File

@@ -1,11 +1,14 @@
from __future__ import annotations from __future__ import annotations
from collections import defaultdict
from sqlalchemy import delete, func, select from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models.permission_group import PermissionGroup from app.models.permission_group import PermissionGroup
from app.models.permission_group_member import PermissionGroupMember from app.models.permission_group_member import PermissionGroupMember
from app.models.permission_group_permission import PermissionGroupPermission from app.models.permission_group_permission import PermissionGroupPermission
from app.models.user import User
class PermissionGroupsRepository: class PermissionGroupsRepository:
@@ -125,6 +128,128 @@ class PermissionGroupsRepository:
) )
return list(self.db.scalars(stmt).all()) return list(self.db.scalars(stmt).all())
def replace_group_bindings(
self,
*,
group_id: str,
site_keys: list[str],
system_keys: list[str],
module_keys: list[str],
member_subs: list[str],
actions: list[str],
) -> None:
normalized_sites = list(dict.fromkeys([s for s in site_keys if s]))
normalized_actions = [a for a in list(dict.fromkeys(actions)) if a in {"view", "edit"}]
normalized_member_subs = list(dict.fromkeys([s for s in member_subs if s]))
modules_by_system: dict[str, list[str]] = defaultdict(list)
for full_module_key in list(dict.fromkeys([m for m in module_keys if m])):
if "." not in full_module_key:
continue
system_key, module_name = full_module_key.split(".", 1)
if module_name == "__system__":
continue
modules_by_system[system_key].append(module_name)
normalized_systems = set([s for s in system_keys if s])
normalized_systems.update(modules_by_system.keys())
self.db.execute(delete(PermissionGroupPermission).where(PermissionGroupPermission.group_id == group_id))
self.db.execute(delete(PermissionGroupMember).where(PermissionGroupMember.group_id == group_id))
for sub in normalized_member_subs:
self.db.add(PermissionGroupMember(group_id=group_id, authentik_sub=sub))
for site_key in normalized_sites:
for action in normalized_actions:
for system_key in sorted(normalized_systems):
module_names = modules_by_system.get(system_key) or ["__system__"]
for module_name in module_names:
self.db.add(
PermissionGroupPermission(
group_id=group_id,
system=system_key,
module=module_name,
action=action,
scope_type="site",
scope_id=site_key,
)
)
self.db.commit()
def get_group_binding_snapshot(self, group_id: str, group_key: str) -> dict:
permissions = self.list_group_permissions(group_id)
site_keys = sorted({p.scope_id for p in permissions if p.scope_type == "site"})
system_keys = sorted({p.system for p in permissions})
actions = sorted({p.action for p in permissions if p.action in {"view", "edit"}})
module_keys = sorted(
{
p.module if "." in p.module else f"{p.system}.{p.module}"
for p in permissions
if p.module and p.module != "__system__"
}
)
member_subs = sorted(self.list_group_member_subs(group_id))
return {
"group_key": group_key,
"site_keys": site_keys,
"system_keys": system_keys,
"module_keys": module_keys,
"member_subs": member_subs,
"actions": actions,
}
def list_group_member_subs(self, group_id: str) -> list[str]:
stmt = (
select(PermissionGroupMember.authentik_sub)
.where(PermissionGroupMember.group_id == group_id)
.order_by(PermissionGroupMember.authentik_sub.asc())
)
return [row[0] for row in self.db.execute(stmt).all()]
def list_system_groups(self, system_key: str) -> list[PermissionGroup]:
stmt = (
select(PermissionGroup)
.join(PermissionGroupPermission, PermissionGroupPermission.group_id == PermissionGroup.id)
.where(PermissionGroupPermission.system == system_key)
.order_by(PermissionGroup.name.asc())
.distinct()
)
return list(self.db.scalars(stmt).all())
def list_system_members(self, system_key: str) -> list[User]:
stmt = (
select(User)
.join(PermissionGroupMember, PermissionGroupMember.authentik_sub == User.authentik_sub)
.join(PermissionGroupPermission, PermissionGroupPermission.group_id == PermissionGroupMember.group_id)
.where(PermissionGroupPermission.system == system_key)
.order_by(User.email.asc(), User.authentik_sub.asc())
.distinct()
)
return list(self.db.scalars(stmt).all())
def list_module_groups(self, system_key: str, module_name: str) -> list[PermissionGroup]:
stmt = (
select(PermissionGroup)
.join(PermissionGroupPermission, PermissionGroupPermission.group_id == PermissionGroup.id)
.where(PermissionGroupPermission.system == system_key, PermissionGroupPermission.module == module_name)
.order_by(PermissionGroup.name.asc())
.distinct()
)
return list(self.db.scalars(stmt).all())
def list_module_members(self, system_key: str, module_name: str) -> list[User]:
stmt = (
select(User)
.join(PermissionGroupMember, PermissionGroupMember.authentik_sub == User.authentik_sub)
.join(PermissionGroupPermission, PermissionGroupPermission.group_id == PermissionGroupMember.group_id)
.where(PermissionGroupPermission.system == system_key, PermissionGroupPermission.module == module_name)
.order_by(User.email.asc(), User.authentik_sub.asc())
.distinct()
)
return list(self.db.scalars(stmt).all())
def revoke_group_permission( def revoke_group_permission(
self, self,
group_id: str, group_id: str,

View File

@@ -29,6 +29,8 @@ class PermissionsRepository:
.join(Company, Company.id == UserScopePermission.company_id, isouter=True) .join(Company, Company.id == UserScopePermission.company_id, isouter=True)
.join(Site, Site.id == UserScopePermission.site_id, isouter=True) .join(Site, Site.id == UserScopePermission.site_id, isouter=True)
.where(UserScopePermission.user_id == user_id) .where(UserScopePermission.user_id == user_id)
.where(UserScopePermission.action.in_(["view", "edit"]))
.where(UserScopePermission.scope_type == "site")
) )
group_stmt = ( group_stmt = (
select( select(
@@ -42,6 +44,8 @@ class PermissionsRepository:
.select_from(PermissionGroupPermission) .select_from(PermissionGroupPermission)
.join(PermissionGroupMember, PermissionGroupMember.group_id == PermissionGroupPermission.group_id) .join(PermissionGroupMember, PermissionGroupMember.group_id == PermissionGroupPermission.group_id)
.where(PermissionGroupMember.authentik_sub == authentik_sub) .where(PermissionGroupMember.authentik_sub == authentik_sub)
.where(PermissionGroupPermission.action.in_(["view", "edit"]))
.where(PermissionGroupPermission.scope_type == "site")
) )
rows = self.db.execute(direct_stmt).all() + self.db.execute(group_stmt).all() rows = self.db.execute(direct_stmt).all() + self.db.execute(group_stmt).all()
result: list[tuple[str, str, str | None, str, str]] = [] result: list[tuple[str, str, str | None, str, str]] = []
@@ -50,6 +54,10 @@ class PermissionsRepository:
source = row[0] source = row[0]
if source == "group": if source == "group":
_, scope_type, scope_id, system_key, module_key, action = row _, scope_type, scope_id, system_key, module_key, action = row
if module_key == "__system__":
module_key = f"{system_key}.__system__"
elif module_key and "." not in module_key:
module_key = f"{system_key}.{module_key}"
else: else:
_, scope_type, company_key, site_key, module_key, action = row _, scope_type, company_key, site_key, module_key, action = row
scope_id = company_key if scope_type == "company" else site_key scope_id = company_key if scope_type == "company" else site_key
@@ -147,6 +155,8 @@ class PermissionsRepository:
.join(Module, Module.id == UserScopePermission.module_id) .join(Module, Module.id == UserScopePermission.module_id)
.join(Company, Company.id == UserScopePermission.company_id, isouter=True) .join(Company, Company.id == UserScopePermission.company_id, isouter=True)
.join(Site, Site.id == UserScopePermission.site_id, isouter=True) .join(Site, Site.id == UserScopePermission.site_id, isouter=True)
.where(UserScopePermission.action.in_(["view", "edit"]))
.where(UserScopePermission.scope_type == "site")
) )
count_stmt = ( count_stmt = (
select(func.count()) select(func.count())
@@ -155,9 +165,11 @@ class PermissionsRepository:
.join(Module, Module.id == UserScopePermission.module_id) .join(Module, Module.id == UserScopePermission.module_id)
.join(Company, Company.id == UserScopePermission.company_id, isouter=True) .join(Company, Company.id == UserScopePermission.company_id, isouter=True)
.join(Site, Site.id == UserScopePermission.site_id, isouter=True) .join(Site, Site.id == UserScopePermission.site_id, isouter=True)
.where(UserScopePermission.action.in_(["view", "edit"]))
.where(UserScopePermission.scope_type == "site")
) )
if scope_type in {"company", "site"}: if scope_type == "site":
stmt = stmt.where(UserScopePermission.scope_type == scope_type) stmt = stmt.where(UserScopePermission.scope_type == scope_type)
count_stmt = count_stmt.where(UserScopePermission.scope_type == scope_type) count_stmt = count_stmt.where(UserScopePermission.scope_type == scope_type)

View File

@@ -1,4 +1,5 @@
from pydantic import BaseModel from pydantic import BaseModel
from typing import Literal
class SystemCreateRequest(BaseModel): class SystemCreateRequest(BaseModel):
@@ -134,11 +135,41 @@ class PermissionGroupPermissionItem(BaseModel):
id: str id: str
system: str system: str
module: str module: str
action: str action: Literal["view", "edit"]
scope_type: str scope_type: Literal["site"]
scope_id: str scope_id: str
class MemberPermissionGroupsResponse(BaseModel): class MemberPermissionGroupsResponse(BaseModel):
authentik_sub: str authentik_sub: str
group_keys: list[str] group_keys: list[str]
class GroupBindingUpdateRequest(BaseModel):
site_keys: list[str]
system_keys: list[str]
module_keys: list[str]
member_subs: list[str]
actions: list[Literal["view", "edit"]]
class GroupBindingSnapshot(BaseModel):
group_key: str
site_keys: list[str]
system_keys: list[str]
module_keys: list[str]
member_subs: list[str]
actions: list[Literal["view", "edit"]]
class GroupRelationItem(BaseModel):
group_key: str
group_name: str
status: str
class MemberRelationItem(BaseModel):
authentik_sub: str
email: str | None = None
display_name: str | None = None
is_active: bool

View File

@@ -1,34 +1,38 @@
from datetime import datetime from datetime import datetime
from typing import Literal
from pydantic import BaseModel from pydantic import BaseModel
ActionType = Literal["view", "edit"]
ScopeType = Literal["site"]
class PermissionGrantRequest(BaseModel): class PermissionGrantRequest(BaseModel):
authentik_sub: str authentik_sub: str
email: str | None = None email: str | None = None
display_name: str | None = None display_name: str | None = None
scope_type: str scope_type: ScopeType
scope_id: str scope_id: str
system: str system: str
module: str | None = None module: str | None = None
action: str action: ActionType
class PermissionRevokeRequest(BaseModel): class PermissionRevokeRequest(BaseModel):
authentik_sub: str authentik_sub: str
scope_type: str scope_type: ScopeType
scope_id: str scope_id: str
system: str system: str
module: str | None = None module: str | None = None
action: str action: ActionType
class PermissionItem(BaseModel): class PermissionItem(BaseModel):
scope_type: str scope_type: ScopeType
scope_id: str scope_id: str
system: str | None = None system: str | None = None
module: str module: str
action: str action: ActionType
class PermissionSnapshotResponse(BaseModel): class PermissionSnapshotResponse(BaseModel):
@@ -41,11 +45,11 @@ class DirectPermissionRow(BaseModel):
authentik_sub: str authentik_sub: str
email: str | None = None email: str | None = None
display_name: str | None = None display_name: str | None = None
scope_type: str scope_type: ScopeType
scope_id: str scope_id: str
system: str | None = None system: str | None = None
module: str | None = None module: str | None = None
action: str action: ActionType
created_at: datetime created_at: datetime

View File

@@ -56,7 +56,6 @@ const userTabs = [
] ]
const adminTabs = [ const adminTabs = [
{ to: '/admin/permissions', label: '權限管理' },
{ to: '/admin/systems', label: '系統' }, { to: '/admin/systems', label: '系統' },
{ to: '/admin/modules', label: '模組' }, { to: '/admin/modules', label: '模組' },
{ to: '/admin/companies', label: '公司' }, { to: '/admin/companies', label: '公司' },

View File

@@ -3,3 +3,4 @@ import { adminHttp } from './http'
export const getCompanies = () => adminHttp.get('/admin/companies') export const getCompanies = () => adminHttp.get('/admin/companies')
export const createCompany = (data) => adminHttp.post('/admin/companies', data) export const createCompany = (data) => adminHttp.post('/admin/companies', data)
export const updateCompany = (companyKey, data) => adminHttp.patch(`/admin/companies/${companyKey}`, data) export const updateCompany = (companyKey, data) => adminHttp.patch(`/admin/companies/${companyKey}`, data)
export const getCompanySites = (companyKey) => adminHttp.get(`/admin/companies/${companyKey}/sites`)

View File

@@ -3,3 +3,5 @@ import { adminHttp } from './http'
export const getModules = () => adminHttp.get('/admin/modules') export const getModules = () => adminHttp.get('/admin/modules')
export const createModule = (data) => adminHttp.post('/admin/modules', data) export const createModule = (data) => adminHttp.post('/admin/modules', data)
export const updateModule = (moduleKey, data) => adminHttp.patch(`/admin/modules/${moduleKey}`, data) export const updateModule = (moduleKey, data) => adminHttp.patch(`/admin/modules/${moduleKey}`, data)
export const getModuleGroups = (moduleKey) => adminHttp.get(`/admin/modules/${moduleKey}/groups`)
export const getModuleMembers = (moduleKey) => adminHttp.get(`/admin/modules/${moduleKey}/members`)

View File

@@ -4,6 +4,9 @@ export const getPermissionGroups = () => adminHttp.get('/admin/permission-groups
export const createPermissionGroup = (data) => adminHttp.post('/admin/permission-groups', data) export const createPermissionGroup = (data) => adminHttp.post('/admin/permission-groups', data)
export const updatePermissionGroup = (groupKey, data) => adminHttp.patch(`/admin/permission-groups/${groupKey}`, data) export const updatePermissionGroup = (groupKey, data) => adminHttp.patch(`/admin/permission-groups/${groupKey}`, data)
export const getPermissionGroupPermissions = (groupKey) => adminHttp.get(`/admin/permission-groups/${groupKey}/permissions`) export const getPermissionGroupPermissions = (groupKey) => adminHttp.get(`/admin/permission-groups/${groupKey}/permissions`)
export const getPermissionGroupBindings = (groupKey) => adminHttp.get(`/admin/permission-groups/${groupKey}/bindings`)
export const updatePermissionGroupBindings = (groupKey, data) =>
adminHttp.put(`/admin/permission-groups/${groupKey}/bindings`, data)
export const addMemberToGroup = (groupKey, authentikSub) => export const addMemberToGroup = (groupKey, authentikSub) =>
adminHttp.post(`/admin/permission-groups/${groupKey}/members/${authentikSub}`) adminHttp.post(`/admin/permission-groups/${groupKey}/members/${authentikSub}`)

View File

@@ -3,3 +3,5 @@ import { adminHttp } from './http'
export const getSystems = () => adminHttp.get('/admin/systems') export const getSystems = () => adminHttp.get('/admin/systems')
export const createSystem = (data) => adminHttp.post('/admin/systems', data) export const createSystem = (data) => adminHttp.post('/admin/systems', data)
export const updateSystem = (systemKey, data) => adminHttp.patch(`/admin/systems/${systemKey}`, data) export const updateSystem = (systemKey, data) => adminHttp.patch(`/admin/systems/${systemKey}`, data)
export const getSystemGroups = (systemKey) => adminHttp.get(`/admin/systems/${systemKey}/groups`)
export const getSystemMembers = (systemKey) => adminHttp.get(`/admin/systems/${systemKey}/members`)

View File

@@ -13,9 +13,10 @@
<el-table-column prop="company_key" label="Company Key" width="220" /> <el-table-column prop="company_key" label="Company Key" width="220" />
<el-table-column prop="name" label="名稱" min-width="200" /> <el-table-column prop="name" label="名稱" min-width="200" />
<el-table-column prop="status" label="狀態" width="120" /> <el-table-column prop="status" label="狀態" width="120" />
<el-table-column label="操作" width="120"> <el-table-column label="操作" width="200">
<template #default="{ row }"> <template #default="{ row }">
<el-button size="small" @click="openEdit(row)">編輯</el-button> <el-button size="small" @click="openEdit(row)">編輯</el-button>
<el-button size="small" @click="openSites(row)">站台</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -47,6 +48,18 @@
<el-button type="primary" :loading="savingEdit" @click="handleEdit">儲存</el-button> <el-button type="primary" :loading="savingEdit" @click="handleEdit">儲存</el-button>
</template> </template>
</el-dialog> </el-dialog>
<el-dialog v-model="showSitesDialog" :title="`公司站台:${selectedCompanyKey}`" width="900px">
<el-table :data="companySites" border stripe v-loading="sitesLoading">
<template #empty><el-empty description="此公司目前沒有站台" /></template>
<el-table-column prop="site_key" label="Site Key" width="220" />
<el-table-column prop="name" label="名稱" min-width="220" />
<el-table-column prop="status" label="狀態" width="120" />
</el-table>
<template #footer>
<el-button @click="showSitesDialog = false">關閉</el-button>
</template>
</el-dialog>
</div> </div>
</template> </template>
@@ -54,7 +67,7 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { Plus } from '@element-plus/icons-vue' import { Plus } from '@element-plus/icons-vue'
import { getCompanies, createCompany, updateCompany } from '@/api/companies' import { getCompanies, createCompany, updateCompany, getCompanySites } from '@/api/companies'
const companies = ref([]) const companies = ref([])
const loading = ref(false) const loading = ref(false)
@@ -73,6 +86,11 @@ const rules = {
name: [{ required: true, message: '請輸入名稱', trigger: 'blur' }] name: [{ required: true, message: '請輸入名稱', trigger: 'blur' }]
} }
const showSitesDialog = ref(false)
const sitesLoading = ref(false)
const selectedCompanyKey = ref('')
const companySites = ref([])
async function load() { async function load() {
loading.value = true loading.value = true
error.value = false error.value = false
@@ -133,5 +151,19 @@ async function handleEdit() {
} }
} }
async function openSites(row) {
selectedCompanyKey.value = row.company_key
showSitesDialog.value = true
sitesLoading.value = true
try {
const res = await getCompanySites(row.company_key)
companySites.value = res.data?.items || []
} catch (err) {
ElMessage.error('載入公司站台失敗')
} finally {
sitesLoading.value = false
}
}
onMounted(load) onMounted(load)
</script> </script>

View File

@@ -19,12 +19,14 @@
<el-table v-else :data="modules" stripe border class="w-full shadow-sm"> <el-table v-else :data="modules" stripe border class="w-full shadow-sm">
<template #empty><el-empty description="目前無模組" /></template> <template #empty><el-empty description="目前無模組" /></template>
<el-table-column prop="system_key" label="System" width="140" /> <el-table-column prop="system_key" label="System" width="140" />
<el-table-column prop="module_key" label="Module Key" width="180" /> <el-table-column prop="module_key" label="Module Key" width="220" />
<el-table-column prop="name" label="名稱" min-width="180" /> <el-table-column prop="name" label="名稱" min-width="180" />
<el-table-column prop="status" label="狀態" width="120" /> <el-table-column prop="status" label="狀態" width="120" />
<el-table-column label="操作" width="120"> <el-table-column label="操作" width="260">
<template #default="{ row }"> <template #default="{ row }">
<el-button size="small" @click="openEdit(row)">編輯</el-button> <el-button size="small" @click="openEdit(row)">編輯</el-button>
<el-button size="small" @click="openRelations(row, 'groups')">群組</el-button>
<el-button size="small" @click="openRelations(row, 'members')">會員</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -69,6 +71,33 @@
<el-button type="primary" :loading="savingEdit" @click="handleEdit">儲存</el-button> <el-button type="primary" :loading="savingEdit" @click="handleEdit">儲存</el-button>
</template> </template>
</el-dialog> </el-dialog>
<el-dialog v-model="showRelationDialog" :title="`模組關聯:${relationModuleKey}`" width="900px">
<el-tabs v-model="relationTab">
<el-tab-pane label="所屬群組" name="groups">
<el-table :data="relationGroups" border stripe v-loading="relationLoading">
<template #empty><el-empty description="尚無關聯群組" /></template>
<el-table-column prop="group_key" label="Group Key" width="220" />
<el-table-column prop="group_name" label="名稱" min-width="220" />
<el-table-column prop="status" label="狀態" width="120" />
</el-table>
</el-tab-pane>
<el-tab-pane label="涉及會員" name="members">
<el-table :data="relationMembers" border stripe v-loading="relationLoading">
<template #empty><el-empty description="尚無關聯會員" /></template>
<el-table-column prop="authentik_sub" label="Authentik Sub" min-width="260" />
<el-table-column prop="email" label="Email" min-width="220" />
<el-table-column prop="display_name" label="顯示名稱" min-width="160" />
<el-table-column label="啟用" width="80">
<template #default="{ row }">{{ row.is_active ? '是' : '否' }}</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
<template #footer>
<el-button @click="showRelationDialog = false">關閉</el-button>
</template>
</el-dialog>
</div> </div>
</template> </template>
@@ -76,7 +105,7 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { Plus } from '@element-plus/icons-vue' import { Plus } from '@element-plus/icons-vue'
import { getModules, createModule, updateModule } from '@/api/modules' import { getModules, createModule, updateModule, getModuleGroups, getModuleMembers } from '@/api/modules'
import { getSystems } from '@/api/systems' import { getSystems } from '@/api/systems'
const modules = ref([]) const modules = ref([])
@@ -98,6 +127,13 @@ const rules = {
name: [{ required: true, message: '請輸入名稱', trigger: 'blur' }] name: [{ required: true, message: '請輸入名稱', trigger: 'blur' }]
} }
const showRelationDialog = ref(false)
const relationLoading = ref(false)
const relationModuleKey = ref('')
const relationTab = ref('groups')
const relationGroups = ref([])
const relationMembers = ref([])
async function load() { async function load() {
loading.value = true loading.value = true
error.value = false error.value = false
@@ -166,5 +202,24 @@ async function handleEdit() {
} }
} }
async function openRelations(row, tab) {
relationModuleKey.value = row.module_key
relationTab.value = tab
showRelationDialog.value = true
relationLoading.value = true
try {
const [groupsRes, membersRes] = await Promise.all([
getModuleGroups(row.module_key),
getModuleMembers(row.module_key)
])
relationGroups.value = groupsRes.data?.items || []
relationMembers.value = membersRes.data?.items || []
} catch (err) {
ElMessage.error('載入模組關聯資料失敗')
} finally {
relationLoading.value = false
}
}
onMounted(load) onMounted(load)
</script> </script>

View File

@@ -1,105 +1,96 @@
<template> <template>
<div> <div>
<h2 class="text-xl font-bold text-gray-800 mb-6">權限群組管理</h2> <h2 class="text-xl font-bold text-gray-800 mb-6">群組與權限管理</h2>
<el-tabs v-model="activeTab" type="border-card" class="shadow-sm"> <el-card class="mb-6 shadow-sm">
<!-- Groups Tab --> <template #header>
<el-tab-pane label="群組管理" name="groups"> <div class="flex items-center justify-between">
<div class="mt-4"> <span class="font-medium">群組列表</span>
<el-button type="primary" @click="showCreateGroup = true" :icon="Plus" class="mb-4"> <el-button type="primary" @click="showCreateGroup = true" :icon="Plus">新增群組</el-button>
新增群組
</el-button>
<el-skeleton v-if="loadingGroups" :rows="4" animated />
<el-table v-else :data="groups" stripe border class="w-full">
<template #empty><el-empty description="目前無群組" /></template>
<el-table-column prop="group_key" label="Group Key" width="180" />
<el-table-column prop="name" label="群組名稱" min-width="200" />
<el-table-column prop="status" label="狀態" width="120" />
<el-table-column label="操作" width="120">
<template #default="{ row }">
<el-button size="small" @click="openEditGroup(row)">編輯</el-button>
<el-button size="small" class="ml-2" @click="openPermissionsDialog(row)">權限</el-button>
</template>
</el-table-column>
</el-table>
</div> </div>
</el-tab-pane> </template>
<!-- Permissions Tab --> <el-skeleton v-if="loadingGroups" :rows="4" animated />
<el-tab-pane label="群組授權" name="permissions">
<div class="mt-4">
<el-form :model="groupPermForm" label-width="120px" class="max-w-xl mb-4">
<el-form-item label="Group Key">
<el-select v-model="groupPermForm.groupKey" placeholder="選擇群組">
<el-option v-for="g in groups" :key="g.group_key" :label="`${g.name} (${g.group_key})`" :value="g.group_key" />
</el-select>
</el-form-item>
<el-form-item label="Scope Type">
<el-select v-model="groupPermForm.scope_type" placeholder="company or site">
<el-option label="Company" value="company" />
<el-option label="Site" value="site" />
</el-select>
</el-form-item>
<el-form-item label="Scope ID">
<el-select v-model="groupPermForm.scope_id" placeholder="選擇 Scope ID" filterable style="width: 100%">
<el-option
v-for="s in scopeOptions"
:key="s.value"
:label="s.label"
:value="s.value"
/>
</el-select>
</el-form-item>
<el-form-item label="系統">
<el-select v-model="groupPermForm.system" placeholder="選擇系統" filterable style="width: 100%">
<el-option v-for="s in systems" :key="s.system_key" :label="`${s.name} (${s.system_key})`" :value="s.system_key" />
</el-select>
</el-form-item>
<el-form-item label="模組(選填)">
<el-select v-model="groupPermForm.module" placeholder="系統層(留空) 或選模組" clearable filterable style="width: 100%">
<el-option
v-for="m in filteredModuleOptions"
:key="m.value"
:label="m.label"
:value="m.value"
/>
</el-select>
</el-form-item>
<el-form-item label="操作">
<el-select v-model="groupPermForm.action" filterable allow-create default-first-option style="width: 100%">
<el-option v-for="a in actionOptions" :key="a" :label="a" :value="a" />
</el-select>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="grantingGroupPerm"
@click="handleGroupGrant"
:disabled="!groupPermForm.groupKey || !groupPermForm.scope_type || !groupPermForm.scope_id || !groupPermForm.system || !groupPermForm.action"
>
Grant 授權
</el-button>
<el-button
type="danger"
class="ml-2"
:loading="revokingGroupPerm"
@click="handleGroupRevoke"
:disabled="!groupPermForm.groupKey || !groupPermForm.scope_type || !groupPermForm.scope_id || !groupPermForm.system || !groupPermForm.action"
>
Revoke 撤銷
</el-button>
</el-form-item>
</el-form>
<el-alert v-if="groupPermError" :title="groupPermError" type="error" show-icon :closable="false" class="mt-3" /> <el-table v-else :data="groups" stripe border class="w-full">
<el-alert v-if="groupPermSuccess" :title="groupPermSuccess" type="success" show-icon :closable="false" class="mt-3" /> <template #empty><el-empty description="目前無群組" /></template>
</div> <el-table-column prop="group_key" label="Group Key" width="180" />
</el-tab-pane> <el-table-column prop="name" label="群組名稱" min-width="220" />
</el-tabs> <el-table-column prop="status" label="狀態" width="120" />
<el-table-column label="操作" width="220">
<template #default="{ row }">
<el-button size="small" @click="openEditGroup(row)">編輯</el-button>
<el-button size="small" @click="openBindingDialog(row)">設定關聯</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="showBindingDialog" :title="`群組關聯設定:${bindingGroupKey}`" width="980px">
<el-form :model="bindingForm" label-width="130px">
<el-form-item label="站台(公司/站台)">
<el-select v-model="bindingForm.site_keys" multiple filterable clearable style="width: 100%" placeholder="選擇站台">
<el-option v-for="s in siteOptions" :key="s.value" :label="s.label" :value="s.value" />
</el-select>
</el-form-item>
<el-form-item label="系統(多選)">
<el-select v-model="bindingForm.system_keys" multiple filterable clearable style="width: 100%" placeholder="選擇系統">
<el-option v-for="s in systems" :key="s.system_key" :label="`${s.name} (${s.system_key})`" :value="s.system_key" />
</el-select>
</el-form-item>
<el-form-item label="模組(多選)">
<el-select v-model="bindingForm.module_keys" multiple filterable clearable style="width: 100%" placeholder="選擇模組(可空,空值代表系統層)">
<el-option
v-for="m in filteredModuleOptions"
:key="m.module_key"
:label="`${m.name} (${m.module_key})`"
:value="m.module_key"
/>
</el-select>
</el-form-item>
<el-form-item label="會員(多選)">
<el-select v-model="bindingForm.member_subs" multiple filterable clearable style="width: 100%" placeholder="選擇會員">
<el-option
v-for="m in members"
:key="m.authentik_sub"
:label="`${m.display_name || m.email || '(no-name)'} (${m.authentik_sub})`"
:value="m.authentik_sub"
/>
</el-select>
</el-form-item>
<el-form-item label="操作(多選)">
<el-select v-model="bindingForm.actions" multiple style="width: 100%" placeholder="選擇操作">
<el-option label="view" value="view" />
<el-option label="edit" value="edit" />
</el-select>
</el-form-item>
</el-form>
<el-alert
title="規則scope 固定為 siteaction 只允許 view/edit可同時選"
type="info"
:closable="false"
class="mb-4"
/>
<el-table :data="bindingPreview" border stripe v-loading="bindingLoading" max-height="260">
<template #empty><el-empty description="目前沒有授權規則" /></template>
<el-table-column prop="scope_display" label="公司/站台" min-width="220" />
<el-table-column prop="system" label="系統" width="120" />
<el-table-column prop="module" label="模組" min-width="180" />
<el-table-column prop="action" label="操作" width="100" />
</el-table>
<template #footer>
<el-button @click="showBindingDialog = false">取消</el-button>
<el-button type="primary" :loading="savingBinding" @click="saveBindings">儲存關聯</el-button>
</template>
</el-dialog>
<!-- Create Group Dialog -->
<el-dialog v-model="showCreateGroup" title="新增群組" @close="resetCreateForm"> <el-dialog v-model="showCreateGroup" title="新增群組" @close="resetCreateForm">
<el-form :model="createForm" label-width="120px"> <el-form :model="createForm" label-width="120px">
<el-form-item label="Group Key"> <el-form-item label="Group Key">
@@ -135,28 +126,11 @@
<el-button type="primary" :loading="savingGroup" @click="handleEditGroup">儲存</el-button> <el-button type="primary" :loading="savingGroup" @click="handleEditGroup">儲存</el-button>
</template> </template>
</el-dialog> </el-dialog>
<el-dialog v-model="showPermissionsDialog" title="群組權限列表" width="900px">
<div class="mb-3 text-sm text-gray-600">
Group: <span class="font-medium">{{ selectedGroupKey }}</span>
</div>
<el-table :data="selectedGroupPermissions" border stripe v-loading="loadingGroupPermissions">
<template #empty><el-empty description="此群組目前沒有權限" /></template>
<el-table-column prop="scope_type" label="Scope" width="100" />
<el-table-column prop="scope_id" label="Scope ID" min-width="140" />
<el-table-column prop="system" label="系統" width="120" />
<el-table-column prop="module" label="模組" width="180" />
<el-table-column prop="action" label="操作" width="120" />
</el-table>
<template #footer>
<el-button @click="showPermissionsDialog = false">關閉</el-button>
</template>
</el-dialog>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, reactive, onMounted, computed, watch } from 'vue' import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { Plus } from '@element-plus/icons-vue' import { Plus } from '@element-plus/icons-vue'
import { import {
@@ -164,46 +138,84 @@ import {
createPermissionGroup, createPermissionGroup,
updatePermissionGroup, updatePermissionGroup,
getPermissionGroupPermissions, getPermissionGroupPermissions,
groupGrant, getPermissionGroupBindings,
groupRevoke updatePermissionGroupBindings
} from '@/api/permission-groups' } from '@/api/permission-groups'
import { getSystems } from '@/api/systems' import { getSystems } from '@/api/systems'
import { getModules } from '@/api/modules' import { getModules } from '@/api/modules'
import { getCompanies } from '@/api/companies'
import { getSites } from '@/api/sites' import { getSites } from '@/api/sites'
import { getCompanies } from '@/api/companies'
import { getMembers } from '@/api/members'
const activeTab = ref('groups')
const systems = ref([])
const modules = ref([])
const companies = ref([])
const sites = ref([])
const actionOptions = ['view', 'edit', 'manage', 'admin']
const filteredModuleOptions = computed(() => {
if (!groupPermForm.system) return []
return modules.value
.filter(m => m.system_key === groupPermForm.system && !m.module_key.endsWith('.__system__'))
.map(m => ({
value: m.module_key.startsWith(`${groupPermForm.system}.`)
? m.module_key.slice(groupPermForm.system.length + 1)
: m.module_key,
label: `${m.name} (${m.module_key})`
}))
})
const scopeOptions = computed(() => {
if (groupPermForm.scope_type === 'company') {
return companies.value.map(c => ({ value: c.company_key, label: `${c.name} (${c.company_key})` }))
}
if (groupPermForm.scope_type === 'site') {
return sites.value.map(s => ({ value: s.site_key, label: `${s.name} (${s.site_key})` }))
}
return []
})
// Groups
const groups = ref([]) const groups = ref([])
const loadingGroups = ref(false) const loadingGroups = ref(false)
const systems = ref([])
const modules = ref([])
const sites = ref([])
const companies = ref([])
const members = ref([])
const showCreateGroup = ref(false)
const creatingGroup = ref(false)
const createForm = reactive({ group_key: '', name: '' })
const showEditGroup = ref(false)
const savingGroup = ref(false)
const editGroupForm = reactive({ group_key: '', name: '', status: 'active' })
const showBindingDialog = ref(false)
const bindingGroupKey = ref('')
const bindingLoading = ref(false)
const savingBinding = ref(false)
const bindingPreview = ref([])
const bindingForm = reactive({
site_keys: [],
system_keys: [],
module_keys: [],
member_subs: [],
actions: ['view']
})
const companyLookup = computed(() => {
const map = {}
for (const c of companies.value) map[c.company_key] = c.name
return map
})
const siteOptions = computed(() => {
return sites.value.map(s => {
const companyName = companyLookup.value[s.company_key] || s.company_key
return {
value: s.site_key,
label: `${companyName}/${s.name} (${s.site_key})`
}
})
})
const filteredModuleOptions = computed(() => {
if (bindingForm.system_keys.length === 0) return modules.value
return modules.value.filter(m => bindingForm.system_keys.includes(m.system_key) && !m.module_key.endsWith('.__system__'))
})
function resetCreateForm() {
createForm.group_key = ''
createForm.name = ''
}
function resetEditGroupForm() {
editGroupForm.group_key = ''
editGroupForm.name = ''
editGroupForm.status = 'active'
}
function resetBindingForm() {
bindingForm.site_keys = []
bindingForm.system_keys = []
bindingForm.module_keys = []
bindingForm.member_subs = []
bindingForm.actions = ['view']
bindingPreview.value = []
}
async function loadGroups() { async function loadGroups() {
loadingGroups.value = true loadingGroups.value = true
@@ -211,10 +223,6 @@ async function loadGroups() {
const res = await getPermissionGroups() const res = await getPermissionGroups()
groups.value = res.data?.items || [] groups.value = res.data?.items || []
} catch (err) { } catch (err) {
if (err.response?.status === 422) {
ElMessage.error('缺少管理員 API 認證,請檢查前端 .env.development')
return
}
ElMessage.error('載入群組失敗') ElMessage.error('載入群組失敗')
} finally { } finally {
loadingGroups.value = false loadingGroups.value = false
@@ -222,29 +230,18 @@ async function loadGroups() {
} }
async function loadCatalogs() { async function loadCatalogs() {
const [systemsRes, modulesRes, companiesRes, sitesRes] = await Promise.all([ const [systemsRes, modulesRes, sitesRes, companiesRes, membersRes] = await Promise.all([
getSystems(), getSystems(),
getModules(), getModules(),
getSites(),
getCompanies(), getCompanies(),
getSites() getMembers()
]) ])
systems.value = systemsRes.data?.items || [] systems.value = systemsRes.data?.items || []
modules.value = modulesRes.data?.items || [] modules.value = modulesRes.data?.items || []
companies.value = companiesRes.data?.items || []
sites.value = sitesRes.data?.items || [] sites.value = sitesRes.data?.items || []
} companies.value = companiesRes.data?.items || []
members.value = membersRes.data?.items || []
// Create Group
const showCreateGroup = ref(false)
const creatingGroup = ref(false)
const createForm = reactive({ group_key: '', name: '' })
const showEditGroup = ref(false)
const savingGroup = ref(false)
const editGroupForm = reactive({ group_key: '', name: '', status: 'active' })
function resetCreateForm() {
createForm.group_key = ''
createForm.name = ''
} }
async function handleCreateGroup() { async function handleCreateGroup() {
@@ -273,12 +270,6 @@ function openEditGroup(row) {
showEditGroup.value = true showEditGroup.value = true
} }
function resetEditGroupForm() {
editGroupForm.group_key = ''
editGroupForm.name = ''
editGroupForm.status = 'active'
}
async function handleEditGroup() { async function handleEditGroup() {
savingGroup.value = true savingGroup.value = true
try { try {
@@ -296,77 +287,74 @@ async function handleEditGroup() {
} }
} }
const showPermissionsDialog = ref(false) async function openBindingDialog(row) {
const loadingGroupPermissions = ref(false) bindingGroupKey.value = row.group_key
const selectedGroupPermissions = ref([]) showBindingDialog.value = true
const selectedGroupKey = ref('') bindingLoading.value = true
resetBindingForm()
async function openPermissionsDialog(row) {
selectedGroupKey.value = row.group_key
showPermissionsDialog.value = true
loadingGroupPermissions.value = true
try { try {
const res = await getPermissionGroupPermissions(row.group_key) const [bindingsRes, previewRes] = await Promise.all([
selectedGroupPermissions.value = res.data?.items || [] getPermissionGroupBindings(row.group_key),
getPermissionGroupPermissions(row.group_key)
])
const data = bindingsRes.data || {}
bindingForm.site_keys = data.site_keys || []
bindingForm.system_keys = data.system_keys || []
bindingForm.module_keys = data.module_keys || []
bindingForm.member_subs = data.member_subs || []
bindingForm.actions = (data.actions && data.actions.length > 0) ? data.actions : ['view']
bindingPreview.value = toPreview(previewRes.data?.items || [])
} catch (err) { } catch (err) {
ElMessage.error('載入群組權限失敗') ElMessage.error('載入群組關聯失敗')
} finally { } finally {
loadingGroupPermissions.value = false bindingLoading.value = false
} }
} }
// Group Grant/Revoke function toPreview(items) {
const groupPermForm = reactive({ const siteLabelMap = {}
groupKey: '', for (const s of siteOptions.value) siteLabelMap[s.value] = s.label
scope_type: '', return items.map(i => ({
scope_id: '', ...i,
system: '', module: i.module || '(系統層)',
module: '', scope_display: siteLabelMap[i.scope_id] || i.scope_id
action: '' }))
})
const grantingGroupPerm = ref(false)
const revokingGroupPerm = ref(false)
const groupPermError = ref('')
const groupPermSuccess = ref('')
async function handleGroupGrant() {
groupPermError.value = ''
groupPermSuccess.value = ''
grantingGroupPerm.value = true
try {
const { groupKey, ...permData } = groupPermForm
await groupGrant(groupKey, permData)
groupPermSuccess.value = 'Grant 成功'
} catch (err) {
groupPermError.value = 'Grant 失敗'
} finally {
grantingGroupPerm.value = false
}
} }
async function handleGroupRevoke() { async function saveBindings() {
groupPermError.value = '' if (!bindingGroupKey.value) return
groupPermSuccess.value = '' if (bindingForm.site_keys.length === 0) {
revokingGroupPerm.value = true ElMessage.warning('至少需要選擇 1 個站台')
return
}
if (bindingForm.system_keys.length === 0 && bindingForm.module_keys.length === 0) {
ElMessage.warning('至少需要選擇 1 個系統或模組')
return
}
if (bindingForm.actions.length === 0) {
ElMessage.warning('至少需要選擇 1 個操作')
return
}
savingBinding.value = true
try { try {
const { groupKey, ...permData } = groupPermForm await updatePermissionGroupBindings(bindingGroupKey.value, {
await groupRevoke(groupKey, permData) site_keys: bindingForm.site_keys,
groupPermSuccess.value = 'Revoke 成功' system_keys: bindingForm.system_keys,
module_keys: bindingForm.module_keys,
member_subs: bindingForm.member_subs,
actions: bindingForm.actions
})
const previewRes = await getPermissionGroupPermissions(bindingGroupKey.value)
bindingPreview.value = toPreview(previewRes.data?.items || [])
ElMessage.success('群組關聯已更新')
} catch (err) { } catch (err) {
groupPermError.value = 'Revoke 失敗' ElMessage.error(err.response?.data?.detail || '更新失敗')
} finally { } finally {
revokingGroupPerm.value = false savingBinding.value = false
} }
} }
watch(() => groupPermForm.scope_type, () => {
groupPermForm.scope_id = ''
})
watch(() => groupPermForm.system, () => {
groupPermForm.module = ''
})
onMounted(async () => { onMounted(async () => {
await Promise.all([loadGroups(), loadCatalogs()]) await Promise.all([loadGroups(), loadCatalogs()])
}) })

View File

@@ -21,9 +21,11 @@
<el-table-column prop="system_key" label="System Key" width="200" /> <el-table-column prop="system_key" label="System Key" width="200" />
<el-table-column prop="name" label="名稱" min-width="180" /> <el-table-column prop="name" label="名稱" min-width="180" />
<el-table-column prop="status" label="狀態" width="120" /> <el-table-column prop="status" label="狀態" width="120" />
<el-table-column label="操作" width="120"> <el-table-column label="操作" width="260">
<template #default="{ row }"> <template #default="{ row }">
<el-button size="small" @click="openEdit(row)">編輯</el-button> <el-button size="small" @click="openEdit(row)">編輯</el-button>
<el-button size="small" @click="openRelations(row, 'groups')">群組</el-button>
<el-button size="small" @click="openRelations(row, 'members')">會員</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -63,6 +65,33 @@
<el-button type="primary" :loading="savingEdit" @click="handleEdit">儲存</el-button> <el-button type="primary" :loading="savingEdit" @click="handleEdit">儲存</el-button>
</template> </template>
</el-dialog> </el-dialog>
<el-dialog v-model="showRelationDialog" :title="`系統關聯:${relationSystemKey}`" width="900px">
<el-tabs v-model="relationTab">
<el-tab-pane label="所屬群組" name="groups">
<el-table :data="relationGroups" border stripe v-loading="relationLoading">
<template #empty><el-empty description="尚無關聯群組" /></template>
<el-table-column prop="group_key" label="Group Key" width="220" />
<el-table-column prop="group_name" label="名稱" min-width="220" />
<el-table-column prop="status" label="狀態" width="120" />
</el-table>
</el-tab-pane>
<el-tab-pane label="涉及會員" name="members">
<el-table :data="relationMembers" border stripe v-loading="relationLoading">
<template #empty><el-empty description="尚無關聯會員" /></template>
<el-table-column prop="authentik_sub" label="Authentik Sub" min-width="260" />
<el-table-column prop="email" label="Email" min-width="220" />
<el-table-column prop="display_name" label="顯示名稱" min-width="160" />
<el-table-column label="啟用" width="80">
<template #default="{ row }">{{ row.is_active ? '是' : '否' }}</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
<template #footer>
<el-button @click="showRelationDialog = false">關閉</el-button>
</template>
</el-dialog>
</div> </div>
</template> </template>
@@ -70,7 +99,7 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { Plus } from '@element-plus/icons-vue' import { Plus } from '@element-plus/icons-vue'
import { getSystems, createSystem, updateSystem } from '@/api/systems' import { getSystems, createSystem, updateSystem, getSystemGroups, getSystemMembers } from '@/api/systems'
const systems = ref([]) const systems = ref([])
const loading = ref(false) const loading = ref(false)
@@ -89,6 +118,13 @@ const rules = {
name: [{ required: true, message: '請輸入名稱', trigger: 'blur' }] name: [{ required: true, message: '請輸入名稱', trigger: 'blur' }]
} }
const showRelationDialog = ref(false)
const relationLoading = ref(false)
const relationSystemKey = ref('')
const relationTab = ref('groups')
const relationGroups = ref([])
const relationMembers = ref([])
async function load() { async function load() {
loading.value = true loading.value = true
error.value = false error.value = false
@@ -156,5 +192,24 @@ async function handleEdit() {
} }
} }
async function openRelations(row, tab) {
relationSystemKey.value = row.system_key
relationTab.value = tab
showRelationDialog.value = true
relationLoading.value = true
try {
const [groupsRes, membersRes] = await Promise.all([
getSystemGroups(row.system_key),
getSystemMembers(row.system_key)
])
relationGroups.value = groupsRes.data?.items || []
relationMembers.value = membersRes.data?.items || []
} catch (err) {
ElMessage.error('載入系統關聯資料失敗')
} finally {
relationLoading.value = false
}
}
onMounted(load) onMounted(load)
</script> </script>

View File

@@ -27,7 +27,6 @@
</el-form-item> </el-form-item>
<el-form-item label="Scope 類型" prop="scope_type"> <el-form-item label="Scope 類型" prop="scope_type">
<el-select v-model="grantForm.scope_type" placeholder="選擇 Scope 類型"> <el-select v-model="grantForm.scope_type" placeholder="選擇 Scope 類型">
<el-option label="Company" value="company" />
<el-option label="Site" value="site" /> <el-option label="Site" value="site" />
</el-select> </el-select>
</el-form-item> </el-form-item>
@@ -99,7 +98,6 @@
</el-form-item> </el-form-item>
<el-form-item label="Scope 類型" prop="scope_type"> <el-form-item label="Scope 類型" prop="scope_type">
<el-select v-model="revokeForm.scope_type" placeholder="選擇 Scope 類型"> <el-select v-model="revokeForm.scope_type" placeholder="選擇 Scope 類型">
<el-option label="Company" value="company" />
<el-option label="Site" value="site" /> <el-option label="Site" value="site" />
</el-select> </el-select>
</el-form-item> </el-form-item>
@@ -162,7 +160,6 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<el-input v-model="listFilters.keyword" placeholder="搜尋 email/sub/module/action" clearable style="width: 280px" @keyup.enter="loadDirectPermissionList" /> <el-input v-model="listFilters.keyword" placeholder="搜尋 email/sub/module/action" clearable style="width: 280px" @keyup.enter="loadDirectPermissionList" />
<el-select v-model="listFilters.scope_type" clearable placeholder="Scope" style="width: 140px"> <el-select v-model="listFilters.scope_type" clearable placeholder="Scope" style="width: 140px">
<el-option label="Company" value="company" />
<el-option label="Site" value="site" /> <el-option label="Site" value="site" />
</el-select> </el-select>
<el-button :loading="listLoading" @click="loadDirectPermissionList">查詢</el-button> <el-button :loading="listLoading" @click="loadDirectPermissionList">查詢</el-button>
@@ -210,7 +207,7 @@ const modules = ref([])
const companies = ref([]) const companies = ref([])
const sites = ref([]) const sites = ref([])
const members = ref([]) const members = ref([])
const actionOptions = ['view', 'edit', 'manage', 'admin'] const actionOptions = ['view', 'edit']
const listFilters = reactive({ keyword: '', scope_type: '' }) const listFilters = reactive({ keyword: '', scope_type: '' })
const listLoading = ref(false) const listLoading = ref(false)
const directPermissions = ref([]) const directPermissions = ref([])
@@ -246,9 +243,6 @@ const grantModuleOptions = computed(() => {
}) })
const grantScopeOptions = computed(() => { const grantScopeOptions = computed(() => {
if (grantForm.scope_type === 'company') {
return companies.value.map(c => ({ value: c.company_key, label: `${c.name} (${c.company_key})` }))
}
if (grantForm.scope_type === 'site') { if (grantForm.scope_type === 'site') {
return sites.value.map(s => ({ value: s.site_key, label: `${s.name} (${s.site_key})` })) return sites.value.map(s => ({ value: s.site_key, label: `${s.name} (${s.site_key})` }))
} }
@@ -318,9 +312,6 @@ const revokeModuleOptions = computed(() => {
}) })
const revokeScopeOptions = computed(() => { const revokeScopeOptions = computed(() => {
if (revokeForm.scope_type === 'company') {
return companies.value.map(c => ({ value: c.company_key, label: `${c.name} (${c.company_key})` }))
}
if (revokeForm.scope_type === 'site') { if (revokeForm.scope_type === 'site') {
return sites.value.map(s => ({ value: s.site_key, label: `${s.name} (${s.site_key})` })) return sites.value.map(s => ({ value: s.site_key, label: `${s.name} (${s.site_key})` }))
} }