mirror of https://github.com/veypi/OneAuth.git
feat: 全新UI框架升级和页面重构
parent
5654a7cd37
commit
e5308f9471
@ -0,0 +1,24 @@
|
||||
# Frontend TODOs and Missing APIs
|
||||
|
||||
## Missing APIs
|
||||
|
||||
### Dashboard Statistics
|
||||
- **Endpoint**: `GET /api/stats/dashboard`
|
||||
- **Description**: Returns summary statistics for the dashboard (e.g., total users, active orgs, api calls, revenue).
|
||||
- **Reason**: The dashboard needs high-level metrics to display to the user immediately upon login. Currently using mock data in `ui/page/dashboard/index.html`.
|
||||
|
||||
## Future Improvements
|
||||
|
||||
### UI
|
||||
- Add proper form validation for all inputs.
|
||||
- Implement pagination for lists (Users, Orgs, OAuth Clients).
|
||||
- Add search and filter functionality for lists.
|
||||
- Improve error handling and user feedback messages.
|
||||
- Add loading states for all async operations.
|
||||
|
||||
### Features
|
||||
- Implement Organization editing (currently placeholder).
|
||||
- Implement Organization member management (add/remove members).
|
||||
- Implement User creation modal (currently placeholder).
|
||||
- Implement User editing (currently placeholder).
|
||||
- Implement detailed OAuth client management (scopes, secrets).
|
||||
@ -1,33 +1,62 @@
|
||||
import token from './token.js'
|
||||
export default ($env) => {
|
||||
token.wrapAxios($env.$axios)
|
||||
$env.$G.token = token
|
||||
let user = token.body()
|
||||
$env.$G.user = user
|
||||
|
||||
import VBase from './vbase.js'
|
||||
|
||||
export default async ($env) => {
|
||||
// Load i18n
|
||||
try {
|
||||
const langs = await (await fetch('/langs.json')).json()
|
||||
$env.$i18n.load(langs)
|
||||
} catch (e) {
|
||||
console.error('Failed to load langs.json', e)
|
||||
}
|
||||
|
||||
// Initialize VBase Service
|
||||
const vbase = new VBase(''); // Relative path
|
||||
$env.$vbase = vbase;
|
||||
|
||||
// Wrap Axios
|
||||
vbase.wrapAxios($env.$axios);
|
||||
|
||||
// Router Guard
|
||||
$env.$router.beforeEnter = async (to, from, next) => {
|
||||
if (to.meta && to.meta.auth) {
|
||||
if (token.isExpired()) {
|
||||
await token.refresh()
|
||||
const isAuth = to.meta && to.meta.auth;
|
||||
const isGuest = to.meta && to.meta.guest;
|
||||
const roles = to.meta && to.meta.roles; // Array of required roles
|
||||
|
||||
if (isAuth) {
|
||||
if (vbase.isExpired()) {
|
||||
try {
|
||||
await vbase.refresh();
|
||||
} catch (e) {
|
||||
vbase.logout(to.fullPath);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (token.isExpired()) {
|
||||
token.logout(to.fullPath)
|
||||
return false
|
||||
|
||||
if (!vbase.user) {
|
||||
try {
|
||||
await vbase.fetchUser();
|
||||
} catch (e) {
|
||||
vbase.logout(to.fullPath);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!token.check('app', '', 2)) {
|
||||
next('/')
|
||||
|
||||
// Role Check
|
||||
if (roles && roles.length > 0) {
|
||||
const hasRole = roles.some(role => vbase.hasRole(role));
|
||||
if (!hasRole) {
|
||||
$env.$router.push('/403');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} else if (isGuest) {
|
||||
if (!vbase.isExpired()) {
|
||||
next('/');
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
$env.$axios.interceptors.response.use(function(response) {
|
||||
return response?.data || response;
|
||||
}, function(error) {
|
||||
error = error?.response?.data || error?.response || error
|
||||
return Promise.reject(error);
|
||||
});
|
||||
$env.$axios.get('/api/cfg').then(res => {
|
||||
$env.$G.cfg = res
|
||||
console.log(res)
|
||||
})
|
||||
}
|
||||
|
||||
@ -0,0 +1,58 @@
|
||||
{
|
||||
"en-US": {
|
||||
"auth.email": "Email",
|
||||
"auth.login": "Login",
|
||||
"auth.logout": "Logout",
|
||||
"auth.password": "Password",
|
||||
"auth.register": "Register",
|
||||
"auth.username": "Username",
|
||||
"common.cancel": "Cancel",
|
||||
"common.confirm": "Confirm",
|
||||
"common.create": "Create",
|
||||
"common.delete": "Delete",
|
||||
"common.edit": "Edit",
|
||||
"common.forbidden": "Forbidden",
|
||||
"common.not_found": "Not Found",
|
||||
"common.save": "Save",
|
||||
"nav.dashboard": "Dashboard",
|
||||
"nav.home": "Home",
|
||||
"nav.oauth": "OAuth Apps",
|
||||
"nav.org": "Organizations",
|
||||
"nav.profile": "Profile",
|
||||
"nav.users": "Users",
|
||||
"org.create": "Create Organization",
|
||||
"org.detail": "Organization Detail",
|
||||
"org.info": "Information",
|
||||
"org.members": "Members",
|
||||
"org.name": "Organization Name",
|
||||
"user.profile": "User Profile"
|
||||
},
|
||||
"zh-CN": {
|
||||
"auth.email": "邮箱",
|
||||
"auth.login": "登录",
|
||||
"auth.logout": "登出",
|
||||
"auth.password": "密码",
|
||||
"auth.register": "注册",
|
||||
"auth.username": "用户名",
|
||||
"common.cancel": "取消",
|
||||
"common.confirm": "确认",
|
||||
"common.create": "创建",
|
||||
"common.delete": "删除",
|
||||
"common.edit": "编辑",
|
||||
"common.forbidden": "禁止访问",
|
||||
"common.not_found": "页面未找到",
|
||||
"common.save": "保存",
|
||||
"nav.dashboard": "仪表盘",
|
||||
"nav.home": "首页",
|
||||
"nav.oauth": "OAuth应用",
|
||||
"nav.org": "组织管理",
|
||||
"nav.profile": "个人中心",
|
||||
"nav.users": "用户管理",
|
||||
"org.create": "创建组织",
|
||||
"org.detail": "组织详情",
|
||||
"org.info": "基本信息",
|
||||
"org.members": "成员列表",
|
||||
"org.name": "组织名称",
|
||||
"user.profile": "个人资料"
|
||||
}
|
||||
}
|
||||
@ -1,10 +1,35 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<body style="height: 100%;width: 100%;">
|
||||
<vslot style="height: 100%;width: 100%;display: block;">
|
||||
Public Layout
|
||||
</vslot>
|
||||
<head>
|
||||
<meta name="description" content="Public Layout">
|
||||
<title>VBase</title>
|
||||
<style>
|
||||
.public-container {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
background-color: var(--bg-color-secondary);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.content-box {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 20px;
|
||||
background: #fff;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="public-container">
|
||||
<vslot class="content-box">
|
||||
</vslot>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@ -0,0 +1,41 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="description" content="403 Forbidden">
|
||||
<title>403 Forbidden</title>
|
||||
<style>
|
||||
.error-container {
|
||||
text-align: center;
|
||||
padding: 50px;
|
||||
}
|
||||
.error-code {
|
||||
font-size: 100px;
|
||||
font-weight: bold;
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.error-msg {
|
||||
font-size: 24px;
|
||||
margin-bottom: 20px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
.btn-home {
|
||||
padding: 10px 20px;
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: var(--radius-md);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.btn-home:hover {
|
||||
background-color: var(--color-primary-hover);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="error-container">
|
||||
<div class="error-code">403</div>
|
||||
<div class="error-msg">{{ $t('common.forbidden') || 'Access Denied' }}</div>
|
||||
<a href="/" class="btn-home">{{ $t('nav.home') || 'Go Home' }}</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,41 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="description" content="404 Not Found">
|
||||
<title>404 Not Found</title>
|
||||
<style>
|
||||
.error-container {
|
||||
text-align: center;
|
||||
padding: 50px;
|
||||
}
|
||||
.error-code {
|
||||
font-size: 100px;
|
||||
font-weight: bold;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.error-msg {
|
||||
font-size: 24px;
|
||||
margin-bottom: 20px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
.btn-home {
|
||||
padding: 10px 20px;
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: var(--radius-md);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.btn-home:hover {
|
||||
background-color: var(--color-primary-hover);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="error-container">
|
||||
<div class="error-code">404</div>
|
||||
<div class="error-msg">{{ $t('common.not_found') || 'Page Not Found' }}</div>
|
||||
<a href="/" class="btn-home">{{ $t('nav.home') || 'Go Home' }}</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,116 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta name="description" content="Login Page">
|
||||
<title>{{ $t('auth.login') }}</title>
|
||||
<style>
|
||||
.login-title {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-submit:hover {
|
||||
background-color: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
.links {
|
||||
margin-top: 15px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.links a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
color: var(--color-danger);
|
||||
text-align: center;
|
||||
margin-bottom: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
123
|
||||
<h2 class="login-title">{{ $t('auth.login') }}</h2>
|
||||
|
||||
<div v-if="error" class="error-msg">{{ error }}</div>
|
||||
|
||||
<form @submit.prevent="handleLogin">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ $t('auth.username') }}</label>
|
||||
<input type="text" v:value="username" class="form-input" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ $t('auth.password') }}</label>
|
||||
<input type="password" v:value="password" class="form-input" required>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-submit">{{ $t('auth.login') }}</button>
|
||||
</form>
|
||||
|
||||
<div class="links">
|
||||
<a href="/register">{{ $t('auth.register') }}</a>
|
||||
</div>
|
||||
</body>
|
||||
<script setup>
|
||||
username = "";
|
||||
password = "";
|
||||
error = "";
|
||||
|
||||
handleLogin = async (e) => {
|
||||
e.preventDefault();
|
||||
error = "";
|
||||
try {
|
||||
await $env.$vbase.login(username, password);
|
||||
$router.push('/');
|
||||
} catch (err) {
|
||||
error = err.message || "Login failed";
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
</html>
|
||||
@ -0,0 +1,130 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="description" content="Register Page">
|
||||
<title>{{ $t('auth.register') }}</title>
|
||||
<style>
|
||||
.register-title {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
}
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.btn-submit {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.btn-submit:hover {
|
||||
background-color: var(--color-primary-hover);
|
||||
}
|
||||
.links {
|
||||
margin-top: 15px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
.links a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
.error-msg {
|
||||
color: var(--color-danger);
|
||||
text-align: center;
|
||||
margin-bottom: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2 class="register-title">{{ $t('auth.register') }}</h2>
|
||||
|
||||
<div v-if="error" class="error-msg">{{ error }}</div>
|
||||
|
||||
<form @submit.prevent="handleRegister">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ $t('auth.username') }}</label>
|
||||
<input type="text" v:value="username" class="form-input" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ $t('auth.email') }}</label>
|
||||
<input type="email" v:value="email" class="form-input" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ $t('auth.password') }}</label>
|
||||
<input type="password" v:value="password" class="form-input" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ $t('common.confirm') }} {{ $t('auth.password') }}</label>
|
||||
<input type="password" v:value="confirmPassword" class="form-input" required>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-submit">{{ $t('auth.register') }}</button>
|
||||
</form>
|
||||
|
||||
<div class="links">
|
||||
<a href="/login">{{ $t('auth.login') }}</a>
|
||||
</div>
|
||||
</body>
|
||||
<script setup>
|
||||
username = "";
|
||||
email = "";
|
||||
password = "";
|
||||
confirmPassword = "";
|
||||
error = "";
|
||||
|
||||
handleRegister = async (e) => {
|
||||
e.preventDefault();
|
||||
error = "";
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
error = "Passwords do not match";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await $axios.post('/api/auth/register', {
|
||||
username: username,
|
||||
email: email,
|
||||
password: password
|
||||
});
|
||||
|
||||
// Redirect to login on success
|
||||
$router.push('/login');
|
||||
$message.success("Registration successful! Please login.");
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
error = err.message || "Registration failed";
|
||||
}
|
||||
};
|
||||
</script>
|
||||
</html>
|
||||
@ -0,0 +1,115 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="description" content="Dashboard">
|
||||
<title>{{ $t('nav.dashboard') }}</title>
|
||||
<style>
|
||||
.dashboard-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.stat-title {
|
||||
font-size: 14px;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.chart-container {
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-sm);
|
||||
height: 400px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="dashboard-container">
|
||||
<h1>{{ $t('nav.dashboard') }}</h1>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card" v-for="stat in stats">
|
||||
<div class="stat-title">{{ stat.title }}</div>
|
||||
<div class="stat-value">{{ stat.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-container" id="main-chart"></div>
|
||||
</div>
|
||||
</body>
|
||||
<script setup>
|
||||
stats = [
|
||||
{ title: "Total Users", value: "1,234" },
|
||||
{ title: "Active Orgs", value: "56" },
|
||||
{ title: "API Calls", value: "89.2k" },
|
||||
{ title: "Revenue", value: "$12,340" }
|
||||
];
|
||||
|
||||
// Mock data fetch
|
||||
fetchStats = async () => {
|
||||
// In real app, call API
|
||||
// const res = await $axios.get('/api/stats/dashboard');
|
||||
// stats = res;
|
||||
};
|
||||
|
||||
initChart = () => {
|
||||
const chartDom = $node.querySelector('#main-chart');
|
||||
if (!chartDom) return;
|
||||
|
||||
const myChart = echarts.init(chartDom);
|
||||
const option = {
|
||||
title: {
|
||||
text: 'Activity Trend'
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis'
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value'
|
||||
},
|
||||
series: [
|
||||
{
|
||||
data: [820, 932, 901, 934, 1290, 1330, 1320],
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
itemStyle: {
|
||||
color: getComputedStyle(document.documentElement).getPropertyValue('--color-primary').trim()
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
myChart.setOption(option);
|
||||
|
||||
// Resize chart on window resize
|
||||
window.addEventListener('resize', () => {
|
||||
myChart.resize();
|
||||
});
|
||||
};
|
||||
</script>
|
||||
<script>
|
||||
// Run after mount
|
||||
$data.initChart();
|
||||
</script>
|
||||
</html>
|
||||
@ -0,0 +1,128 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="description" content="OAuth Apps">
|
||||
<title>{{ $t('nav.oauth') }}</title>
|
||||
<style>
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.app-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
.app-card {
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.app-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.app-name {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.app-id {
|
||||
font-size: 12px;
|
||||
color: var(--text-color-secondary);
|
||||
font-family: monospace;
|
||||
background: var(--bg-color-tertiary);
|
||||
padding: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.app-redirect {
|
||||
font-size: 14px;
|
||||
color: var(--text-color-secondary);
|
||||
word-break: break-all;
|
||||
}
|
||||
.btn-create {
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-delete {
|
||||
background-color: var(--color-danger);
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page-header">
|
||||
<h1>{{ $t('nav.oauth') }}</h1>
|
||||
<button class="btn-create" @click="createApp">New App</button>
|
||||
</div>
|
||||
|
||||
<div class="app-grid">
|
||||
<div class="app-card" v-for="app in apps">
|
||||
<div class="app-header">
|
||||
<div class="app-name">{{ app.name }}</div>
|
||||
<button class="btn-delete" @click="deleteApp(app.id)">Delete</button>
|
||||
</div>
|
||||
<div class="app-id">ID: {{ app.client_id }}</div>
|
||||
<div class="app-redirect">Callback: {{ app.redirect_uri }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
<script setup>
|
||||
apps = [];
|
||||
|
||||
loadApps = async () => {
|
||||
try {
|
||||
const res = await $axios.get('/api/oauth/clients');
|
||||
apps = res || [];
|
||||
} catch (e) {
|
||||
$message.error(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
createApp = () => {
|
||||
$message.prompt("Enter App Name", "New App").then(async (name) => {
|
||||
if (!name) return;
|
||||
const uri = await $message.prompt("Enter Redirect URI", "http://localhost:3000/callback");
|
||||
if (!uri) return;
|
||||
|
||||
try {
|
||||
await $axios.post('/api/oauth/clients', { name: name, redirect_uri: uri });
|
||||
$message.success("Created");
|
||||
loadApps();
|
||||
} catch (e) {
|
||||
$message.error(e.message);
|
||||
}
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
deleteApp = async (id) => {
|
||||
try {
|
||||
await $message.confirm("Delete this app?");
|
||||
await $axios.delete(`/api/oauth/clients/${id}`);
|
||||
$message.success("Deleted");
|
||||
loadApps();
|
||||
} catch (e) {
|
||||
// Cancelled
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<script>
|
||||
$data.loadApps();
|
||||
</script>
|
||||
</html>
|
||||
@ -0,0 +1,165 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="description" content="Org Detail">
|
||||
<title>{{ $t('org.detail') }}</title>
|
||||
<style>
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.section {
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-sm);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 15px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
.info-label {
|
||||
font-size: 14px;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
.info-value {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.btn-danger {
|
||||
background-color: var(--color-danger);
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
.btn-edit {
|
||||
background-color: var(--color-info);
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
th, td {
|
||||
text-align: left;
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
th {
|
||||
background-color: var(--bg-color-tertiary);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page-header">
|
||||
<h1>{{ org ? org.name : 'Loading...' }}</h1>
|
||||
<div class="actions">
|
||||
<button class="btn-edit" @click="editOrg">{{ $t('common.edit') }}</button>
|
||||
<button class="btn-danger" @click="deleteOrg">{{ $t('common.delete') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">{{ $t('org.info') }}</div>
|
||||
<div class="info-grid" v-if="org">
|
||||
<div class="info-item">
|
||||
<span class="info-label">ID</span>
|
||||
<span class="info-value">{{ org.id }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Name</span>
|
||||
<span class="info-value">{{ org.name }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Created At</span>
|
||||
<span class="info-value">{{ new Date(org.created_at).toLocaleDateString() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">{{ $t('org.members') }}</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Role</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="member in members">
|
||||
<td>{{ member.username }}</td>
|
||||
<td>{{ member.role }}</td>
|
||||
<td>
|
||||
<button class="btn-sm btn-danger" @click="removeMember(member.id)">Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</body>
|
||||
<script setup>
|
||||
orgId = $router.params.id;
|
||||
org = null;
|
||||
members = [];
|
||||
|
||||
loadData = async () => {
|
||||
try {
|
||||
const [orgRes, membersRes] = await Promise.all([
|
||||
$axios.get(`/api/orgs/${orgId}`),
|
||||
$axios.get(`/api/orgs/${orgId}/members`)
|
||||
]);
|
||||
org = orgRes;
|
||||
members = membersRes || [];
|
||||
} catch (e) {
|
||||
$message.error(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
editOrg = () => {
|
||||
$message.info("Edit feature coming soon");
|
||||
};
|
||||
|
||||
deleteOrg = async () => {
|
||||
try {
|
||||
await $message.confirm("Are you sure you want to delete this organization?");
|
||||
await $axios.delete(`/api/orgs/${orgId}`);
|
||||
$message.success("Deleted");
|
||||
$router.push('/org');
|
||||
} catch (e) {
|
||||
// Cancelled or error
|
||||
}
|
||||
};
|
||||
|
||||
removeMember = async (userId) => {
|
||||
// Implement remove logic
|
||||
$message.info("Remove member feature coming soon");
|
||||
};
|
||||
</script>
|
||||
<script>
|
||||
$data.loadData();
|
||||
</script>
|
||||
</html>
|
||||
@ -0,0 +1,148 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="description" content="Org List">
|
||||
<title>{{ $t('nav.org') }}</title>
|
||||
<style>
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.org-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
.org-card {
|
||||
background: #fff;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.org-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
.org-name {
|
||||
font-weight: bold;
|
||||
font-size: 18px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
.org-meta {
|
||||
font-size: 14px;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
.btn-create {
|
||||
padding: 8px 16px;
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
}
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.modal-content {
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
border-radius: var(--radius-lg);
|
||||
width: 400px;
|
||||
max-width: 90%;
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page-header">
|
||||
<h1>{{ $t('nav.org') }}</h1>
|
||||
<button class="btn-create" @click="openCreateModal">{{ $t('common.create') }}</button>
|
||||
</div>
|
||||
|
||||
<div class="org-grid">
|
||||
<div class="org-card" v-for="org in orgs" @click="goToDetail(org.id)">
|
||||
<div class="org-name">{{ org.name }}</div>
|
||||
<div class="org-meta">ID: {{ org.id }}</div>
|
||||
<div class="org-meta">Role: {{ org.role || 'Member' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Modal -->
|
||||
<div class="modal-overlay" v-if="showCreateModal">
|
||||
<div class="modal-content">
|
||||
<h3>{{ $t('org.create') }}</h3>
|
||||
<div class="form-group">
|
||||
<label>{{ $t('org.name') }}</label>
|
||||
<input type="text" v:value="newOrgName" class="form-input" style="width: 100%; margin-top: 5px;">
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button @click="closeCreateModal" class="btn-cancel">{{ $t('common.cancel') }}</button>
|
||||
<button @click="createOrg" class="btn-confirm">{{ $t('common.confirm') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
<script setup>
|
||||
orgs = [];
|
||||
showCreateModal = false;
|
||||
newOrgName = "";
|
||||
|
||||
loadOrgs = async () => {
|
||||
try {
|
||||
const res = await $axios.get('/api/orgs');
|
||||
orgs = res || [];
|
||||
} catch (e) {
|
||||
$message.error(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
openCreateModal = () => {
|
||||
showCreateModal = true;
|
||||
newOrgName = "";
|
||||
};
|
||||
|
||||
closeCreateModal = () => {
|
||||
showCreateModal = false;
|
||||
};
|
||||
|
||||
createOrg = async () => {
|
||||
if (!newOrgName) return;
|
||||
try {
|
||||
await $axios.post('/api/orgs', { name: newOrgName });
|
||||
$message.success("Created successfully");
|
||||
closeCreateModal();
|
||||
loadOrgs();
|
||||
} catch (e) {
|
||||
$message.error(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
goToDetail = (id) => {
|
||||
$router.push('/org/' + id);
|
||||
};
|
||||
</script>
|
||||
<script>
|
||||
$data.loadOrgs();
|
||||
</script>
|
||||
</html>
|
||||
@ -0,0 +1,110 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="description" content="User Management">
|
||||
<title>{{ $t('nav.users') }}</title>
|
||||
<style>
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: #fff;
|
||||
box-shadow: var(--shadow-sm);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
th, td {
|
||||
text-align: left;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
th {
|
||||
background-color: var(--bg-color-tertiary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-action {
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
margin-right: 5px;
|
||||
}
|
||||
.btn-edit {
|
||||
background-color: var(--color-info);
|
||||
color: white;
|
||||
}
|
||||
.btn-delete {
|
||||
background-color: var(--color-danger);
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page-header">
|
||||
<h1>{{ $t('nav.users') }}</h1>
|
||||
<button class="btn-action btn-edit" @click="createUser">Create User</button>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Username</th>
|
||||
<th>Email</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="u in users">
|
||||
<td>{{ u.id }}</td>
|
||||
<td>{{ u.username }}</td>
|
||||
<td>{{ u.email }}</td>
|
||||
<td>{{ u.status || 'Active' }}</td>
|
||||
<td>
|
||||
<button class="btn-action btn-edit" @click="editUser(u)">Edit</button>
|
||||
<button class="btn-action btn-delete" @click="deleteUser(u.id)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
<script setup>
|
||||
users = [];
|
||||
|
||||
loadUsers = async () => {
|
||||
try {
|
||||
const res = await $axios.get('/api/users');
|
||||
users = res || [];
|
||||
} catch (e) {
|
||||
$message.error(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
createUser = () => {
|
||||
$message.info("Create User Modal coming soon");
|
||||
};
|
||||
|
||||
editUser = (u) => {
|
||||
$message.info("Edit User " + u.username);
|
||||
};
|
||||
|
||||
deleteUser = async (id) => {
|
||||
try {
|
||||
await $message.confirm("Delete user?");
|
||||
await $axios.delete(`/api/users/${id}`);
|
||||
$message.success("Deleted");
|
||||
loadUsers();
|
||||
} catch (e) {
|
||||
// Cancelled
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<script>
|
||||
$data.loadUsers();
|
||||
</script>
|
||||
</html>
|
||||
@ -0,0 +1,133 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="description" content="User Profile">
|
||||
<title>{{ $t('user.profile') }}</title>
|
||||
<style>
|
||||
.profile-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background: #fff;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.avatar-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.avatar {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--color-primary-light);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 32px;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.btn-save {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
}
|
||||
.btn-logout {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background-color: var(--color-danger);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="profile-container">
|
||||
<h2 style="text-align: center; margin-bottom: 30px;">{{ $t('user.profile') }}</h2>
|
||||
|
||||
<div class="avatar-section">
|
||||
<div class="avatar">
|
||||
{{ user.username ? user.username.charAt(0).toUpperCase() : 'U' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="updateProfile">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ $t('auth.username') }}</label>
|
||||
<input type="text" v:value="user.username" class="form-input" disabled>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ $t('auth.email') }}</label>
|
||||
<input type="email" v:value="user.email" class="form-input">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Phone</label>
|
||||
<input type="tel" v:value="user.phone" class="form-input">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-save">{{ $t('common.save') }}</button>
|
||||
</form>
|
||||
|
||||
<button class="btn-logout" @click="handleLogout">{{ $t('auth.logout') }}</button>
|
||||
</div>
|
||||
</body>
|
||||
<script setup>
|
||||
user = $env.$vbase.user || {};
|
||||
|
||||
// Fetch fresh data
|
||||
loadUser = async () => {
|
||||
try {
|
||||
user = await $env.$vbase.fetchUser();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
updateProfile = async () => {
|
||||
try {
|
||||
await $axios.patch('/api/auth/me', {
|
||||
email: user.email,
|
||||
phone: user.phone
|
||||
});
|
||||
$message.success("Profile updated");
|
||||
loadUser();
|
||||
} catch (e) {
|
||||
$message.error(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
handleLogout = async () => {
|
||||
await $env.$vbase.logout('/login');
|
||||
};
|
||||
</script>
|
||||
<script>
|
||||
$data.loadUser();
|
||||
</script>
|
||||
</html>
|
||||
@ -1,25 +1,40 @@
|
||||
/*
|
||||
* routes.js
|
||||
* Copyright (C) 2025 veypi <i@veypi.com>
|
||||
*
|
||||
* Distributed under terms of the MIT license.
|
||||
*/
|
||||
|
||||
const routes = [
|
||||
{ path: '/', component: '/page/index.html', name: 'home', layout: 'public' },
|
||||
{ path: '/login', component: '/page/login.html', name: 'login', meta: { auth: false } },
|
||||
{ path: '/profile', component: '/page/profile.html', layout: 'default', meta: { auth: true } },
|
||||
{ path: '/app', component: '/page/app.html', name: 'app', layout: 'default', meta: { auth: true } },
|
||||
{ path: '/settings', component: '/page/settings.html', layout: 'default', meta: { auth: true } },
|
||||
// Public
|
||||
{ path: '/login', component: '/page/auth/login.html', layout: 'public', meta: { guest: true } },
|
||||
{ path: '/register', component: '/page/auth/register.html', layout: 'public', meta: { guest: true } },
|
||||
|
||||
// Dashboard (Default Layout)
|
||||
{
|
||||
path: '/',
|
||||
layout: 'default',
|
||||
meta: { auth: true },
|
||||
component: '/page/dashboard/index.html'
|
||||
},
|
||||
|
||||
// Org Management
|
||||
{ path: '/org', component: '/page/sys/org/index.html', layout: 'default', meta: { auth: true } },
|
||||
{
|
||||
path: '/app/:id', layout: 'app', meta: { auth: true },
|
||||
children: [
|
||||
{ path: '/', component: '/page/app/index.html' },
|
||||
{ path: '/user', component: '/page/app/user.html' },
|
||||
{ path: '/auth', component: '/page/app/auth.html' },
|
||||
{ path: '/settings', component: '/page/app/settings.html' },
|
||||
]
|
||||
path: '/org/:id',
|
||||
component: '/page/sys/org/detail.html',
|
||||
layout: 'default',
|
||||
meta: { auth: true }
|
||||
},
|
||||
{ path: '*', component: '/page/404.html', name: '404' },
|
||||
|
||||
// User System
|
||||
{ path: '/profile', component: '/page/user/profile.html', layout: 'default', meta: { auth: true } },
|
||||
{
|
||||
path: '/users',
|
||||
component: '/page/sys/user/index.html',
|
||||
layout: 'default',
|
||||
meta: { auth: true, roles: ['admin'] }
|
||||
},
|
||||
|
||||
// OAuth Management
|
||||
{ path: '/oauth/apps', component: '/page/sys/oauth/index.html', layout: 'default', meta: { auth: true } },
|
||||
|
||||
// Errors
|
||||
{ path: '/403', component: '/page/403.html', layout: 'public' },
|
||||
{ path: '*', component: '/page/404.html', layout: 'public' }
|
||||
]
|
||||
|
||||
export default routes
|
||||
|
||||
@ -0,0 +1,217 @@
|
||||
|
||||
class VBase {
|
||||
constructor(baseURL) {
|
||||
this.baseURL = baseURL || '';
|
||||
this.tokenKey = 'vbase_access_token';
|
||||
this.refreshTokenKey = 'vbase_refresh_token';
|
||||
this.userKey = 'vbase_user_info';
|
||||
this.orgKey = 'vbase_current_org';
|
||||
|
||||
this._token = localStorage.getItem(this.tokenKey) || '';
|
||||
this._refreshToken = localStorage.getItem(this.refreshTokenKey) || '';
|
||||
this._user = JSON.parse(localStorage.getItem(this.userKey) || 'null');
|
||||
this._currentOrg = JSON.parse(localStorage.getItem(this.orgKey) || 'null');
|
||||
}
|
||||
|
||||
// Getters
|
||||
get token() { return this._token; }
|
||||
get refreshToken() { return this._refreshToken; }
|
||||
get user() { return this._user; }
|
||||
get currentOrg() { return this._currentOrg; }
|
||||
|
||||
// Setters
|
||||
set token(val) {
|
||||
this._token = val;
|
||||
if (val) localStorage.setItem(this.tokenKey, val);
|
||||
else localStorage.removeItem(this.tokenKey);
|
||||
}
|
||||
|
||||
set refreshToken(val) {
|
||||
this._refreshToken = val;
|
||||
if (val) localStorage.setItem(this.refreshTokenKey, val);
|
||||
else localStorage.removeItem(this.refreshTokenKey);
|
||||
}
|
||||
|
||||
set user(val) {
|
||||
this._user = val;
|
||||
if (val) localStorage.setItem(this.userKey, JSON.stringify(val));
|
||||
else localStorage.removeItem(this.userKey);
|
||||
}
|
||||
|
||||
set currentOrg(val) {
|
||||
this._currentOrg = val;
|
||||
if (val) localStorage.setItem(this.orgKey, JSON.stringify(val));
|
||||
else localStorage.removeItem(this.orgKey);
|
||||
}
|
||||
|
||||
// API Helpers
|
||||
async request(method, path, data = null, headers = {}) {
|
||||
const url = `${this.baseURL}${path}`;
|
||||
const config = {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...this.getAuthHeaders(),
|
||||
...headers
|
||||
}
|
||||
};
|
||||
if (data) config.body = JSON.stringify(data);
|
||||
|
||||
const response = await fetch(url, config);
|
||||
const resData = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw resData || new Error(`Request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
if (resData.code && resData.code !== 200) {
|
||||
throw new Error(resData.message || 'API Error');
|
||||
}
|
||||
|
||||
return resData.data || resData;
|
||||
}
|
||||
|
||||
// Auth Actions
|
||||
async login(username, password) {
|
||||
try {
|
||||
const data = await this.request('POST', '/api/auth/login', { username, password });
|
||||
if (data.access) {
|
||||
this.token = data.access;
|
||||
if (data.refresh) this.refreshToken = data.refresh;
|
||||
await this.fetchUser();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async logout(redirect) {
|
||||
try {
|
||||
// Optional: Call server logout
|
||||
// await this.request('POST', '/api/auth/logout');
|
||||
} catch (e) {
|
||||
console.warn('Logout API failed', e);
|
||||
} finally {
|
||||
this.clear();
|
||||
if (redirect) {
|
||||
location.href = redirect;
|
||||
} else {
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
if (!this.refreshToken) throw new Error("No refresh token");
|
||||
try {
|
||||
const data = await this.request('POST', '/api/auth/refresh', { refresh: this.refreshToken });
|
||||
if (data.access) {
|
||||
this.token = data.access;
|
||||
if (data.refresh) this.refreshToken = data.refresh;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
this.logout();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchUser() {
|
||||
const user = await this.request('GET', '/api/auth/me');
|
||||
this.user = user;
|
||||
return user;
|
||||
}
|
||||
|
||||
// Auth Headers
|
||||
getAuthHeaders() {
|
||||
const headers = {};
|
||||
if (this.token) {
|
||||
headers['Authorization'] = `Bearer ${this.token}`;
|
||||
}
|
||||
if (this.currentOrg && this.currentOrg.id) {
|
||||
headers['X-Org-ID'] = this.currentOrg.id;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
// Permission Check
|
||||
hasPermission(permission) {
|
||||
if (!this.user) return false;
|
||||
if (this.user.is_admin) return true;
|
||||
if (!permission) return true;
|
||||
const userPerms = this.user.permissions || [];
|
||||
return userPerms.includes(permission);
|
||||
}
|
||||
|
||||
hasRole(role) {
|
||||
if (!this.user) return false;
|
||||
if (this.user.is_admin) return true;
|
||||
const userRoles = this.user.roles || [];
|
||||
return userRoles.includes(role);
|
||||
}
|
||||
|
||||
// State Management
|
||||
clear() {
|
||||
this.token = '';
|
||||
this.refreshToken = '';
|
||||
this.user = null;
|
||||
this.currentOrg = null;
|
||||
}
|
||||
|
||||
isExpired(token) {
|
||||
if (!token) token = this.token;
|
||||
if (!token) return true;
|
||||
try {
|
||||
const base64Url = token.split('.')[1];
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const payload = JSON.parse(window.atob(base64));
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return payload.exp && payload.exp < now;
|
||||
} catch (e) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
wrapAxios(axiosInstance) {
|
||||
// Request Interceptor
|
||||
axiosInstance.interceptors.request.use(config => {
|
||||
const headers = this.getAuthHeaders();
|
||||
for (const key in headers) {
|
||||
config.headers[key] = headers[key];
|
||||
}
|
||||
return config;
|
||||
}, error => Promise.reject(error));
|
||||
|
||||
// Response Interceptor
|
||||
axiosInstance.interceptors.response.use(response => {
|
||||
const res = response.data;
|
||||
if (res && res.code === 200) {
|
||||
return res.data;
|
||||
}
|
||||
if (res && res.code && res.code !== 200) {
|
||||
return Promise.reject(new Error(res.message || 'Error'));
|
||||
}
|
||||
return res || response;
|
||||
}, async error => {
|
||||
const originalRequest = error.config;
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
try {
|
||||
await this.refresh();
|
||||
const headers = this.getAuthHeaders();
|
||||
originalRequest.headers['Authorization'] = headers['Authorization'];
|
||||
return axiosInstance(originalRequest);
|
||||
} catch (e) {
|
||||
this.logout(window.location.pathname);
|
||||
return Promise.reject(e);
|
||||
}
|
||||
}
|
||||
return Promise.reject(error?.response?.data || error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default VBase;
|
||||
Loading…
Reference in New Issue