refactor: 重构用户/组织/OAuth页面UI

v3
veypi 1 week ago
parent d85cb6ae84
commit 4101daeed3

@ -13,7 +13,7 @@ export default async ($env) => {
// Initialize VBase Service
const vbase = new VBase(''); // Relative path
$env.$vbase = vbase;
// Wrap Axios
vbase.wrapAxios($env.$axios);
@ -26,32 +26,33 @@ export default async ($env) => {
if (isAuth) {
if (vbase.isExpired()) {
try {
await vbase.refresh();
await vbase.refresh();
} catch (e) {
vbase.logout('/login?redirect=' + encodeURIComponent(to.fullPath));
return false;
vbase.logout('/login?redirect=' + encodeURIComponent(to.fullPath));
return false;
}
}
if (!vbase.user) {
try {
await vbase.fetchUser();
} catch (e) {
vbase.logout('/login?redirect=' + encodeURIComponent(to.fullPath));
return false;
}
try {
await vbase.fetchUser();
} catch (e) {
vbase.logout('/login?redirect=' + encodeURIComponent(to.fullPath));
return false;
}
}
// Role Check
if (roles && roles.length > 0) {
const hasRole = roles.some(role => vbase.hasRole(role));
if (!hasRole) {
$env.$router.push('/403');
return false;
}
const hasRole = roles.some(role => vbase.hasRole(role));
console.log(roles, hasRole, vbase.user)
if (!hasRole) {
// $env.$router.push('/403');
// return false;
}
}
}
next();
};
}

@ -34,8 +34,8 @@
<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 class="error-msg">{{ $t('common.forbidden') }}</div>
<a href="/" class="btn-home">{{ $t('nav.home') }}</a>
</div>
</body>
</html>

@ -34,8 +34,8 @@
<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 class="error-msg">{{ $t('common.not_found') }}</div>
<a href="/" class="btn-home">{{ $t('nav.home') }}</a>
</div>
</body>
</html>

@ -13,45 +13,6 @@
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;
@ -77,18 +38,11 @@
<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 @submit.prevent="handleLogin" style="display: grid; gap: 16px;">
<v-input label="Username" v:value="username" required placeholder="Enter username"></v-input>
<v-input label="Password" type="password" v:value="password" required placeholder="Enter password"></v-input>
<v-btn type="submit" color="primary" block style="margin-top: 8px;">{{ $t('auth.login') }}</v-btn>
</form>
<div class="links">

