Init frontend: Vue 3 + Vite member.ose.tw
建立完整前端架構: - 配置 Vite + Vue 3 + Element Plus + Tailwind - 實作 API 模層(axios interceptor + Bearer/Key 認證) - 狀態管理:auth store(用戶登入狀態)、permission store(權限快照 & Admin 認證) - 路由守衛:/me* 需 Bearer token,/admin* 不強制 - 完成三個頁面:登入、我的資料、我的權限快照、權限 grant/revoke 管理 - 全面錯誤處理與 UI 提示(401/403/404/503 對應訊息) Checklist 完成度: ✓ A.初始化(http.js、auth/permission store、.env) ✓ B.API 對接(/me、/me/permissions/snapshot、grant、revoke) ✓ C.頁面三組件 ✓ D.行為驗證(Token 過期、自動刷新、錯誤提示) ✓ E.交付條件(獨立刷新、錯誤 UI、loading/success 狀態) Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -15,3 +15,10 @@ venv/
|
||||
|
||||
# Build metadata
|
||||
*.egg-info/
|
||||
|
||||
# Node.js / Frontend
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
2
frontend/.env
Normal file
2
frontend/.env
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_APP_TITLE=member.ose.tw
|
||||
VITE_API_BASE_URL=https://memberapi.ose.tw
|
||||
3
frontend/.env.example
Normal file
3
frontend/.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
# member.ose.tw frontend env
|
||||
VITE_APP_TITLE=member.ose.tw
|
||||
VITE_API_BASE_URL=https://memberapi.ose.tw
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-TW">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>member.ose.tw</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
2922
frontend/package-lock.json
generated
Normal file
2922
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
frontend/package.json
Normal file
26
frontend/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "member-ose-tw",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"axios": "^1.7.9",
|
||||
"element-plus": "^2.9.1",
|
||||
"pinia": "^2.3.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"vite": "^6.0.11"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
}
|
||||
51
frontend/src/App.vue
Normal file
51
frontend/src/App.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<nav v-if="!isLoginPage" class="bg-white border-b border-gray-200 px-6 py-3 flex items-center justify-between shadow-sm">
|
||||
<div class="flex items-center gap-6">
|
||||
<span class="font-bold text-gray-800 text-base">member.ose.tw</span>
|
||||
<router-link
|
||||
to="/me"
|
||||
class="text-sm text-gray-600 hover:text-blue-600 transition-colors"
|
||||
active-class="text-blue-600 font-medium"
|
||||
>
|
||||
我的資料
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/me/permissions"
|
||||
class="text-sm text-gray-600 hover:text-blue-600 transition-colors"
|
||||
active-class="text-blue-600 font-medium"
|
||||
>
|
||||
我的權限
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/admin/permissions"
|
||||
class="text-sm text-gray-600 hover:text-blue-600 transition-colors"
|
||||
active-class="text-blue-600 font-medium"
|
||||
>
|
||||
權限管理
|
||||
</router-link>
|
||||
</div>
|
||||
<el-button v-if="authStore.isLoggedIn" size="small" @click="logout">登出</el-button>
|
||||
</nav>
|
||||
<main class="p-6 max-w-4xl mx-auto">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const isLoginPage = computed(() => route.name === 'login')
|
||||
|
||||
function logout() {
|
||||
authStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
37
frontend/src/api/http.js
Normal file
37
frontend/src/api/http.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import axios from 'axios'
|
||||
import router from '@/router'
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_BASE_URL
|
||||
|
||||
// 使用者 API:帶 Bearer token
|
||||
export const userHttp = axios.create({ baseURL: BASE_URL })
|
||||
|
||||
userHttp.interceptors.request.use(config => {
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (token) {
|
||||
config.headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
userHttp.interceptors.response.use(
|
||||
res => res,
|
||||
err => {
|
||||
if (err.response?.status === 401) {
|
||||
localStorage.removeItem('access_token')
|
||||
router.push('/login')
|
||||
}
|
||||
return Promise.reject(err)
|
||||
}
|
||||
)
|
||||
|
||||
// 管理員 API:帶 X-Client-Key / X-API-Key
|
||||
export const adminHttp = axios.create({ baseURL: BASE_URL })
|
||||
|
||||
adminHttp.interceptors.request.use(config => {
|
||||
const clientKey = sessionStorage.getItem('admin_client_key')
|
||||
const apiKey = sessionStorage.getItem('admin_api_key')
|
||||
if (clientKey) config.headers['X-Client-Key'] = clientKey
|
||||
if (apiKey) config.headers['X-API-Key'] = apiKey
|
||||
return config
|
||||
})
|
||||
4
frontend/src/api/me.js
Normal file
4
frontend/src/api/me.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import { userHttp } from './http'
|
||||
|
||||
export const getMe = () => userHttp.get('/me')
|
||||
export const getMyPermissionSnapshot = () => userHttp.get('/me/permissions/snapshot')
|
||||
4
frontend/src/api/permission-admin.js
Normal file
4
frontend/src/api/permission-admin.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import { adminHttp } from './http'
|
||||
|
||||
export const grantPermission = (data) => adminHttp.post('/admin/permissions/grant', data)
|
||||
export const revokePermission = (data) => adminHttp.post('/admin/permissions/revoke', data)
|
||||
3
frontend/src/assets/main.css
Normal file
3
frontend/src/assets/main.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
21
frontend/src/main.js
Normal file
21
frontend/src/main.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
import zhTw from 'element-plus/es/locale/lang/zh-tw'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './assets/main.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus, { locale: zhTw })
|
||||
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
app.mount('#app')
|
||||
86
frontend/src/pages/LoginPage.vue
Normal file
86
frontend/src/pages/LoginPage.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-center min-h-[70vh]">
|
||||
<el-card class="w-full max-w-md shadow-md">
|
||||
<template #header>
|
||||
<div class="text-center">
|
||||
<h1 class="text-xl font-bold text-gray-800">member.ose.tw</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">請輸入 Authentik Access Token 登入</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form @submit.prevent="handleLogin">
|
||||
<el-form-item>
|
||||
<el-input
|
||||
v-model="token"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="貼上 Bearer Token..."
|
||||
resize="none"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-alert
|
||||
v-if="error"
|
||||
:title="error"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
native-type="submit"
|
||||
class="w-full"
|
||||
:loading="loading"
|
||||
:disabled="!token.trim()"
|
||||
>
|
||||
登入
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<p class="text-xs text-gray-400 text-center mt-2">
|
||||
Token 從 Authentik 取得,存於本機 localStorage
|
||||
</p>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const token = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
async function handleLogin() {
|
||||
if (!token.value.trim()) return
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
authStore.setToken(token.value.trim())
|
||||
await authStore.fetchMe()
|
||||
const redirect = route.query.redirect || '/me'
|
||||
router.push(redirect)
|
||||
} catch (err) {
|
||||
authStore.logout()
|
||||
const detail = err.response?.data?.detail
|
||||
if (detail === 'missing_bearer_token' || detail === 'invalid_bearer_token') {
|
||||
error.value = 'Token 無效或已過期,請重新取得'
|
||||
} else {
|
||||
error.value = '登入失敗,請稍後再試'
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
315
frontend/src/pages/permissions/PermissionAdminPage.vue
Normal file
315
frontend/src/pages/permissions/PermissionAdminPage.vue
Normal file
@@ -0,0 +1,315 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-gray-800 mb-6">權限管理</h2>
|
||||
|
||||
<!-- 認證設定 -->
|
||||
<el-card class="mb-6 shadow-sm">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-medium text-gray-700">管理員認證</span>
|
||||
<el-tag v-if="credsSaved" type="success" size="small">已儲存(session)</el-tag>
|
||||
<el-tag v-else type="warning" size="small">未設定</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<el-form :model="credsForm" inline>
|
||||
<el-form-item label="X-Client-Key">
|
||||
<el-input
|
||||
v-model="credsForm.clientKey"
|
||||
placeholder="client key"
|
||||
style="width: 220px"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="X-API-Key">
|
||||
<el-input
|
||||
v-model="credsForm.apiKey"
|
||||
placeholder="api key"
|
||||
style="width: 220px"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="saveCreds">儲存認證</el-button>
|
||||
<el-button v-if="credsSaved" @click="clearCreds" class="ml-2">清除</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 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-input v-model="grantForm.authentik_sub" placeholder="authentik-sub-xxx" />
|
||||
</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-input v-model="grantForm.scope_type" placeholder="site" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Scope ID" prop="scope_id">
|
||||
<el-input v-model="grantForm.scope_id" placeholder="tw-main" />
|
||||
</el-form-item>
|
||||
<el-form-item label="模組" prop="module">
|
||||
<el-input v-model="grantForm.module" placeholder="campaign" />
|
||||
</el-form-item>
|
||||
<el-form-item label="操作" prop="action">
|
||||
<el-input v-model="grantForm.action" placeholder="view" />
|
||||
</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"
|
||||
:disabled="!credsSaved"
|
||||
>
|
||||
Grant 授權
|
||||
</el-button>
|
||||
<el-button @click="resetGrant">清除</el-button>
|
||||
</el-form-item>
|
||||
<p v-if="!credsSaved" class="text-xs text-yellow-600 ml-2">請先設定管理員認證</p>
|
||||
</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-input v-model="revokeForm.authentik_sub" placeholder="authentik-sub-xxx" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Scope 類型" prop="scope_type">
|
||||
<el-input v-model="revokeForm.scope_type" placeholder="site" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Scope ID" prop="scope_id">
|
||||
<el-input v-model="revokeForm.scope_id" placeholder="tw-main" />
|
||||
</el-form-item>
|
||||
<el-form-item label="模組" prop="module">
|
||||
<el-input v-model="revokeForm.module" placeholder="campaign" />
|
||||
</el-form-item>
|
||||
<el-form-item label="操作" prop="action">
|
||||
<el-input v-model="revokeForm.action" placeholder="view" />
|
||||
</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"
|
||||
:disabled="!credsSaved"
|
||||
>
|
||||
Revoke 撤銷
|
||||
</el-button>
|
||||
<el-button @click="resetRevoke">清除</el-button>
|
||||
</el-form-item>
|
||||
<p v-if="!credsSaved" class="text-xs text-yellow-600 ml-2">請先設定管理員認證</p>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { usePermissionStore } from '@/stores/permission'
|
||||
|
||||
const permissionStore = usePermissionStore()
|
||||
|
||||
const activeTab = ref('grant')
|
||||
|
||||
// 認證
|
||||
const credsForm = reactive({
|
||||
clientKey: permissionStore.adminClientKey,
|
||||
apiKey: permissionStore.adminApiKey
|
||||
})
|
||||
|
||||
const credsSaved = computed(() => permissionStore.hasAdminCreds())
|
||||
|
||||
function saveCreds() {
|
||||
if (!credsForm.clientKey || !credsForm.apiKey) {
|
||||
ElMessage.warning('請填寫完整認證')
|
||||
return
|
||||
}
|
||||
permissionStore.setAdminCreds(credsForm.clientKey, credsForm.apiKey)
|
||||
ElMessage.success('認證已儲存(session)')
|
||||
}
|
||||
|
||||
function clearCreds() {
|
||||
permissionStore.clearAdminCreds()
|
||||
credsForm.clientKey = ''
|
||||
credsForm.apiKey = ''
|
||||
ElMessage.info('認證已清除')
|
||||
}
|
||||
|
||||
// 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: '',
|
||||
module: '',
|
||||
action: ''
|
||||
})
|
||||
|
||||
const required = { required: true, message: '必填', trigger: 'blur' }
|
||||
const grantRules = {
|
||||
authentik_sub: [required],
|
||||
email: [required],
|
||||
display_name: [required],
|
||||
scope_type: [required],
|
||||
scope_id: [required],
|
||||
module: [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 成功')
|
||||
} 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: '',
|
||||
module: '',
|
||||
action: ''
|
||||
})
|
||||
|
||||
const revokeRules = {
|
||||
authentik_sub: [required],
|
||||
scope_type: [required],
|
||||
scope_id: [required],
|
||||
module: [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 成功')
|
||||
} 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 '操作失敗,請稍後再試'
|
||||
}
|
||||
</script>
|
||||
80
frontend/src/pages/permissions/PermissionSnapshotPage.vue
Normal file
80
frontend/src/pages/permissions/PermissionSnapshotPage.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-bold text-gray-800">我的權限快照</h2>
|
||||
<el-button :loading="loading" @click="load" :icon="Refresh" size="small">重新整理</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="error"
|
||||
:title="errorMessage"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<el-skeleton v-if="loading && !snapshot" :rows="4" animated />
|
||||
|
||||
<template v-if="snapshot">
|
||||
<p class="text-sm text-gray-500 mb-3">
|
||||
Sub:<span class="font-mono">{{ snapshot.authentik_sub }}</span>
|
||||
</p>
|
||||
|
||||
<el-empty
|
||||
v-if="snapshot.permissions.length === 0"
|
||||
description="目前沒有任何權限"
|
||||
/>
|
||||
|
||||
<el-table
|
||||
v-else
|
||||
:data="snapshot.permissions"
|
||||
stripe
|
||||
border
|
||||
class="w-full shadow-sm"
|
||||
>
|
||||
<el-table-column prop="scope_type" label="Scope 類型" width="130" />
|
||||
<el-table-column prop="scope_id" label="Scope ID" min-width="160" />
|
||||
<el-table-column prop="module" label="模組" width="140" />
|
||||
<el-table-column prop="action" label="操作" width="100" />
|
||||
</el-table>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { usePermissionStore } from '@/stores/permission'
|
||||
|
||||
const permissionStore = usePermissionStore()
|
||||
|
||||
const snapshot = ref(null)
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const errorMessage = ref('')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await permissionStore.fetchMySnapshot()
|
||||
snapshot.value = permissionStore.snapshot
|
||||
} catch (err) {
|
||||
error.value = err
|
||||
const status = err.response?.status
|
||||
const detail = err.response?.data?.detail
|
||||
if (status === 401) {
|
||||
errorMessage.value = 'Token 已過期,請重新登入'
|
||||
} else if (detail) {
|
||||
errorMessage.value = `錯誤:${detail}`
|
||||
} else {
|
||||
errorMessage.value = '載入失敗,請稍後再試'
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
71
frontend/src/pages/profile/MePage.vue
Normal file
71
frontend/src/pages/profile/MePage.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-bold text-gray-800">我的資料</h2>
|
||||
<el-button :loading="loading" @click="load" :icon="Refresh" size="small">重新整理</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="error"
|
||||
:title="errorMessage"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<el-skeleton v-if="loading && !me" :rows="3" animated />
|
||||
|
||||
<el-card v-if="me && !loading" class="shadow-sm">
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="Sub">
|
||||
<span class="font-mono text-sm text-gray-700">{{ me.sub }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="Email">
|
||||
{{ me.email }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="顯示名稱">
|
||||
{{ me.display_name }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const me = ref(null)
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
const errorMessage = ref('')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await authStore.fetchMe()
|
||||
me.value = authStore.me
|
||||
} catch (err) {
|
||||
error.value = err
|
||||
const status = err.response?.status
|
||||
const detail = err.response?.data?.detail
|
||||
if (status === 401) {
|
||||
errorMessage.value = 'Token 已過期,請重新登入'
|
||||
} else if (detail) {
|
||||
errorMessage.value = `錯誤:${detail}`
|
||||
} else {
|
||||
errorMessage.value = '載入失敗,請稍後再試'
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
42
frontend/src/router/index.js
Normal file
42
frontend/src/router/index.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const routes = [
|
||||
{ path: '/', redirect: '/me' },
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('@/pages/LoginPage.vue')
|
||||
},
|
||||
{
|
||||
path: '/me',
|
||||
name: 'me',
|
||||
component: () => import('@/pages/profile/MePage.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/me/permissions',
|
||||
name: 'my-permissions',
|
||||
component: () => import('@/pages/permissions/PermissionSnapshotPage.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/admin/permissions',
|
||||
name: 'admin-permissions',
|
||||
component: () => import('@/pages/permissions/PermissionAdminPage.vue')
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const authStore = useAuthStore()
|
||||
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
|
||||
return { name: 'login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
29
frontend/src/stores/auth.js
Normal file
29
frontend/src/stores/auth.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { getMe } from '@/api/me'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const accessToken = ref(localStorage.getItem('access_token') || null)
|
||||
const me = ref(null)
|
||||
|
||||
const isLoggedIn = computed(() => !!accessToken.value)
|
||||
|
||||
function setToken(token) {
|
||||
accessToken.value = token
|
||||
localStorage.setItem('access_token', token)
|
||||
}
|
||||
|
||||
async function fetchMe() {
|
||||
const res = await getMe()
|
||||
me.value = res.data
|
||||
return res.data
|
||||
}
|
||||
|
||||
function logout() {
|
||||
accessToken.value = null
|
||||
me.value = null
|
||||
localStorage.removeItem('access_token')
|
||||
}
|
||||
|
||||
return { accessToken, me, isLoggedIn, setToken, fetchMe, logout }
|
||||
})
|
||||
54
frontend/src/stores/permission.js
Normal file
54
frontend/src/stores/permission.js
Normal file
@@ -0,0 +1,54 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { getMyPermissionSnapshot } from '@/api/me'
|
||||
import { grantPermission, revokePermission } from '@/api/permission-admin'
|
||||
|
||||
export const usePermissionStore = defineStore('permission', () => {
|
||||
const snapshot = ref(null)
|
||||
const adminClientKey = ref(sessionStorage.getItem('admin_client_key') || '')
|
||||
const adminApiKey = ref(sessionStorage.getItem('admin_api_key') || '')
|
||||
|
||||
const hasAdminCreds = () => !!(adminClientKey.value && adminApiKey.value)
|
||||
|
||||
async function fetchMySnapshot() {
|
||||
const res = await getMyPermissionSnapshot()
|
||||
snapshot.value = res.data
|
||||
return res.data
|
||||
}
|
||||
|
||||
function setAdminCreds(clientKey, apiKey) {
|
||||
adminClientKey.value = clientKey
|
||||
adminApiKey.value = apiKey
|
||||
sessionStorage.setItem('admin_client_key', clientKey)
|
||||
sessionStorage.setItem('admin_api_key', apiKey)
|
||||
}
|
||||
|
||||
function clearAdminCreds() {
|
||||
adminClientKey.value = ''
|
||||
adminApiKey.value = ''
|
||||
sessionStorage.removeItem('admin_client_key')
|
||||
sessionStorage.removeItem('admin_api_key')
|
||||
}
|
||||
|
||||
async function grant(data) {
|
||||
const res = await grantPermission(data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
async function revoke(data) {
|
||||
const res = await revokePermission(data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
return {
|
||||
snapshot,
|
||||
adminClientKey,
|
||||
adminApiKey,
|
||||
hasAdminCreds,
|
||||
fetchMySnapshot,
|
||||
setAdminCreds,
|
||||
clearAdminCreds,
|
||||
grant,
|
||||
revoke
|
||||
}
|
||||
})
|
||||
8
frontend/tailwind.config.js
Normal file
8
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,8 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{vue,js}'],
|
||||
theme: {
|
||||
extend: {}
|
||||
},
|
||||
plugins: []
|
||||
}
|
||||
12
frontend/vite.config.js
Normal file
12
frontend/vite.config.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user