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:
2
.env
Normal file
2
.env
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
VITE_APP_TITLE=member.ose.tw
|
||||||
|
VITE_API_BASE_URL=https://memberapi.ose.tw
|
||||||
3
.env.example
Normal file
3
.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
index.html
Normal file
12
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
package-lock.json
generated
Normal file
2922
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
package.json
Normal file
26
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
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/App.vue
Normal file
51
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
src/api/http.js
Normal file
37
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
src/api/me.js
Normal file
4
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
src/api/permission-admin.js
Normal file
4
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
src/assets/main.css
Normal file
3
src/assets/main.css
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
21
src/main.js
Normal file
21
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
src/pages/LoginPage.vue
Normal file
86
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
src/pages/permissions/PermissionAdminPage.vue
Normal file
315
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
src/pages/permissions/PermissionSnapshotPage.vue
Normal file
80
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
src/pages/profile/MePage.vue
Normal file
71
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
src/router/index.js
Normal file
42
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
src/stores/auth.js
Normal file
29
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
src/stores/permission.js
Normal file
54
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
tailwind.config.js
Normal file
8
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
vite.config.js
Normal file
12
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