@ -11,39 +11,6 @@
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;
@ -66,28 +33,13 @@
<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>
<form @submit.prevent="handleRegister" style="display: grid; gap: 16px;">
<v-input :label="$t('auth.username')" v:value="username" required></v-input>
<v-input :label="$t('auth.email')" type="email" v:value="email" required></v-input>
<v-input :label="$t('auth.password')" type="password" v:value="password" required></v-input>
<v-input :label="$t('common.confirm') + ' ' + $t('auth.password')" type="password" v:value="confirmPassword" required></v-input>
<button type="submit" class="btn-submit">{{ $t('auth.register') }}</button>
<v-btn type="submit" color="primary" block style="margin-top: 8px;">{{ $t('auth.register') }}</v-btn>
</form>
<div class="links">
@ -101,8 +53,7 @@
confirmPassword = "";
error = "";
handleRegister = async (e) => {
e.preventDefault();
handleRegister = async () => {
error = "";
if (password !== confirmPassword) {
@ -117,9 +68,8 @@
password: password
});
// Redirect to login on success
$message.success($t('auth.register_success'));
$router.push('/login');
$message.success("Registration successful! Please login.");
} catch (err) {
console.error(err);

@ -1,128 +1,241 @@
<!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>
<meta name="description" content="OAuth Apps">
<title>{{ $t('nav.oauth') }}</title>
<style>
body {
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
padding: var(--spacing-lg);
box-sizing: border-box;
background-color: var(--bg-color);
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: var(--spacing-md);
padding-bottom: var(--spacing-md);
border-bottom: 1px solid var(--border-color);
}
.page-title {
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-bold);
color: var(--text-color);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.app-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: var(--spacing-lg);
overflow-y: auto;
padding: 4px;
}
.app-card {
background: var(--bg-color-secondary);
padding: var(--spacing-lg);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
display: flex;
flex-direction: column;
gap: var(--spacing-md);
border: 1px solid var(--border-color);
transition: all var(--transition-base);
}
.app-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
border-color: var(--color-primary);
}
.app-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.app-name {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-bold);
color: var(--color-primary);
}
.info-row {
display: flex;
flex-direction: column;
gap: 4px;
}
.info-label {
font-size: var(--font-size-xs);
color: var(--text-color-tertiary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.info-value {
font-size: var(--font-size-sm);
color: var(--text-color-secondary);
font-family: monospace;
background: var(--bg-color-tertiary);
padding: 6px;
border-radius: var(--radius-sm);
word-break: break-all;
}
.actions {
display: flex;
justify-content: flex-end;
gap: var(--spacing-sm);
margin-top: auto;
padding-top: var(--spacing-md);
border-top: 1px solid var(--border-color);
}
</style>
</head>
<body>
<div class="page-header">
<h1>{{ $t('nav.oauth') }}</h1>
<button class="btn-create" @click="createApp">New App</button>
<div class="page-header">
<div class="page-title">
<i class="fas fa-key" style="color: var(--color-primary);"></i>
{{ $t('nav.oauth') }}
</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>
<v-btn color="primary" :click="openCreateModal">
<i class="fas fa-plus"></i>
{{ $t('oauth.create_app') }}
</v-btn>
</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>
<div class="info-value" style="font-size: 10px; background: transparent; padding: 0;">{{ app.id }}</div>
</div>
<div class="info-row">
<span class="info-label">Client ID</span>
<div class="info-value">{{ app.client_id }}</div>
</div>
<div class="info-row">
<span class="info-label">Redirect URI</span>
<div class="info-value">{{ app.redirect_uri }}</div>
</div>
<div class="actions">
<v-btn icon size="sm" variant="outline" :click="() => openEditModal(app)" title="Edit">
<i class="fas fa-edit"></i>
</v-btn>
<v-btn icon size="sm" color="danger" variant="outline" :click="() => deleteApp(app)" title="Delete">
<i class="fas fa-trash"></i>
</v-btn>
</div>
</div>
</div>
<!-- Create/Edit Dialog -->
<v-dialog v:visible="showModal"
:title="isEdit ? $t('oauth.edit') : $t('oauth.create')">
<form @submit.prevent="saveApp" style="display: grid; gap: 16px;">
<v-input label="App Name" required v:value="formData.name" placeholder="e.g. My Awesome App"></v-input>
<v-input label="Redirect URI" required v:value="formData.redirect_uri"
placeholder="e.g. http://localhost:3000/callback"></v-input>
</form>
<div vslot="footer">
<v-btn variant="outline" :click="closeModal">{{ $t('common.cancel') }}</v-btn>
<v-btn color="primary" :click="saveApp">
{{ isEdit ? $t('common.save') : $t('common.create') }}
</v-btn>
</div>
</v-dialog>
</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
}
};
apps = [];
showModal = false;
isEdit = false;
formData = {
id: null,
name: "",
redirect_uri: ""
};
loadApps = async () => {
try {
const res = await $axios.get('/api/oauth/clients');
apps = res || [];
} catch (e) {
$message.error(e.message);
}
};
openCreateModal = () => {
isEdit = false;
formData = { id: null, name: "", redirect_uri: "http://localhost:3000/callback" };
showModal = true;
};
openEditModal = (app) => {
isEdit = true;
formData = { ...app };
showModal = true;
};
closeModal = () => {
showModal = false;
};
saveApp = async () => {
if (!formData.name || !formData.redirect_uri) {
$message.error("Name and Redirect URI are required");
return;
}
try {
if (isEdit) {
await $axios.patch(`/api/oauth/clients/${formData.id}`, {
name: formData.name,
redirect_uri: formData.redirect_uri
});
$message.success("App updated");
} else {
await $axios.post('/api/oauth/clients', {
name: formData.name,
redirect_uri: formData.redirect_uri
});
$message.success("App created");
}
closeModal();
loadApps();
} catch (e) {
$message.error(e.message);
}
};
deleteApp = async (app) => {
try {
await $message.confirm(`Delete app "${app.name}"?`);
await $axios.delete(`/api/oauth/clients/${app.id}`);
$message.success("Deleted");
loadApps();
} catch (e) {
// Cancelled
}
};
</script>
<script>
$data.loadApps();
$data.loadApps();
</script>
</html>

