Files
member-platform/frontend/src/pages/permissions/PermissionAdminPage.vue

440 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div>
<h2 class="text-xl font-bold text-gray-800 mb-6">權限管理</h2>
<!-- Grant / Revoke -->
<el-tabs v-model="activeTab" type="border-card" class="shadow-sm">
<!-- Grant Tab -->
<el-tab-pane label="Grant 授權" name="grant">
<el-form
ref="grantFormRef"
:model="grantForm"
:rules="grantRules"
label-width="130px"
class="max-w-xl mt-4"
@submit.prevent="handleGrant"
>
<el-form-item label="Authentik Sub" prop="authentik_sub">
<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" />
</el-form-item>
<el-form-item label="顯示名稱" prop="display_name">
<el-input v-model="grantForm.display_name" placeholder="User Name" />
</el-form-item>
<el-form-item label="Scope 類型" prop="scope_type">
<el-select v-model="grantForm.scope_type" placeholder="選擇 Scope 類型">
<el-option label="Site" value="site" />
</el-select>
</el-form-item>
<el-form-item label="Scope ID" prop="scope_id">
<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-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-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-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
v-if="grantError"
:title="grantError"
type="error"
show-icon
:closable="false"
class="mb-4"
/>
<el-alert
v-if="grantSuccess"
:title="grantSuccess"
type="success"
show-icon
:closable="false"
class="mb-4"
/>
<el-form-item>
<el-button
type="primary"
native-type="submit"
:loading="grantLoading"
>
Grant 授權
</el-button>
<el-button @click="resetGrant">清除</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
<!-- Revoke Tab -->
<el-tab-pane label="Revoke 撤銷" name="revoke">
<el-form
ref="revokeFormRef"
:model="revokeForm"
:rules="revokeRules"
label-width="130px"
class="max-w-xl mt-4"
@submit.prevent="handleRevoke"
>
<el-form-item label="Authentik Sub" prop="authentik_sub">
<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 類型">
<el-option label="Site" value="site" />
</el-select>
</el-form-item>
<el-form-item label="Scope ID" prop="scope_id">
<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-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-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-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
v-if="revokeError"
:title="revokeError"
type="error"
show-icon
:closable="false"
class="mb-4"
/>
<el-alert
v-if="revokeSuccess"
:title="revokeSuccess"
type="success"
show-icon
:closable="false"
class="mb-4"
/>
<el-form-item>
<el-button
type="danger"
native-type="submit"
:loading="revokeLoading"
>
Revoke 撤銷
</el-button>
<el-button @click="resetRevoke">清除</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
</el-tabs>
<el-card class="mt-6 shadow-sm">
<template #header>
<div class="flex items-center justify-between gap-3">
<span class="font-medium text-gray-700">已授權列表直接授權</span>
<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-select v-model="listFilters.scope_type" clearable placeholder="Scope" style="width: 140px">
<el-option label="Site" value="site" />
</el-select>
<el-button :loading="listLoading" @click="loadDirectPermissionList">查詢</el-button>
</div>
</div>
</template>
<el-table :data="directPermissions" stripe border class="w-full" v-loading="listLoading">
<template #empty><el-empty description="目前沒有直接授權資料" /></template>
<el-table-column prop="display_name" label="名稱" min-width="140" />
<el-table-column prop="email" label="Email" min-width="200" />
<el-table-column prop="authentik_sub" label="Sub" min-width="200" />
<el-table-column prop="scope_type" label="Scope" width="90" />
<el-table-column prop="scope_id" label="Scope ID" min-width="120" />
<el-table-column prop="system" label="系統" width="100" />
<el-table-column prop="module" label="模組" width="130" />
<el-table-column prop="action" label="操作" width="100" />
<el-table-column prop="created_at" label="建立時間" min-width="180" />
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button type="danger" size="small" @click="handleRevokeByRow(row)" :loading="revokeRowLoadingId === row.permission_id">撤銷</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup>
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'
import { listDirectPermissions, revokeDirectPermissionById } from '@/api/permission-admin'
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']
const listFilters = reactive({ keyword: '', scope_type: '' })
const listLoading = ref(false)
const directPermissions = ref([])
const revokeRowLoadingId = ref('')
// Grant
const grantFormRef = ref()
const grantLoading = ref(false)
const grantError = ref('')
const grantSuccess = ref('')
const grantForm = reactive({
authentik_sub: '',
email: '',
display_name: '',
scope_type: '',
scope_id: '',
system: '',
module: '',
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.startsWith(`${grantForm.system}.`)
? m.module_key.slice(grantForm.system.length + 1)
: m.module_key,
label: `${m.name} (${m.module_key})`
}))
})
const grantScopeOptions = computed(() => {
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],
email: [required],
display_name: [required],
scope_type: [required],
scope_id: [required],
system: [required],
action: [required]
}
async function handleGrant() {
const valid = await grantFormRef.value.validate().catch(() => false)
if (!valid) return
grantLoading.value = true
grantError.value = ''
grantSuccess.value = ''
try {
const result = await permissionStore.grant({ ...grantForm })
grantSuccess.value = `授權成功ID: ${result.permission_id}`
ElMessage.success('Grant 成功')
await loadDirectPermissionList()
} catch (err) {
grantError.value = formatAdminError(err)
} finally {
grantLoading.value = false
}
}
function resetGrant() {
grantFormRef.value?.resetFields()
grantError.value = ''
grantSuccess.value = ''
}
// Revoke
const revokeFormRef = ref()
const revokeLoading = ref(false)
const revokeError = ref('')
const revokeSuccess = ref('')
const revokeForm = reactive({
authentik_sub: '',
scope_type: '',
scope_id: '',
system: '',
module: '',
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.startsWith(`${revokeForm.system}.`)
? m.module_key.slice(revokeForm.system.length + 1)
: m.module_key,
label: `${m.name} (${m.module_key})`
}))
})
const revokeScopeOptions = computed(() => {
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],
scope_id: [required],
system: [required],
action: [required]
}
async function handleRevoke() {
const valid = await revokeFormRef.value.validate().catch(() => false)
if (!valid) return
revokeLoading.value = true
revokeError.value = ''
revokeSuccess.value = ''
try {
const result = await permissionStore.revoke({ ...revokeForm })
revokeSuccess.value = `撤銷成功(共刪除 ${result.deleted} 筆)`
ElMessage.success('Revoke 成功')
await loadDirectPermissionList()
} catch (err) {
revokeError.value = formatAdminError(err)
} finally {
revokeLoading.value = false
}
}
function resetRevoke() {
revokeFormRef.value?.resetFields()
revokeError.value = ''
revokeSuccess.value = ''
}
function formatAdminError(err) {
const status = err.response?.status
const detail = err.response?.data?.detail
const map = {
invalid_client: '無效的 Client Key',
invalid_api_key: '無效的 API Key',
client_expired: 'Client 已過期',
origin_not_allowed: '來源 Origin 不允許',
ip_not_allowed: 'IP 不在白名單',
path_not_allowed: '路徑不允許',
internal_secret_not_configured: '後端設定缺失internal secret',
authentik_admin_not_configured: '後端設定缺失authentik admin',
user_not_found: '找不到該使用者'
}
if (detail && map[detail]) return map[detail]
if (detail) return `錯誤:${detail}`
if (status === 401) return '認證失敗,請檢查 Client Key / API Key'
if (status === 403) return '存取被拒IP 或 Origin 限制)'
if (status === 404) return '找不到該使用者'
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 || []
}
async function loadDirectPermissionList() {
listLoading.value = true
try {
const res = await listDirectPermissions({
keyword: listFilters.keyword || undefined,
scope_type: listFilters.scope_type || undefined,
limit: 200,
offset: 0
})
directPermissions.value = (res.data?.items || []).map(row => ({
...row,
created_at: row.created_at ? new Date(row.created_at).toLocaleString() : ''
}))
} catch (err) {
ElMessage.error('載入權限列表失敗')
} finally {
listLoading.value = false
}
}
async function handleRevokeByRow(row) {
revokeRowLoadingId.value = row.permission_id
try {
await revokeDirectPermissionById(row.permission_id)
ElMessage.success('已撤銷該筆授權')
await loadDirectPermissionList()
} catch (err) {
ElMessage.error('撤銷失敗')
} finally {
revokeRowLoadingId.value = ''
}
}
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(async () => {
await Promise.all([loadCatalogs(), loadDirectPermissionList()])
})
</script>