feat(admin): add edit flows for all catalogs and member authentik sync
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.db.session import get_db
|
||||
from app.models.api_client import ApiClient
|
||||
from app.repositories.companies_repo import CompaniesRepository
|
||||
@@ -12,18 +13,26 @@ from app.repositories.users_repo import UsersRepository
|
||||
from app.schemas.catalog import (
|
||||
CompanyCreateRequest,
|
||||
CompanyItem,
|
||||
CompanyUpdateRequest,
|
||||
MemberItem,
|
||||
MemberUpdateRequest,
|
||||
MemberUpsertRequest,
|
||||
ModuleCreateRequest,
|
||||
ModuleItem,
|
||||
ModuleUpdateRequest,
|
||||
PermissionGroupCreateRequest,
|
||||
PermissionGroupItem,
|
||||
PermissionGroupUpdateRequest,
|
||||
SiteCreateRequest,
|
||||
SiteItem,
|
||||
SiteUpdateRequest,
|
||||
SystemCreateRequest,
|
||||
SystemItem,
|
||||
SystemUpdateRequest,
|
||||
)
|
||||
from app.schemas.permissions import PermissionGrantRequest, PermissionRevokeRequest
|
||||
from app.security.api_client_auth import require_api_client
|
||||
from app.services.authentik_admin_service import AuthentikAdminService
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
@@ -57,6 +66,26 @@ 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")
|
||||
|
||||
|
||||
def _sync_member_to_authentik(
|
||||
*,
|
||||
authentik_sub: str,
|
||||
email: str | None,
|
||||
display_name: str | None,
|
||||
is_active: bool,
|
||||
) -> dict[str, str | int]:
|
||||
if not email:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="email_required_for_authentik_sync")
|
||||
settings = get_settings()
|
||||
service = AuthentikAdminService(settings=settings)
|
||||
result = service.ensure_user(
|
||||
sub=authentik_sub,
|
||||
email=email,
|
||||
display_name=display_name,
|
||||
is_active=is_active,
|
||||
)
|
||||
return {"authentik_user_id": result.user_id, "sync_action": result.action}
|
||||
|
||||
|
||||
@router.get("/systems")
|
||||
def list_systems(
|
||||
_: ApiClient = Depends(require_api_client),
|
||||
@@ -82,6 +111,21 @@ def create_system(
|
||||
return SystemItem(id=row.id, system_key=row.system_key, name=row.name, status=row.status)
|
||||
|
||||
|
||||
@router.patch("/systems/{system_key}", response_model=SystemItem)
|
||||
def update_system(
|
||||
system_key: str,
|
||||
payload: SystemUpdateRequest,
|
||||
_: ApiClient = Depends(require_api_client),
|
||||
db: Session = Depends(get_db),
|
||||
) -> SystemItem:
|
||||
repo = SystemsRepository(db)
|
||||
row = repo.get_by_key(system_key)
|
||||
if not row:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="system_not_found")
|
||||
row = repo.update(row, name=payload.name, status=payload.status)
|
||||
return SystemItem(id=row.id, system_key=row.system_key, name=row.name, status=row.status)
|
||||
|
||||
|
||||
@router.get("/modules")
|
||||
def list_modules(
|
||||
_: ApiClient = Depends(require_api_client),
|
||||
@@ -128,6 +172,22 @@ def create_module(
|
||||
return ModuleItem(id=row.id, system_key=payload.system_key, module_key=row.module_key, name=row.name, status=row.status)
|
||||
|
||||
|
||||
@router.patch("/modules/{module_key}")
|
||||
def update_module(
|
||||
module_key: str,
|
||||
payload: ModuleUpdateRequest,
|
||||
_: ApiClient = Depends(require_api_client),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ModuleItem:
|
||||
modules_repo = ModulesRepository(db)
|
||||
row = modules_repo.get_by_key(module_key)
|
||||
if not row:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="module_not_found")
|
||||
row = modules_repo.update(row, name=payload.name, status=payload.status)
|
||||
system_key = row.module_key.split(".", 1)[0] if "." in row.module_key else None
|
||||
return ModuleItem(id=row.id, system_key=system_key, module_key=row.module_key, name=row.name, status=row.status)
|
||||
|
||||
|
||||
@router.get("/companies")
|
||||
def list_companies(
|
||||
_: ApiClient = Depends(require_api_client),
|
||||
@@ -154,6 +214,21 @@ def create_company(
|
||||
return CompanyItem(id=row.id, company_key=row.company_key, name=row.name, status=row.status)
|
||||
|
||||
|
||||
@router.patch("/companies/{company_key}", response_model=CompanyItem)
|
||||
def update_company(
|
||||
company_key: str,
|
||||
payload: CompanyUpdateRequest,
|
||||
_: ApiClient = Depends(require_api_client),
|
||||
db: Session = Depends(get_db),
|
||||
) -> CompanyItem:
|
||||
repo = CompaniesRepository(db)
|
||||
row = repo.get_by_key(company_key)
|
||||
if not row:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="company_not_found")
|
||||
row = repo.update(row, name=payload.name, status=payload.status)
|
||||
return CompanyItem(id=row.id, company_key=row.company_key, name=row.name, status=row.status)
|
||||
|
||||
|
||||
@router.get("/sites")
|
||||
def list_sites(
|
||||
_: ApiClient = Depends(require_api_client),
|
||||
@@ -210,6 +285,33 @@ def create_site(
|
||||
return SiteItem(id=row.id, site_key=row.site_key, company_key=payload.company_key, name=row.name, status=row.status)
|
||||
|
||||
|
||||
@router.patch("/sites/{site_key}", response_model=SiteItem)
|
||||
def update_site(
|
||||
site_key: str,
|
||||
payload: SiteUpdateRequest,
|
||||
_: ApiClient = Depends(require_api_client),
|
||||
db: Session = Depends(get_db),
|
||||
) -> SiteItem:
|
||||
companies_repo = CompaniesRepository(db)
|
||||
sites_repo = SitesRepository(db)
|
||||
row = sites_repo.get_by_key(site_key)
|
||||
if not row:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="site_not_found")
|
||||
company_id = None
|
||||
company_key = None
|
||||
if payload.company_key is not None:
|
||||
company = companies_repo.get_by_key(payload.company_key)
|
||||
if not company:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="company_not_found")
|
||||
company_id = company.id
|
||||
company_key = company.company_key
|
||||
row = sites_repo.update(row, company_id=company_id, name=payload.name, status=payload.status)
|
||||
if company_key is None:
|
||||
current_company = companies_repo.get_by_id(row.company_id)
|
||||
company_key = current_company.company_key if current_company else ""
|
||||
return SiteItem(id=row.id, site_key=row.site_key, company_key=company_key, name=row.name, status=row.status)
|
||||
|
||||
|
||||
@router.get("/members")
|
||||
def list_members(
|
||||
_: ApiClient = Depends(require_api_client),
|
||||
@@ -223,6 +325,80 @@ def list_members(
|
||||
return {"items": [MemberItem(id=i.id, authentik_sub=i.authentik_sub, email=i.email, display_name=i.display_name, is_active=i.is_active).model_dump() for i in items], "total": total, "limit": limit, "offset": offset}
|
||||
|
||||
|
||||
@router.post("/members/upsert", response_model=MemberItem)
|
||||
def upsert_member(
|
||||
payload: MemberUpsertRequest,
|
||||
_: ApiClient = Depends(require_api_client),
|
||||
db: Session = Depends(get_db),
|
||||
) -> MemberItem:
|
||||
users_repo = UsersRepository(db)
|
||||
authentik_user_id = None
|
||||
if payload.sync_to_authentik:
|
||||
sync = _sync_member_to_authentik(
|
||||
authentik_sub=payload.authentik_sub,
|
||||
email=payload.email,
|
||||
display_name=payload.display_name,
|
||||
is_active=payload.is_active,
|
||||
)
|
||||
authentik_user_id = int(sync["authentik_user_id"])
|
||||
row = users_repo.upsert_by_sub(
|
||||
authentik_sub=payload.authentik_sub,
|
||||
email=payload.email,
|
||||
display_name=payload.display_name,
|
||||
is_active=payload.is_active,
|
||||
authentik_user_id=authentik_user_id,
|
||||
)
|
||||
return MemberItem(
|
||||
id=row.id,
|
||||
authentik_sub=row.authentik_sub,
|
||||
email=row.email,
|
||||
display_name=row.display_name,
|
||||
is_active=row.is_active,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/members/{authentik_sub}", response_model=MemberItem)
|
||||
def update_member(
|
||||
authentik_sub: str,
|
||||
payload: MemberUpdateRequest,
|
||||
_: ApiClient = Depends(require_api_client),
|
||||
db: Session = Depends(get_db),
|
||||
) -> MemberItem:
|
||||
users_repo = UsersRepository(db)
|
||||
row = users_repo.get_by_sub(authentik_sub)
|
||||
if not row:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user_not_found")
|
||||
|
||||
next_email = payload.email if payload.email is not None else row.email
|
||||
next_display_name = payload.display_name if payload.display_name is not None else row.display_name
|
||||
next_is_active = payload.is_active if payload.is_active is not None else row.is_active
|
||||
|
||||
authentik_user_id = row.authentik_user_id
|
||||
if payload.sync_to_authentik:
|
||||
sync = _sync_member_to_authentik(
|
||||
authentik_sub=row.authentik_sub,
|
||||
email=next_email,
|
||||
display_name=next_display_name,
|
||||
is_active=next_is_active,
|
||||
)
|
||||
authentik_user_id = int(sync["authentik_user_id"])
|
||||
|
||||
row = users_repo.upsert_by_sub(
|
||||
authentik_sub=row.authentik_sub,
|
||||
email=next_email,
|
||||
display_name=next_display_name,
|
||||
is_active=next_is_active,
|
||||
authentik_user_id=authentik_user_id,
|
||||
)
|
||||
return MemberItem(
|
||||
id=row.id,
|
||||
authentik_sub=row.authentik_sub,
|
||||
email=row.email,
|
||||
display_name=row.display_name,
|
||||
is_active=row.is_active,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/permission-groups")
|
||||
def list_permission_groups(
|
||||
_: ApiClient = Depends(require_api_client),
|
||||
@@ -248,6 +424,21 @@ def create_permission_group(
|
||||
return PermissionGroupItem(id=row.id, group_key=row.group_key, name=row.name, status=row.status)
|
||||
|
||||
|
||||
@router.patch("/permission-groups/{group_key}", response_model=PermissionGroupItem)
|
||||
def update_permission_group(
|
||||
group_key: str,
|
||||
payload: PermissionGroupUpdateRequest,
|
||||
_: ApiClient = Depends(require_api_client),
|
||||
db: Session = Depends(get_db),
|
||||
) -> PermissionGroupItem:
|
||||
repo = PermissionGroupsRepository(db)
|
||||
row = repo.get_by_key(group_key)
|
||||
if not row:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="group_not_found")
|
||||
row = repo.update(row, name=payload.name, status=payload.status)
|
||||
return PermissionGroupItem(id=row.id, group_key=row.group_key, name=row.name, status=row.status)
|
||||
|
||||
|
||||
@router.post("/permission-groups/{group_key}/members/{authentik_sub}")
|
||||
def add_group_member(
|
||||
group_key: str,
|
||||
|
||||
@@ -33,3 +33,12 @@ class CompaniesRepository:
|
||||
self.db.commit()
|
||||
self.db.refresh(item)
|
||||
return item
|
||||
|
||||
def update(self, item: Company, *, name: str | None = None, status: str | None = None) -> Company:
|
||||
if name is not None:
|
||||
item.name = name
|
||||
if status is not None:
|
||||
item.status = status
|
||||
self.db.commit()
|
||||
self.db.refresh(item)
|
||||
return item
|
||||
|
||||
@@ -24,3 +24,12 @@ class ModulesRepository:
|
||||
self.db.commit()
|
||||
self.db.refresh(item)
|
||||
return item
|
||||
|
||||
def update(self, item: Module, *, name: str | None = None, status: str | None = None) -> Module:
|
||||
if name is not None:
|
||||
item.name = name
|
||||
if status is not None:
|
||||
item.status = status
|
||||
self.db.commit()
|
||||
self.db.refresh(item)
|
||||
return item
|
||||
|
||||
@@ -28,6 +28,15 @@ class PermissionGroupsRepository:
|
||||
self.db.refresh(item)
|
||||
return item
|
||||
|
||||
def update(self, item: PermissionGroup, *, name: str | None = None, status: str | None = None) -> PermissionGroup:
|
||||
if name is not None:
|
||||
item.name = name
|
||||
if status is not None:
|
||||
item.status = status
|
||||
self.db.commit()
|
||||
self.db.refresh(item)
|
||||
return item
|
||||
|
||||
def add_member_if_not_exists(self, group_id: str, authentik_sub: str) -> PermissionGroupMember:
|
||||
existing = self.db.scalar(
|
||||
select(PermissionGroupMember).where(
|
||||
|
||||
@@ -38,3 +38,21 @@ class SitesRepository:
|
||||
self.db.commit()
|
||||
self.db.refresh(item)
|
||||
return item
|
||||
|
||||
def update(
|
||||
self,
|
||||
item: Site,
|
||||
*,
|
||||
company_id: str | None = None,
|
||||
name: str | None = None,
|
||||
status: str | None = None,
|
||||
) -> Site:
|
||||
if company_id is not None:
|
||||
item.company_id = company_id
|
||||
if name is not None:
|
||||
item.name = name
|
||||
if status is not None:
|
||||
item.status = status
|
||||
self.db.commit()
|
||||
self.db.refresh(item)
|
||||
return item
|
||||
|
||||
@@ -31,3 +31,12 @@ class SystemsRepository:
|
||||
self.db.commit()
|
||||
self.db.refresh(item)
|
||||
return item
|
||||
|
||||
def update(self, item: System, *, name: str | None = None, status: str | None = None) -> System:
|
||||
if name is not None:
|
||||
item.name = name
|
||||
if status is not None:
|
||||
item.status = status
|
||||
self.db.commit()
|
||||
self.db.refresh(item)
|
||||
return item
|
||||
|
||||
@@ -7,6 +7,11 @@ class SystemCreateRequest(BaseModel):
|
||||
status: str = "active"
|
||||
|
||||
|
||||
class SystemUpdateRequest(BaseModel):
|
||||
name: str | None = None
|
||||
status: str | None = None
|
||||
|
||||
|
||||
class SystemItem(BaseModel):
|
||||
id: str
|
||||
system_key: str
|
||||
@@ -21,6 +26,11 @@ class ModuleCreateRequest(BaseModel):
|
||||
status: str = "active"
|
||||
|
||||
|
||||
class ModuleUpdateRequest(BaseModel):
|
||||
name: str | None = None
|
||||
status: str | None = None
|
||||
|
||||
|
||||
class ModuleItem(BaseModel):
|
||||
id: str
|
||||
system_key: str | None = None
|
||||
@@ -35,6 +45,11 @@ class CompanyCreateRequest(BaseModel):
|
||||
status: str = "active"
|
||||
|
||||
|
||||
class CompanyUpdateRequest(BaseModel):
|
||||
name: str | None = None
|
||||
status: str | None = None
|
||||
|
||||
|
||||
class CompanyItem(BaseModel):
|
||||
id: str
|
||||
company_key: str
|
||||
@@ -49,6 +64,12 @@ class SiteCreateRequest(BaseModel):
|
||||
status: str = "active"
|
||||
|
||||
|
||||
class SiteUpdateRequest(BaseModel):
|
||||
company_key: str | None = None
|
||||
name: str | None = None
|
||||
status: str | None = None
|
||||
|
||||
|
||||
class SiteItem(BaseModel):
|
||||
id: str
|
||||
site_key: str
|
||||
@@ -65,6 +86,21 @@ class MemberItem(BaseModel):
|
||||
is_active: bool
|
||||
|
||||
|
||||
class MemberUpsertRequest(BaseModel):
|
||||
authentik_sub: str
|
||||
email: str | None = None
|
||||
display_name: str | None = None
|
||||
is_active: bool = True
|
||||
sync_to_authentik: bool = True
|
||||
|
||||
|
||||
class MemberUpdateRequest(BaseModel):
|
||||
email: str | None = None
|
||||
display_name: str | None = None
|
||||
is_active: bool | None = None
|
||||
sync_to_authentik: bool = True
|
||||
|
||||
|
||||
class ListResponse(BaseModel):
|
||||
items: list
|
||||
total: int
|
||||
@@ -78,6 +114,11 @@ class PermissionGroupCreateRequest(BaseModel):
|
||||
status: str = "active"
|
||||
|
||||
|
||||
class PermissionGroupUpdateRequest(BaseModel):
|
||||
name: str | None = None
|
||||
status: str | None = None
|
||||
|
||||
|
||||
class PermissionGroupItem(BaseModel):
|
||||
id: str
|
||||
group_key: str
|
||||
|
||||
@@ -2,3 +2,4 @@ import { adminHttp } from './http'
|
||||
|
||||
export const getCompanies = () => adminHttp.get('/admin/companies')
|
||||
export const createCompany = (data) => adminHttp.post('/admin/companies', data)
|
||||
export const updateCompany = (companyKey, data) => adminHttp.patch(`/admin/companies/${companyKey}`, data)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { adminHttp } from './http'
|
||||
|
||||
export const getMembers = () => adminHttp.get('/admin/members')
|
||||
export const upsertMember = (data) => adminHttp.post('/admin/members/upsert', data)
|
||||
export const updateMember = (authentikSub, data) => adminHttp.patch(`/admin/members/${authentikSub}`, data)
|
||||
|
||||
@@ -2,3 +2,4 @@ import { adminHttp } from './http'
|
||||
|
||||
export const getModules = () => adminHttp.get('/admin/modules')
|
||||
export const createModule = (data) => adminHttp.post('/admin/modules', data)
|
||||
export const updateModule = (moduleKey, data) => adminHttp.patch(`/admin/modules/${moduleKey}`, data)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { adminHttp } from './http'
|
||||
|
||||
export const getPermissionGroups = () => adminHttp.get('/admin/permission-groups')
|
||||
export const createPermissionGroup = (data) => adminHttp.post('/admin/permission-groups', data)
|
||||
export const updatePermissionGroup = (groupKey, data) => adminHttp.patch(`/admin/permission-groups/${groupKey}`, data)
|
||||
|
||||
export const addMemberToGroup = (groupKey, authentikSub) =>
|
||||
adminHttp.post(`/admin/permission-groups/${groupKey}/members/${authentikSub}`)
|
||||
|
||||
@@ -2,3 +2,4 @@ import { adminHttp } from './http'
|
||||
|
||||
export const getSites = () => adminHttp.get('/admin/sites')
|
||||
export const createSite = (data) => adminHttp.post('/admin/sites', data)
|
||||
export const updateSite = (siteKey, data) => adminHttp.patch(`/admin/sites/${siteKey}`, data)
|
||||
|
||||
@@ -2,3 +2,4 @@ import { adminHttp } from './http'
|
||||
|
||||
export const getSystems = () => adminHttp.get('/admin/systems')
|
||||
export const createSystem = (data) => adminHttp.post('/admin/systems', data)
|
||||
export const updateSystem = (systemKey, data) => adminHttp.patch(`/admin/systems/${systemKey}`, data)
|
||||
|
||||
@@ -5,38 +5,48 @@
|
||||
<el-button type="primary" @click="showDialog = true" :icon="Plus">新增公司</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="error"
|
||||
:title="errorMsg"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<el-alert v-if="error" :title="errorMsg" type="error" show-icon :closable="false" class="mb-4" />
|
||||
<el-skeleton v-if="loading" :rows="4" animated />
|
||||
|
||||
<el-table v-else :data="companies" stripe border class="w-full shadow-sm">
|
||||
<template #empty><el-empty description="目前無公司" /></template>
|
||||
<el-table-column prop="company_key" label="Company Key" width="200" />
|
||||
<el-table-column prop="name" label="名稱" min-width="180" />
|
||||
<el-table-column prop="company_key" label="Company Key" width="220" />
|
||||
<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="openEdit(row)">編輯</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 新增 Dialog -->
|
||||
<el-dialog v-model="showDialog" title="新增公司" @close="resetForm">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
|
||||
<el-form-item label="Company Key" prop="company_key">
|
||||
<el-input v-model="form.company_key" placeholder="company-001" />
|
||||
</el-form-item>
|
||||
<el-form-item label="名稱" prop="name">
|
||||
<el-input v-model="form.name" placeholder="公司名稱" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Company Key" prop="company_key"><el-input v-model="form.company_key" /></el-form-item>
|
||||
<el-form-item label="名稱" prop="name"><el-input v-model="form.name" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleCreate">確認</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="showEditDialog" title="編輯公司" @close="resetEditForm">
|
||||
<el-form :model="editForm" label-width="100px">
|
||||
<el-form-item label="Company Key"><el-input :model-value="editForm.company_key" disabled /></el-form-item>
|
||||
<el-form-item label="名稱"><el-input v-model="editForm.name" /></el-form-item>
|
||||
<el-form-item label="狀態">
|
||||
<el-select v-model="editForm.status" style="width: 100%">
|
||||
<el-option label="active" value="active" />
|
||||
<el-option label="inactive" value="inactive" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showEditDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="savingEdit" @click="handleEdit">儲存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -44,7 +54,7 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { getCompanies, createCompany } from '@/api/companies'
|
||||
import { getCompanies, createCompany, updateCompany } from '@/api/companies'
|
||||
|
||||
const companies = ref([])
|
||||
const loading = ref(false)
|
||||
@@ -52,9 +62,12 @@ const error = ref(false)
|
||||
const errorMsg = ref('')
|
||||
const showDialog = ref(false)
|
||||
const submitting = ref(false)
|
||||
const showEditDialog = ref(false)
|
||||
const savingEdit = ref(false)
|
||||
const formRef = ref()
|
||||
|
||||
const form = ref({ company_key: '', name: '' })
|
||||
const editForm = ref({ company_key: '', name: '', status: 'active' })
|
||||
const rules = {
|
||||
company_key: [{ required: true, message: '請輸入 Company Key', trigger: 'blur' }],
|
||||
name: [{ required: true, message: '請輸入名稱', trigger: 'blur' }]
|
||||
@@ -80,6 +93,15 @@ function resetForm() {
|
||||
form.value = { company_key: '', name: '' }
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
editForm.value = { company_key: row.company_key, name: row.name, status: row.status || 'active' }
|
||||
showEditDialog.value = true
|
||||
}
|
||||
|
||||
function resetEditForm() {
|
||||
editForm.value = { company_key: '', name: '', status: 'active' }
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
const valid = await formRef.value.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
@@ -91,11 +113,25 @@ async function handleCreate() {
|
||||
resetForm()
|
||||
await load()
|
||||
} catch (err) {
|
||||
ElMessage.error('新增失敗,請稍後再試')
|
||||
ElMessage.error('新增失敗')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEdit() {
|
||||
savingEdit.value = true
|
||||
try {
|
||||
await updateCompany(editForm.value.company_key, { name: editForm.value.name, status: editForm.value.status })
|
||||
ElMessage.success('更新成功')
|
||||
showEditDialog.value = false
|
||||
await load()
|
||||
} catch (err) {
|
||||
ElMessage.error('更新失敗')
|
||||
} finally {
|
||||
savingEdit.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
@@ -2,39 +2,95 @@
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-bold text-gray-800">會員列表</h2>
|
||||
<div class="flex gap-2">
|
||||
<el-button type="primary" @click="showCreateDialog = true">新增會員</el-button>
|
||||
<el-button :loading="loading" @click="load" :icon="Refresh" size="small">重新整理</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="error"
|
||||
:title="errorMsg"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<el-alert v-if="error" :title="errorMsg" type="error" show-icon :closable="false" class="mb-4" />
|
||||
<el-skeleton v-if="loading" :rows="4" animated />
|
||||
|
||||
<el-table v-else :data="members" stripe border class="w-full shadow-sm">
|
||||
<template #empty><el-empty description="目前無會員" /></template>
|
||||
<el-table-column prop="authentik_sub" label="Authentik Sub" min-width="240" show-overflow-tooltip />
|
||||
<el-table-column prop="email" label="Email" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="display_name" label="顯示名稱" width="150" />
|
||||
<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="180" />
|
||||
<el-table-column prop="is_active" label="啟用" width="100">
|
||||
<template #default="{ row }">{{ row.is_active ? '是' : '否' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="openEdit(row)">編輯</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="showCreateDialog" title="新增會員" @close="resetCreateForm">
|
||||
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="120px">
|
||||
<el-form-item label="Authentik Sub" prop="authentik_sub"><el-input v-model="createForm.authentik_sub" /></el-form-item>
|
||||
<el-form-item label="Email" prop="email"><el-input v-model="createForm.email" /></el-form-item>
|
||||
<el-form-item label="顯示名稱" prop="display_name"><el-input v-model="createForm.display_name" /></el-form-item>
|
||||
<el-form-item label="啟用"><el-switch v-model="createForm.is_active" /></el-form-item>
|
||||
<el-form-item label="同步 Authentik"><el-switch v-model="createForm.sync_to_authentik" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showCreateDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="creating" @click="handleCreate">建立</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="showEditDialog" title="編輯會員" @close="resetEditForm">
|
||||
<el-form :model="editForm" label-width="120px">
|
||||
<el-form-item label="Authentik Sub"><el-input :model-value="editForm.authentik_sub" disabled /></el-form-item>
|
||||
<el-form-item label="Email"><el-input v-model="editForm.email" /></el-form-item>
|
||||
<el-form-item label="顯示名稱"><el-input v-model="editForm.display_name" /></el-form-item>
|
||||
<el-form-item label="啟用"><el-switch v-model="editForm.is_active" /></el-form-item>
|
||||
<el-form-item label="同步 Authentik"><el-switch v-model="editForm.sync_to_authentik" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showEditDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleEdit">儲存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { getMembers } from '@/api/members'
|
||||
import { getMembers, upsertMember, updateMember } from '@/api/members'
|
||||
|
||||
const members = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(false)
|
||||
const errorMsg = ref('')
|
||||
|
||||
const showCreateDialog = ref(false)
|
||||
const createFormRef = ref()
|
||||
const creating = ref(false)
|
||||
const createForm = ref({
|
||||
authentik_sub: '',
|
||||
email: '',
|
||||
display_name: '',
|
||||
is_active: true,
|
||||
sync_to_authentik: true
|
||||
})
|
||||
const createRules = {
|
||||
authentik_sub: [{ required: true, message: '請輸入 Authentik Sub', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const showEditDialog = ref(false)
|
||||
const saving = ref(false)
|
||||
const editForm = ref({
|
||||
authentik_sub: '',
|
||||
email: '',
|
||||
display_name: '',
|
||||
is_active: true,
|
||||
sync_to_authentik: true
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = false
|
||||
@@ -43,13 +99,80 @@ async function load() {
|
||||
members.value = res.data?.items || []
|
||||
} catch (err) {
|
||||
error.value = true
|
||||
errorMsg.value = err.response?.status === 422
|
||||
? '缺少管理員 API 認證,請檢查前端 .env.development'
|
||||
: '載入失敗,請稍後再試'
|
||||
errorMsg.value = err.response?.data?.detail || '載入失敗,請稍後再試'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetCreateForm() {
|
||||
createForm.value = {
|
||||
authentik_sub: '',
|
||||
email: '',
|
||||
display_name: '',
|
||||
is_active: true,
|
||||
sync_to_authentik: true
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
editForm.value = {
|
||||
authentik_sub: row.authentik_sub,
|
||||
email: row.email || '',
|
||||
display_name: row.display_name || '',
|
||||
is_active: !!row.is_active,
|
||||
sync_to_authentik: true
|
||||
}
|
||||
showEditDialog.value = true
|
||||
}
|
||||
|
||||
function resetEditForm() {
|
||||
editForm.value = {
|
||||
authentik_sub: '',
|
||||
email: '',
|
||||
display_name: '',
|
||||
is_active: true,
|
||||
sync_to_authentik: true
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
const valid = await createFormRef.value.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
creating.value = true
|
||||
try {
|
||||
await upsertMember({ ...createForm.value })
|
||||
ElMessage.success('新增會員成功')
|
||||
showCreateDialog.value = false
|
||||
resetCreateForm()
|
||||
await load()
|
||||
} catch (err) {
|
||||
const detail = err.response?.data?.detail
|
||||
ElMessage.error(detail || '新增會員失敗')
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEdit() {
|
||||
saving.value = true
|
||||
try {
|
||||
await updateMember(editForm.value.authentik_sub, {
|
||||
email: editForm.value.email || null,
|
||||
display_name: editForm.value.display_name || null,
|
||||
is_active: editForm.value.is_active,
|
||||
sync_to_authentik: editForm.value.sync_to_authentik
|
||||
})
|
||||
ElMessage.success('更新會員成功')
|
||||
showEditDialog.value = false
|
||||
await load()
|
||||
} catch (err) {
|
||||
const detail = err.response?.data?.detail
|
||||
ElMessage.error(detail || '更新會員失敗')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
@@ -18,12 +18,17 @@
|
||||
|
||||
<el-table v-else :data="modules" stripe border class="w-full shadow-sm">
|
||||
<template #empty><el-empty description="目前無模組" /></template>
|
||||
<el-table-column prop="system_key" label="System Key" width="140" />
|
||||
<el-table-column prop="module_key" label="Module Key" width="160" />
|
||||
<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="name" label="名稱" min-width="180" />
|
||||
<el-table-column prop="status" label="狀態" width="120" />
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="openEdit(row)">編輯</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 新增 Dialog -->
|
||||
<el-dialog v-model="showDialog" title="新增模組" @close="resetForm">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
|
||||
<el-form-item label="System Key" prop="system_key">
|
||||
@@ -41,6 +46,27 @@
|
||||
<el-button type="primary" :loading="submitting" @click="handleCreate">確認</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="showEditDialog" title="編輯模組" @close="resetEditForm">
|
||||
<el-form :model="editForm" label-width="120px">
|
||||
<el-form-item label="Module Key">
|
||||
<el-input :model-value="editForm.module_key" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="名稱">
|
||||
<el-input v-model="editForm.name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="狀態">
|
||||
<el-select v-model="editForm.status" style="width: 100%">
|
||||
<el-option label="active" value="active" />
|
||||
<el-option label="inactive" value="inactive" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showEditDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="savingEdit" @click="handleEdit">儲存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -48,7 +74,7 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { getModules, createModule } from '@/api/modules'
|
||||
import { getModules, createModule, updateModule } from '@/api/modules'
|
||||
|
||||
const modules = ref([])
|
||||
const loading = ref(false)
|
||||
@@ -57,8 +83,11 @@ const errorMsg = ref('')
|
||||
const showDialog = ref(false)
|
||||
const submitting = ref(false)
|
||||
const formRef = ref()
|
||||
const showEditDialog = ref(false)
|
||||
const savingEdit = ref(false)
|
||||
|
||||
const form = ref({ system_key: '', module_key: '', name: '' })
|
||||
const editForm = ref({ module_key: '', name: '', status: 'active' })
|
||||
const rules = {
|
||||
system_key: [{ required: true, message: '請輸入 System Key', trigger: 'blur' }],
|
||||
module_key: [{ required: true, message: '請輸入 Module Key', trigger: 'blur' }],
|
||||
@@ -85,6 +114,19 @@ function resetForm() {
|
||||
form.value = { system_key: '', module_key: '', name: '' }
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
editForm.value = {
|
||||
module_key: row.module_key,
|
||||
name: row.name,
|
||||
status: row.status || 'active'
|
||||
}
|
||||
showEditDialog.value = true
|
||||
}
|
||||
|
||||
function resetEditForm() {
|
||||
editForm.value = { module_key: '', name: '', status: 'active' }
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
const valid = await formRef.value.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
@@ -102,5 +144,22 @@ async function handleCreate() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEdit() {
|
||||
savingEdit.value = true
|
||||
try {
|
||||
await updateModule(editForm.value.module_key, {
|
||||
name: editForm.value.name,
|
||||
status: editForm.value.status
|
||||
})
|
||||
ElMessage.success('更新成功')
|
||||
showEditDialog.value = false
|
||||
await load()
|
||||
} catch (err) {
|
||||
ElMessage.error('更新失敗')
|
||||
} finally {
|
||||
savingEdit.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
@@ -16,6 +16,12 @@
|
||||
<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>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
@@ -60,16 +66,34 @@
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="Scope ID">
|
||||
<el-input v-model="groupPermForm.scope_id" placeholder="company_key or site_key" />
|
||||
<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-input v-model="groupPermForm.system" placeholder="mkt" />
|
||||
<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-input v-model="groupPermForm.module" placeholder="campaign" clearable />
|
||||
<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-input v-model="groupPermForm.action" placeholder="view" />
|
||||
<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
|
||||
@@ -113,22 +137,73 @@
|
||||
<el-button type="primary" :loading="creatingGroup" @click="handleCreateGroup">確認</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="showEditGroup" title="編輯群組" @close="resetEditGroupForm">
|
||||
<el-form :model="editGroupForm" label-width="120px">
|
||||
<el-form-item label="Group Key">
|
||||
<el-input :model-value="editGroupForm.group_key" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="群組名稱">
|
||||
<el-input v-model="editGroupForm.name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="狀態">
|
||||
<el-select v-model="editGroupForm.status" style="width: 100%">
|
||||
<el-option label="active" value="active" />
|
||||
<el-option label="inactive" value="inactive" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showEditGroup = false">取消</el-button>
|
||||
<el-button type="primary" :loading="savingGroup" @click="handleEditGroup">儲存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ref, reactive, onMounted, computed, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getPermissionGroups,
|
||||
createPermissionGroup,
|
||||
updatePermissionGroup,
|
||||
addMemberToGroup,
|
||||
groupGrant,
|
||||
groupRevoke
|
||||
} from '@/api/permission-groups'
|
||||
import { getSystems } from '@/api/systems'
|
||||
import { getModules } from '@/api/modules'
|
||||
import { getCompanies } from '@/api/companies'
|
||||
import { getSites } from '@/api/sites'
|
||||
|
||||
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.split('.', 2)[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([])
|
||||
@@ -150,10 +225,26 @@ async function loadGroups() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCatalogs() {
|
||||
const [systemsRes, modulesRes, companiesRes, sitesRes] = await Promise.all([
|
||||
getSystems(),
|
||||
getModules(),
|
||||
getCompanies(),
|
||||
getSites()
|
||||
])
|
||||
systems.value = systemsRes.data?.items || []
|
||||
modules.value = modulesRes.data?.items || []
|
||||
companies.value = companiesRes.data?.items || []
|
||||
sites.value = sitesRes.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 = ''
|
||||
@@ -179,6 +270,36 @@ async function handleCreateGroup() {
|
||||
}
|
||||
}
|
||||
|
||||
function openEditGroup(row) {
|
||||
editGroupForm.group_key = row.group_key
|
||||
editGroupForm.name = row.name
|
||||
editGroupForm.status = row.status || 'active'
|
||||
showEditGroup.value = true
|
||||
}
|
||||
|
||||
function resetEditGroupForm() {
|
||||
editGroupForm.group_key = ''
|
||||
editGroupForm.name = ''
|
||||
editGroupForm.status = 'active'
|
||||
}
|
||||
|
||||
async function handleEditGroup() {
|
||||
savingGroup.value = true
|
||||
try {
|
||||
await updatePermissionGroup(editGroupForm.group_key, {
|
||||
name: editGroupForm.name,
|
||||
status: editGroupForm.status
|
||||
})
|
||||
ElMessage.success('群組更新成功')
|
||||
showEditGroup.value = false
|
||||
await loadGroups()
|
||||
} catch (err) {
|
||||
ElMessage.error('群組更新失敗')
|
||||
} finally {
|
||||
savingGroup.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Add Member
|
||||
const memberForm = reactive({ groupKey: '', authentikSub: '' })
|
||||
const addingMember = ref(false)
|
||||
@@ -245,5 +366,15 @@ async function handleGroupRevoke() {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadGroups)
|
||||
watch(() => groupPermForm.scope_type, () => {
|
||||
groupPermForm.scope_id = ''
|
||||
})
|
||||
|
||||
watch(() => groupPermForm.system, () => {
|
||||
groupPermForm.module = ''
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadGroups(), loadCatalogs()])
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -5,42 +5,59 @@
|
||||
<el-button type="primary" @click="showDialog = true" :icon="Plus">新增站台</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="error"
|
||||
:title="errorMsg"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<el-alert v-if="error" :title="errorMsg" type="error" show-icon :closable="false" class="mb-4" />
|
||||
<el-skeleton v-if="loading" :rows="4" animated />
|
||||
|
||||
<el-table v-else :data="sites" stripe border class="w-full shadow-sm">
|
||||
<template #empty><el-empty description="目前無站台" /></template>
|
||||
<el-table-column prop="site_key" label="Site Key" width="160" />
|
||||
<el-table-column prop="company_key" label="Company Key" width="160" />
|
||||
<el-table-column prop="site_key" label="Site Key" width="180" />
|
||||
<el-table-column prop="company_key" label="Company" width="180" />
|
||||
<el-table-column prop="name" label="名稱" min-width="180" />
|
||||
<el-table-column prop="status" label="狀態" width="120" />
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="openEdit(row)">編輯</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 新增 Dialog -->
|
||||
<el-dialog v-model="showDialog" title="新增站台" @close="resetForm">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
|
||||
<el-form-item label="Site Key" prop="site_key">
|
||||
<el-input v-model="form.site_key" placeholder="site-001" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Company Key" prop="company_key">
|
||||
<el-input v-model="form.company_key" placeholder="company-001" />
|
||||
</el-form-item>
|
||||
<el-form-item label="名稱" prop="name">
|
||||
<el-input v-model="form.name" placeholder="站台名稱" />
|
||||
<el-form-item label="Site Key" prop="site_key"><el-input v-model="form.site_key" /></el-form-item>
|
||||
<el-form-item label="Company" prop="company_key">
|
||||
<el-select v-model="form.company_key" style="width: 100%" filterable>
|
||||
<el-option v-for="c in companies" :key="c.company_key" :label="`${c.name} (${c.company_key})`" :value="c.company_key" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="名稱" prop="name"><el-input v-model="form.name" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleCreate">確認</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="showEditDialog" title="編輯站台" @close="resetEditForm">
|
||||
<el-form :model="editForm" label-width="120px">
|
||||
<el-form-item label="Site Key"><el-input :model-value="editForm.site_key" disabled /></el-form-item>
|
||||
<el-form-item label="Company">
|
||||
<el-select v-model="editForm.company_key" style="width: 100%" filterable>
|
||||
<el-option v-for="c in companies" :key="c.company_key" :label="`${c.name} (${c.company_key})`" :value="c.company_key" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="名稱"><el-input v-model="editForm.name" /></el-form-item>
|
||||
<el-form-item label="狀態">
|
||||
<el-select v-model="editForm.status" style="width: 100%">
|
||||
<el-option label="active" value="active" />
|
||||
<el-option label="inactive" value="inactive" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showEditDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="savingEdit" @click="handleEdit">儲存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -48,29 +65,39 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { getSites, createSite } from '@/api/sites'
|
||||
import { getSites, createSite, updateSite } from '@/api/sites'
|
||||
import { getCompanies } from '@/api/companies'
|
||||
|
||||
const sites = ref([])
|
||||
const companies = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(false)
|
||||
const errorMsg = ref('')
|
||||
const showDialog = ref(false)
|
||||
const submitting = ref(false)
|
||||
const showEditDialog = ref(false)
|
||||
const savingEdit = ref(false)
|
||||
const formRef = ref()
|
||||
|
||||
const form = ref({ site_key: '', company_key: '', name: '' })
|
||||
const editForm = ref({ site_key: '', company_key: '', name: '', status: 'active' })
|
||||
const rules = {
|
||||
site_key: [{ required: true, message: '請輸入 Site Key', trigger: 'blur' }],
|
||||
company_key: [{ required: true, message: '請輸入 Company Key', trigger: 'blur' }],
|
||||
company_key: [{ required: true, message: '請選擇公司', trigger: 'change' }],
|
||||
name: [{ required: true, message: '請輸入名稱', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
async function loadCompanies() {
|
||||
const res = await getCompanies()
|
||||
companies.value = res.data?.items || []
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
const res = await getSites()
|
||||
sites.value = res.data?.items || []
|
||||
const [sitesRes] = await Promise.all([getSites(), loadCompanies()])
|
||||
sites.value = sitesRes.data?.items || []
|
||||
} catch (err) {
|
||||
error.value = true
|
||||
errorMsg.value = err.response?.status === 422
|
||||
@@ -85,6 +112,20 @@ function resetForm() {
|
||||
form.value = { site_key: '', company_key: '', name: '' }
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
editForm.value = {
|
||||
site_key: row.site_key,
|
||||
company_key: row.company_key,
|
||||
name: row.name,
|
||||
status: row.status || 'active'
|
||||
}
|
||||
showEditDialog.value = true
|
||||
}
|
||||
|
||||
function resetEditForm() {
|
||||
editForm.value = { site_key: '', company_key: '', name: '', status: 'active' }
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
const valid = await formRef.value.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
@@ -96,11 +137,29 @@ async function handleCreate() {
|
||||
resetForm()
|
||||
await load()
|
||||
} catch (err) {
|
||||
ElMessage.error('新增失敗,請稍後再試')
|
||||
ElMessage.error('新增失敗')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEdit() {
|
||||
savingEdit.value = true
|
||||
try {
|
||||
await updateSite(editForm.value.site_key, {
|
||||
company_key: editForm.value.company_key,
|
||||
name: editForm.value.name,
|
||||
status: editForm.value.status
|
||||
})
|
||||
ElMessage.success('更新成功')
|
||||
showEditDialog.value = false
|
||||
await load()
|
||||
} catch (err) {
|
||||
ElMessage.error('更新失敗')
|
||||
} finally {
|
||||
savingEdit.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
@@ -20,9 +20,14 @@
|
||||
<template #empty><el-empty description="目前無系統" /></template>
|
||||
<el-table-column prop="system_key" label="System Key" width="200" />
|
||||
<el-table-column prop="name" label="名稱" min-width="180" />
|
||||
<el-table-column prop="status" label="狀態" width="120" />
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="openEdit(row)">編輯</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 新增 Dialog -->
|
||||
<el-dialog v-model="showDialog" title="新增系統" @close="resetForm">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
|
||||
<el-form-item label="System Key" prop="system_key">
|
||||
@@ -37,6 +42,27 @@
|
||||
<el-button type="primary" :loading="submitting" @click="handleCreate">確認</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="showEditDialog" title="編輯系統" @close="resetEditForm">
|
||||
<el-form :model="editForm" label-width="100px">
|
||||
<el-form-item label="System Key">
|
||||
<el-input :model-value="editForm.system_key" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="名稱">
|
||||
<el-input v-model="editForm.name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="狀態">
|
||||
<el-select v-model="editForm.status" style="width: 100%">
|
||||
<el-option label="active" value="active" />
|
||||
<el-option label="inactive" value="inactive" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showEditDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="savingEdit" @click="handleEdit">儲存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -44,7 +70,7 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { getSystems, createSystem } from '@/api/systems'
|
||||
import { getSystems, createSystem, updateSystem } from '@/api/systems'
|
||||
|
||||
const systems = ref([])
|
||||
const loading = ref(false)
|
||||
@@ -53,8 +79,11 @@ const errorMsg = ref('')
|
||||
const showDialog = ref(false)
|
||||
const submitting = ref(false)
|
||||
const formRef = ref()
|
||||
const showEditDialog = ref(false)
|
||||
const savingEdit = ref(false)
|
||||
|
||||
const form = ref({ system_key: '', name: '' })
|
||||
const editForm = ref({ system_key: '', name: '', status: 'active' })
|
||||
const rules = {
|
||||
system_key: [{ required: true, message: '請輸入 System Key', trigger: 'blur' }],
|
||||
name: [{ required: true, message: '請輸入名稱', trigger: 'blur' }]
|
||||
@@ -80,6 +109,19 @@ function resetForm() {
|
||||
form.value = { system_key: '', name: '' }
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
editForm.value = {
|
||||
system_key: row.system_key,
|
||||
name: row.name,
|
||||
status: row.status || 'active'
|
||||
}
|
||||
showEditDialog.value = true
|
||||
}
|
||||
|
||||
function resetEditForm() {
|
||||
editForm.value = { system_key: '', name: '', status: 'active' }
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
const valid = await formRef.value.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
@@ -97,5 +139,22 @@ async function handleCreate() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEdit() {
|
||||
savingEdit.value = true
|
||||
try {
|
||||
await updateSystem(editForm.value.system_key, {
|
||||
name: editForm.value.name,
|
||||
status: editForm.value.status
|
||||
})
|
||||
ElMessage.success('更新成功')
|
||||
showEditDialog.value = false
|
||||
await load()
|
||||
} catch (err) {
|
||||
ElMessage.error('更新失敗')
|
||||
} finally {
|
||||
savingEdit.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
@submit.prevent="handleGrant"
|
||||
>
|
||||
<el-form-item label="Authentik Sub" prop="authentik_sub">
|
||||
<el-input v-model="grantForm.authentik_sub" placeholder="authentik-sub-xxx" />
|
||||
<el-select v-model="grantForm.authentik_sub" filterable allow-create default-first-option placeholder="選擇會員或輸入 sub" style="width: 100%">
|
||||
<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="Email" prop="email">
|
||||
<el-input v-model="grantForm.email" placeholder="user@example.com" />
|
||||
@@ -30,16 +32,24 @@
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="Scope ID" prop="scope_id">
|
||||
<el-input v-model="grantForm.scope_id" placeholder="company_key or site_key" />
|
||||
<el-select v-model="grantForm.scope_id" placeholder="選擇 Scope ID" filterable style="width: 100%">
|
||||
<el-option v-for="s in grantScopeOptions" :key="s.value" :label="s.label" :value="s.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="系統" prop="system">
|
||||
<el-input v-model="grantForm.system" placeholder="mkt" />
|
||||
<el-select v-model="grantForm.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="模組(選填)" prop="module">
|
||||
<el-input v-model="grantForm.module" placeholder="campaign(空值代表系統層)" clearable />
|
||||
<el-select v-model="grantForm.module" placeholder="系統層(留空) 或選模組" clearable filterable style="width: 100%">
|
||||
<el-option v-for="m in grantModuleOptions" :key="m.value" :label="m.label" :value="m.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="操作" prop="action">
|
||||
<el-input v-model="grantForm.action" placeholder="view" />
|
||||
<el-select v-model="grantForm.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-alert
|
||||
@@ -83,7 +93,9 @@
|
||||
@submit.prevent="handleRevoke"
|
||||
>
|
||||
<el-form-item label="Authentik Sub" prop="authentik_sub">
|
||||
<el-input v-model="revokeForm.authentik_sub" placeholder="authentik-sub-xxx" />
|
||||
<el-select v-model="revokeForm.authentik_sub" filterable allow-create default-first-option placeholder="選擇會員或輸入 sub" style="width: 100%">
|
||||
<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="Scope 類型" prop="scope_type">
|
||||
<el-select v-model="revokeForm.scope_type" placeholder="選擇 Scope 類型">
|
||||
@@ -92,16 +104,24 @@
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="Scope ID" prop="scope_id">
|
||||
<el-input v-model="revokeForm.scope_id" placeholder="company_key or site_key" />
|
||||
<el-select v-model="revokeForm.scope_id" placeholder="選擇 Scope ID" filterable style="width: 100%">
|
||||
<el-option v-for="s in revokeScopeOptions" :key="s.value" :label="s.label" :value="s.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="系統" prop="system">
|
||||
<el-input v-model="revokeForm.system" placeholder="mkt" />
|
||||
<el-select v-model="revokeForm.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="模組(選填)" prop="module">
|
||||
<el-input v-model="revokeForm.module" placeholder="campaign(空值代表系統層)" clearable />
|
||||
<el-select v-model="revokeForm.module" placeholder="系統層(留空) 或選模組" clearable filterable style="width: 100%">
|
||||
<el-option v-for="m in revokeModuleOptions" :key="m.value" :label="m.label" :value="m.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="操作" prop="action">
|
||||
<el-input v-model="revokeForm.action" placeholder="view" />
|
||||
<el-select v-model="revokeForm.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-alert
|
||||
@@ -138,13 +158,24 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import { ref, reactive, onMounted, computed, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { usePermissionStore } from '@/stores/permission'
|
||||
import { getSystems } from '@/api/systems'
|
||||
import { getModules } from '@/api/modules'
|
||||
import { getCompanies } from '@/api/companies'
|
||||
import { getSites } from '@/api/sites'
|
||||
import { getMembers } from '@/api/members'
|
||||
|
||||
const permissionStore = usePermissionStore()
|
||||
|
||||
const activeTab = ref('grant')
|
||||
const systems = ref([])
|
||||
const modules = ref([])
|
||||
const companies = ref([])
|
||||
const sites = ref([])
|
||||
const members = ref([])
|
||||
const actionOptions = ['view', 'edit', 'manage', 'admin']
|
||||
|
||||
// Grant
|
||||
const grantFormRef = ref()
|
||||
@@ -163,6 +194,26 @@ const grantForm = reactive({
|
||||
action: ''
|
||||
})
|
||||
|
||||
const grantModuleOptions = computed(() => {
|
||||
if (!grantForm.system) return []
|
||||
return modules.value
|
||||
.filter(m => m.system_key === grantForm.system && !m.module_key.endsWith('.__system__'))
|
||||
.map(m => ({
|
||||
value: m.module_key.split('.', 2)[1] || m.module_key,
|
||||
label: `${m.name} (${m.module_key})`
|
||||
}))
|
||||
})
|
||||
|
||||
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') {
|
||||
return sites.value.map(s => ({ value: s.site_key, label: `${s.name} (${s.site_key})` }))
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
const required = { required: true, message: '必填', trigger: 'blur' }
|
||||
const grantRules = {
|
||||
authentik_sub: [required],
|
||||
@@ -212,6 +263,26 @@ const revokeForm = reactive({
|
||||
action: ''
|
||||
})
|
||||
|
||||
const revokeModuleOptions = computed(() => {
|
||||
if (!revokeForm.system) return []
|
||||
return modules.value
|
||||
.filter(m => m.system_key === revokeForm.system && !m.module_key.endsWith('.__system__'))
|
||||
.map(m => ({
|
||||
value: m.module_key.split('.', 2)[1] || m.module_key,
|
||||
label: `${m.name} (${m.module_key})`
|
||||
}))
|
||||
})
|
||||
|
||||
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') {
|
||||
return sites.value.map(s => ({ value: s.site_key, label: `${s.name} (${s.site_key})` }))
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
const revokeRules = {
|
||||
authentik_sub: [required],
|
||||
scope_type: [required],
|
||||
@@ -265,4 +336,33 @@ function formatAdminError(err) {
|
||||
if (status === 503) return '後端設定不完整,請聯絡管理員'
|
||||
return '操作失敗,請稍後再試'
|
||||
}
|
||||
|
||||
async function loadCatalogs() {
|
||||
const [systemsRes, modulesRes, companiesRes, sitesRes, membersRes] = await Promise.all([
|
||||
getSystems(),
|
||||
getModules(),
|
||||
getCompanies(),
|
||||
getSites(),
|
||||
getMembers()
|
||||
])
|
||||
systems.value = systemsRes.data?.items || []
|
||||
modules.value = modulesRes.data?.items || []
|
||||
companies.value = companiesRes.data?.items || []
|
||||
sites.value = sitesRes.data?.items || []
|
||||
members.value = membersRes.data?.items || []
|
||||
}
|
||||
|
||||
watch(() => grantForm.scope_type, () => { grantForm.scope_id = '' })
|
||||
watch(() => grantForm.system, () => { grantForm.module = '' })
|
||||
watch(() => revokeForm.scope_type, () => { revokeForm.scope_id = '' })
|
||||
watch(() => revokeForm.system, () => { revokeForm.module = '' })
|
||||
|
||||
watch(() => grantForm.authentik_sub, (sub) => {
|
||||
const user = members.value.find(m => m.authentik_sub === sub)
|
||||
if (!user) return
|
||||
grantForm.email = user.email || ''
|
||||
grantForm.display_name = user.display_name || ''
|
||||
})
|
||||
|
||||
onMounted(loadCatalogs)
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user