@ -3,7 +3,7 @@
<head>
<meta name="description" content="Org Detail">
<title>{{ org ? org.name : ($t('org.detail') || 'Organization Detail') }}</title>
<title>{{ org ? org.name : $t('org.detail') }}</title>
<style>
body {
display: flex;
@ -228,26 +228,26 @@
<!-- Loading State -->
<div class="loading-state" v-if="loading">
<div class="spinner"></div>
<span>{{ $t('common.loading') || 'Loading...' }}</span>
<span>{{ $t('common.loading') }}</span>
</div>
<template v:if="!loading && org">
<!-- Page Header -->
<div class="page-header">
<div class="header-left">
<button class="btn-back" @click="goBack" title="{{ $t('common.back') || 'Back' }}">
<button class="btn-back" @click="goBack" title="{{ $t('common.back') }}">
<i class="fas fa-arrow-left"></i>
</button>
<h1>{{ $t('org.detail') || 'Organization Detail' }}</h1>
<h1>{{ $t('org.detail') }}</h1>
</div>
<div class="header-actions">
<v-btn variant="outline" :click="openEditModal">
<i class="fas fa-edit"></i>
{{ $t('common.edit') || 'Edit' }}
{{ $t('common.edit') }}
</v-btn>
<v-btn color="danger" :click="deleteOrg">
<i class="fas fa-trash"></i>
{{ $t('common.delete') || 'Delete' }}
{{ $t('common.delete') }}
</v-btn>
</div>
</div>
@ -258,7 +258,7 @@
<div class="org-icon-large">{{ org.name ? org.name.charAt(0).toUpperCase() : 'O' }}</div>
<div class="org-header-text">
<div class="org-header-name">{{ org.name }}</div>
<div class="org-header-desc">{{ org.description || ($t('org.no_description') || 'No description') }}</div>
<div class="org-header-desc">{{ org.description || $t('org.no_description') }}</div>
</div>
</div>
@ -268,11 +268,11 @@
<span class="info-value">{{ org.id }}</span>
</div>
<div class="info-item">
<span class="info-label">{{ $t('org.created_at') || 'Created At' }}</span>
<span class="info-label">{{ $t('org.created_at') }}</span>
<span class="info-value">{{ formatDate(org.created_at) }}</span>
</div>
<div class="info-item" v:if="org.updated_at">
<span class="info-label">{{ $t('org.updated_at') || 'Updated At' }}</span>
<span class="info-label">{{ $t('org.updated_at') }}</span>
<span class="info-value">{{ formatDate(org.updated_at) }}</span>
</div>
</div>
@ -283,23 +283,23 @@
<div class="members-header">
<div class="section-title">
<i class="fas fa-users"></i>
{{ $t('org.members') || 'Members' }}
{{ $t('org.members') }}
<span style="font-size: var(--font-size-sm); color: var(--text-color-tertiary);">({{ members.length }})</span>
</div>
</div>
<div class="empty-state" v:if="members.length === 0">
<i class="fas fa-user-slash"></i>
<p>{{ $t('org.no_members') || 'No members yet' }}</p>
<p>{{ $t('org.no_members') }}</p>
</div>
<table v:if="members.length > 0">
<thead>
<tr>
<th>{{ $t('user.username') || 'Username' }}</th>
<th>{{ $t('user.email') || 'Email' }}</th>
<th>{{ $t('user.role') || 'Role' }}</th>
<th>{{ $t('common.actions') || 'Actions' }}</th>
<th>{{ $t('user.username') }}</th>
<th>{{ $t('user.email') }}</th>
<th>{{ $t('user.role') }}</th>
<th>{{ $t('common.actions') }}</th>
</tr>
</thead>
<tbody>
@ -315,10 +315,10 @@
<v-btn size="sm" color="danger" variant="outline" :click="() => removeMember(member)"
v:if="member.id !== currentUserId">
<i class="fas fa-user-minus"></i>
{{ $t('org.remove_member') || 'Remove' }}
{{ $t('org.remove_member') }}
</v-btn>
<span v:else style="color: var(--text-color-tertiary); font-size: var(--font-size-sm);">
{{ $t('org.you') || 'You' }}
{{ $t('org.you') }}
</span>
</td>
</tr>
@ -328,17 +328,17 @@
</template>
<!-- Edit Dialog -->
<v-dialog v:visible="showEditModal" title="{{ $t('org.edit') || 'Edit Organization' }}">
<v-input type="text" v:value="editForm.name" label="{{ $t('org.name') || 'Organization Name' }}" required
placeholder="{{ $t('org.name_placeholder') || 'Enter organization name' }}">
<v-dialog v:visible="showEditModal" title="{{ $t('org.edit') }}">
<v-input type="text" v:value="editForm.name" label="{{ $t('org.name') }}" required
placeholder="{{ $t('org.name_placeholder') }}">
</v-input>
<v-input type="textarea" v:value="editForm.description" label="{{ $t('org.description') || 'Description' }}"
placeholder="{{ $t('org.desc_placeholder') || 'Enter organization description (optional)' }}">
<v-input type="textarea" v:value="editForm.description" label="{{ $t('org.description') }}"
placeholder="{{ $t('org.desc_placeholder') }}">
</v-input>
<div vslot="footer">
<v-btn variant="outline" :click="closeEditModal">{{ $t('common.cancel') || 'Cancel' }}</v-btn>
<v-btn variant="outline" :click="closeEditModal">{{ $t('common.cancel') }}</v-btn>
<v-btn color="primary" :disabled="!editForm.name" :click="saveOrg">
{{ $t('common.save') || 'Save' }}
{{ $t('common.save') }}
</v-btn>
</div>
</v-dialog>
@ -406,7 +406,7 @@
name: editForm.name,
description: editForm.description
});
$message.success($t('org.updated') || "Updated successfully");
$message.success($t('org.updated'));
closeEditModal();
loadData();
} catch (e) {
@ -416,9 +416,9 @@
deleteOrg = async () => {
try {
await $message.confirm($t('org.delete_confirm') || `Are you sure you want to delete "${org.name}"?`);
await $message.confirm($t('org.delete_confirm'));
await $axios.delete(`/api/orgs/${orgId}`);
$message.success($t('org.deleted') || "Deleted successfully");
$message.success($t('org.deleted'));
$router.push('/org');
} catch (e) {
// Cancelled
@ -427,15 +427,15 @@
removeMember = async (member) => {
try {
await $message.confirm($t('org.remove_confirm') || `Remove "${member.username}" from organization?`);
await $message.confirm($t('org.remove_confirm'));
// API might not support this yet, but prepare for it
await $axios.delete(`/api/orgs/${orgId}/members/${member.id}`);
$message.success($t('org.member_removed') || "Member removed");
$message.success($t('org.member_removed'));
loadData();
} catch (e) {
// Cancelled or not implemented
if (e.status === 404) {
$message.info($t('org.feature_coming') || "Feature coming soon");
$message.info($t('org.feature_coming'));
}
}
};

@ -258,7 +258,7 @@
<div class="search-box">
<i class="fas fa-search" style="color: var(--text-color-tertiary);"></i>
<input type="text" v:value="searchQuery"
:placeholder="$t('org.search_placeholder') || 'Search by name, code...'">
:placeholder="$t('org.search_placeholder')">
</div>
<v-btn color="primary" :click="openCreateModal">
<i class="fas fa-plus"></i>
@ -270,23 +270,23 @@
<!-- Loading State -->
<div class="loading-state" v-if="loading">
<div class="spinner"></div>
<span>{{ $t('common.loading') || 'Loading organizations...' }}</span>
<span>{{ $t('common.loading') }}</span>
</div>
<!-- Empty State -->
<div class="empty-state" v-if="!loading && filteredOrgs().length === 0">
<i class="fas fa-building empty-icon"></i>
<h3>{{ $t('org.no_orgs') || 'No Organizations Found' }}</h3>
<p>{{ $t('org.no_orgs_desc') || 'Get started by creating your first organization.' }}</p>
<h3>{{ $t('org.no_orgs') }}</h3>
<p>{{ $t('org.no_orgs_desc') }}</p>
<v-btn color="primary" :click="openCreateModal" v-if="searchQuery === ''" style="margin-top: var(--spacing-md);">
<i class="fas fa-plus"></i>
{{ $t('org.create_first') || 'Create Organization' }}
{{ $t('org.create_first') }}
</v-btn>
</div>
<!-- Org Grid -->
<div class="org-grid" v-if="!loading && filteredOrgs().length > 0">
<div class="org-card" v-for="org in filteredOrgs" @click="goToDetail(org.id)">
<div class="org-card" v-for="org in filteredOrgs()" @click="goToDetail(org.id)">
<div class="org-status" :class="org.status === 1 ? 'active' : ''">
{{ org.status === 1 ? 'Active' : 'Inactive' }}
</div>
@ -302,7 +302,7 @@
</div>
</div>
<div class="org-desc">{{ org.description || ($t('org.no_description') || 'No description provided') }}</div>
<div class="org-desc">{{ org.description || $t('org.no_description') }}</div>
<div class="org-meta">
<div class="org-meta-item" title="Members Limit">
@ -328,17 +328,17 @@
<!-- Create/Edit Dialog -->
<v-dialog v:visible="showModal"
:title="isEdit ? ($t('org.edit') || 'Edit Organization') : ($t('org.create') || 'Create Organization')">
:title="isEdit ? $t('org.edit') : $t('org.create')">
<form @submit.prevent="saveOrg" style="display: grid; gap: 16px;">
<v-input v-for="item in formItems" :type="item.type || 'text'" :label="$t(item.labelKey) || item.label"
:required="item.required" :placeholder="$t(item.placeholderKey) || item.placeholder"
<v-input v-for="item in formItems" :type="item.type || 'text'" :label="$t(item.labelKey)"
:required="item.required" :placeholder="$t(item.placeholderKey)"
v:value="formData[item.name]" :disabled="item.name === 'code' && isEdit">
</v-input>
</form>
<div vslot="footer">
<v-btn variant="outline" :click="closeModal">{{ $t('common.cancel') || 'Cancel' }}</v-btn>
<v-btn variant="outline" :click="closeModal">{{ $t('common.cancel') }}</v-btn>
<v-btn color="primary" :click="saveOrg">
{{ isEdit ? ($t('common.save') || 'Save') : ($t('common.create') || 'Create') }}
{{ isEdit ? $t('common.save') : $t('common.create') }}
</v-btn>
</div>
</v-dialog>
@ -420,7 +420,7 @@
// Save (create or update)
saveOrg = async () => {
if (!formData.name || !formData.code) {
$message.error($t('org.required_fields') || "Name and Code are required");
$message.error($t('org.required_fields'));
return;
}
try {
@ -430,7 +430,7 @@
description: formData.description,
logo: formData.logo
});
$message.success($t('org.updated') || "Updated successfully");
$message.success($t('org.updated'));
} else {
await $axios.post('/api/orgs', {
name: formData.name,
@ -438,7 +438,7 @@
description: formData.description,
logo: formData.logo
});
$message.success($t('org.created') || "Created successfully");
$message.success($t('org.created'));
}
closeModal();
loadOrgs();
@ -451,9 +451,9 @@
// Delete
deleteOrg = async (org) => {
try {
await $message.confirm($t('org.delete_confirm') || `Are you sure you want to delete "${org.name}"?`);
await $message.confirm($t('org.delete_confirm'));
await $axios.delete(`/api/orgs/${org.id}`);
$message.success($t('org.deleted') || "Deleted successfully");
$message.success($t('org.deleted'));
loadOrgs();
} catch (e) {
// Cancelled

@ -1,110 +1,280 @@
<!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>
<meta name="description" content="User Management">
<title>{{ $t('nav.users') }}</title>
<style>
body {
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
padding: var(--spacing-lg);
box-sizing: border-box;
background-color: var(--bg-color);
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: var(--spacing-md);
padding-bottom: var(--spacing-md);
border-bottom: 1px solid var(--border-color);
}
.page-title {
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-bold);
color: var(--text-color);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.search-box {
display: flex;
align-items: center;
gap: var(--spacing-sm);
background: var(--bg-color-secondary);
padding: 0 var(--spacing-md);
border-radius: var(--radius-full);
border: 1px solid var(--border-color);
min-width: 320px;
height: 40px;
transition: all var(--transition-base);
}
.search-box:focus-within {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary), transparent 85%);
}
.search-box input {
border: none;
background: transparent;
outline: none;
font-size: var(--font-size-md);
color: var(--text-color);
width: 100%;
height: 100%;
}
.table-container {
flex: 1;
overflow: auto;
background: var(--bg-color-secondary);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-color);
}
table {
width: 100%;
border-collapse: collapse;
white-space: nowrap;
}
th,
td {
text-align: left;
padding: var(--spacing-md);
border-bottom: 1px solid var(--border-color);
}
th {
background-color: var(--bg-color-tertiary);
font-weight: 600;
position: sticky;
top: 0;
z-index: 10;
}
tr:last-child td {
border-bottom: none;
}
tr:hover td {
background-color: var(--bg-color-tertiary);
}
.status-badge {
display: inline-block;
padding: 2px 8px;
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: 500;
}
.status-active {
background-color: color-mix(in srgb, var(--color-success), transparent 85%);
color: var(--color-success);
}
.status-inactive {
background-color: color-mix(in srgb, var(--text-color-disabled), transparent 85%);
color: var(--text-color-secondary);
}
</style>
</head>
<body>
<div class="page-header">
<h1>{{ $t('nav.users') }}</h1>
<button class="btn-action btn-edit" @click="createUser">Create User</button>
<div class="page-header">
<div class="page-title">
<i class="fas fa-users" style="color: var(--color-primary);"></i>
{{ $t('nav.users') }}
</div>
<div style="display: flex; gap: var(--spacing-md); align-items: center;">
<div class="search-box">
<i class="fas fa-search" style="color: var(--text-color-tertiary);"></i>
<input type="text" v:value="searchQuery" :placeholder="$t('user.search_placeholder')">
</div>
<v-btn color="primary" :click="openCreateModal">
<i class="fas fa-plus"></i>
{{ $t('user.create') }}
</v-btn>
</div>
</div>
<div class="table-container">
<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>
<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 filteredUsers">
<td>{{ u.id }}</td>
<td>{{ u.username }}</td>
<td>{{ u.email }}</td>
<td>
<span class="status-badge" :class="u.status === 1 ? 'status-active' : 'status-inactive'">
{{ u.status === 1 ? 'Active' : 'Inactive' }}
</span>
</td>
<td>
<div style="display: flex; gap: 8px;">
<v-btn icon size="sm" variant="outline" :click="() => openEditModal(u)" title="Edit">
<i class="fas fa-edit"></i>
</v-btn>
<v-btn icon size="sm" color="danger" variant="outline" :click="() => deleteUser(u)" title="Delete">
<i class="fas fa-trash"></i>
</v-btn>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Create/Edit Dialog -->
<v-dialog v:visible="showModal"
:title="isEdit ? $t('user.edit') : $t('user.create')">
<form @submit.prevent="saveUser" style="display: grid; gap: 16px;">
<v-input label="Username" required v:value="formData.username" :disabled="isEdit"></v-input>
<v-input label="Email" type="email" required v:value="formData.email"></v-input>
<v-input label="Password" type="password" :required="!isEdit" v:value="formData.password"
:placeholder="isEdit ? 'Leave blank to keep unchanged' : 'Enter password'"></v-input>
</form>
<div vslot="footer">
<v-btn variant="outline" :click="closeModal">{{ $t('common.cancel') }}</v-btn>
<v-btn color="primary" :click="saveUser">
{{ isEdit ? $t('common.save') : $t('common.create') }}
</v-btn>
</div>
</v-dialog>
</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
users = [];
searchQuery = "";
showModal = false;
isEdit = false;
formData = {
id: null,
username: "",
email: "",
password: ""
};
loadUsers = async () => {
try {
const res = await $axios.get('/api/users');
users = res.items || []; // Adjust based on actual API response structure (array or {items: []})
} catch (e) {
$message.error(e.message);
}
};
filteredUsers = () => {
if (!searchQuery) return users;
const query = searchQuery.toLowerCase();
return users.filter(u =>
u.username.toLowerCase().includes(query) ||
u.email.toLowerCase().includes(query)
);
};
openCreateModal = () => {
isEdit = false;
formData = { id: null, username: "", email: "", password: "" };
showModal = true;
};
openEditModal = (u) => {
isEdit = true;
formData = { ...u, password: "" };
showModal = true;
};
closeModal = () => {
showModal = false;
};
saveUser = async () => {
if (!formData.username) {
$message.error("Username is required");
return;
}
try {
if (isEdit) {
const payload = { email: formData.email };
if (formData.password) payload.password = formData.password;
await $axios.patch(`/api/users/${formData.id}`, payload);
$message.success("User updated");
} else {
if (!formData.password) {
$message.error("Password is required for new users");
return;
}
};
await $axios.post('/api/users', formData);
$message.success("User created");
}
closeModal();
loadUsers();
} catch (e) {
$message.error(e.message);
}
};
deleteUser = async (u) => {
try {
await $message.confirm(`Delete user "${u.username}"?`);
await $axios.delete(`/api/users/${u.id}`);
$message.success("Deleted");
loadUsers();
} catch (e) {
// Cancelled
}
};
</script>
<script>
$data.loadUsers();
$data.loadUsers();
</script>
</html>

@ -1,133 +1,119 @@
<!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>
<meta name="description" content="User Profile">
<title>{{ $t('user.profile') }}</title>
<style>
body {
background-color: var(--bg-color);
display: flex;
justify-content: center;
padding-top: var(--spacing-xl);
box-sizing: border-box;
}
.profile-container {
width: 100%;
max-width: 600px;
padding: var(--spacing-xl);
background: var(--bg-color-secondary);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-color);
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
height: fit-content;
}
.avatar-section {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: var(--spacing-md);
}
.avatar {
width: 100px;
height: 100px;
border-radius: 50%;
background: linear-gradient(135deg, var(--color-primary), var(--color-secondary));
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
color: var(--color-primary-text);
font-weight: bold;
}
.form-content {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
</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 class="profile-container">
<h2 style="text-align: center;">{{ $t('user.profile') }}</h2>
<div class="avatar-section">
<div class="avatar">
{{ user.username ? user.username.charAt(0).toUpperCase() : 'U' }}
</div>
</div>
<form class="form-content" @submit.prevent="updateProfile">
<v-input label="Username" v:value="user.username" disabled></v-input>
<v-input label="Email" type="email" v:value="user.email" required></v-input>
<v-input label="Phone" type="tel" v:value="user.phone"></v-input>
<v-btn type="submit" color="primary" block style="margin-top: var(--spacing-sm);">
{{ $t('common.save') }}
</v-btn>
</form>
<div style="border-top: 1px solid var(--border-color); padding-top: var(--spacing-lg);">
<v-btn color="danger" variant="outline" block :click="handleLogout">
{{ $t('auth.logout') }}
</v-btn>
</div>
</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');
};
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();
$data.loadUser();
</script>
</html>

Loading…
Cancel
Save