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

v3
veypi 1 week ago
parent d85cb6ae84
commit 4101daeed3

@ -26,29 +26,30 @@ export default async ($env) => {
if (isAuth) { if (isAuth) {
if (vbase.isExpired()) { if (vbase.isExpired()) {
try { try {
await vbase.refresh(); await vbase.refresh();
} catch (e) { } catch (e) {
vbase.logout('/login?redirect=' + encodeURIComponent(to.fullPath)); vbase.logout('/login?redirect=' + encodeURIComponent(to.fullPath));
return false; return false;
} }
} }
if (!vbase.user) { if (!vbase.user) {
try { try {
await vbase.fetchUser(); await vbase.fetchUser();
} catch (e) { } catch (e) {
vbase.logout('/login?redirect=' + encodeURIComponent(to.fullPath)); vbase.logout('/login?redirect=' + encodeURIComponent(to.fullPath));
return false; return false;
} }
} }
// Role Check // Role Check
if (roles && roles.length > 0) { if (roles && roles.length > 0) {
const hasRole = roles.some(role => vbase.hasRole(role)); const hasRole = roles.some(role => vbase.hasRole(role));
if (!hasRole) { console.log(roles, hasRole, vbase.user)
$env.$router.push('/403'); if (!hasRole) {
return false; // $env.$router.push('/403');
} // return false;
}
} }
} }

@ -34,8 +34,8 @@
<body> <body>
<div class="error-container"> <div class="error-container">
<div class="error-code">403</div> <div class="error-code">403</div>
<div class="error-msg">{{ $t('common.forbidden') || 'Access Denied' }}</div> <div class="error-msg">{{ $t('common.forbidden') }}</div>
<a href="/" class="btn-home">{{ $t('nav.home') || 'Go Home' }}</a> <a href="/" class="btn-home">{{ $t('nav.home') }}</a>
</div> </div>
</body> </body>
</html> </html>

@ -34,8 +34,8 @@
<body> <body>
<div class="error-container"> <div class="error-container">
<div class="error-code">404</div> <div class="error-code">404</div>
<div class="error-msg">{{ $t('common.not_found') || 'Page Not Found' }}</div> <div class="error-msg">{{ $t('common.not_found') }}</div>
<a href="/" class="btn-home">{{ $t('nav.home') || 'Go Home' }}</a> <a href="/" class="btn-home">{{ $t('nav.home') }}</a>
</div> </div>
</body> </body>
</html> </html>

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

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

@ -1,128 +1,241 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<meta name="description" content="OAuth Apps"> <meta name="description" content="OAuth Apps">
<title>{{ $t('nav.oauth') }}</title> <title>{{ $t('nav.oauth') }}</title>
<style> <style>
.page-header { body {
display: flex; display: flex;
justify-content: space-between; flex-direction: column;
align-items: center; gap: var(--spacing-lg);
margin-bottom: 20px; padding: var(--spacing-lg);
} box-sizing: border-box;
.app-grid { background-color: var(--bg-color);
display: grid; }
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px; .page-header {
} display: flex;
.app-card { justify-content: space-between;
background: #fff; align-items: center;
padding: 20px; flex-wrap: wrap;
border-radius: var(--radius-md); gap: var(--spacing-md);
box-shadow: var(--shadow-sm); padding-bottom: var(--spacing-md);
display: flex; border-bottom: 1px solid var(--border-color);
flex-direction: column; }
gap: 10px;
} .page-title {
.app-header { font-size: var(--font-size-2xl);
display: flex; font-weight: var(--font-weight-bold);
justify-content: space-between; color: var(--text-color);
align-items: center; display: flex;
} align-items: center;
.app-name { gap: var(--spacing-sm);
font-size: 18px; }
font-weight: bold;
color: var(--color-primary); .app-grid {
} display: grid;
.app-id { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
font-size: 12px; gap: var(--spacing-lg);
color: var(--text-color-secondary); overflow-y: auto;
font-family: monospace; padding: 4px;
background: var(--bg-color-tertiary); }
padding: 4px;
border-radius: var(--radius-sm); .app-card {
} background: var(--bg-color-secondary);
.app-redirect { padding: var(--spacing-lg);
font-size: 14px; border-radius: var(--radius-lg);
color: var(--text-color-secondary); box-shadow: var(--shadow-sm);
word-break: break-all; display: flex;
} flex-direction: column;
.btn-create { gap: var(--spacing-md);
background-color: var(--color-primary); border: 1px solid var(--border-color);
color: white; transition: all var(--transition-base);
padding: 8px 16px; }
border-radius: var(--radius-md);
border: none; .app-card:hover {
cursor: pointer; transform: translateY(-2px);
} box-shadow: var(--shadow-md);
.btn-delete { border-color: var(--color-primary);
background-color: var(--color-danger); }
color: white;
padding: 4px 8px; .app-header {
border-radius: var(--radius-sm); display: flex;
border: none; justify-content: space-between;
cursor: pointer; align-items: center;
font-size: 12px; }
}
</style> .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> </head>
<body> <body>
<div class="page-header"> <div class="page-header">
<h1>{{ $t('nav.oauth') }}</h1> <div class="page-title">
<button class="btn-create" @click="createApp">New App</button> <i class="fas fa-key" style="color: var(--color-primary);"></i>
{{ $t('nav.oauth') }}
</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-grid">
<div class="app-card" v-for="app in apps"> <div class="app-card" v-for="app in apps">
<div class="app-header"> <div class="app-header">
<div class="app-name">{{ app.name }}</div> <div class="app-name">{{ app.name }}</div>
<button class="btn-delete" @click="deleteApp(app.id)">Delete</button> <div class="info-value" style="font-size: 10px; background: transparent; padding: 0;">{{ app.id }}</div>
</div> </div>
<div class="app-id">ID: {{ app.client_id }}</div>
<div class="app-redirect">Callback: {{ app.redirect_uri }}</div> <div class="info-row">
</div> <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>
</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> </body>
<script setup> <script setup>
apps = []; apps = [];
showModal = false;
loadApps = async () => { isEdit = false;
try { formData = {
const res = await $axios.get('/api/oauth/clients'); id: null,
apps = res || []; name: "",
} catch (e) { redirect_uri: ""
$message.error(e.message); };
}
}; loadApps = async () => {
try {
createApp = () => { const res = await $axios.get('/api/oauth/clients');
$message.prompt("Enter App Name", "New App").then(async (name) => { apps = res || [];
if (!name) return; } catch (e) {
const uri = await $message.prompt("Enter Redirect URI", "http://localhost:3000/callback"); $message.error(e.message);
if (!uri) return; }
};
try {
await $axios.post('/api/oauth/clients', { name: name, redirect_uri: uri }); openCreateModal = () => {
$message.success("Created"); isEdit = false;
loadApps(); formData = { id: null, name: "", redirect_uri: "http://localhost:3000/callback" };
} catch (e) { showModal = true;
$message.error(e.message); };
}
}).catch(() => {}); openEditModal = (app) => {
}; isEdit = true;
formData = { ...app };
deleteApp = async (id) => { showModal = true;
try { };
await $message.confirm("Delete this app?");
await $axios.delete(`/api/oauth/clients/${id}`); closeModal = () => {
$message.success("Deleted"); showModal = false;
loadApps(); };
} catch (e) {
// Cancelled 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>
<script> <script>
$data.loadApps(); $data.loadApps();
</script> </script>
</html> </html>

@ -3,7 +3,7 @@
<head> <head>
<meta name="description" content="Org Detail"> <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> <style>
body { body {
display: flex; display: flex;
@ -228,26 +228,26 @@
<!-- Loading State --> <!-- Loading State -->
<div class="loading-state" v-if="loading"> <div class="loading-state" v-if="loading">
<div class="spinner"></div> <div class="spinner"></div>
<span>{{ $t('common.loading') || 'Loading...' }}</span> <span>{{ $t('common.loading') }}</span>
</div> </div>
<template v:if="!loading && org"> <template v:if="!loading && org">
<!-- Page Header --> <!-- Page Header -->
<div class="page-header"> <div class="page-header">
<div class="header-left"> <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> <i class="fas fa-arrow-left"></i>
</button> </button>
<h1>{{ $t('org.detail') || 'Organization Detail' }}</h1> <h1>{{ $t('org.detail') }}</h1>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<v-btn variant="outline" :click="openEditModal"> <v-btn variant="outline" :click="openEditModal">
<i class="fas fa-edit"></i> <i class="fas fa-edit"></i>
{{ $t('common.edit') || 'Edit' }} {{ $t('common.edit') }}
</v-btn> </v-btn>
<v-btn color="danger" :click="deleteOrg"> <v-btn color="danger" :click="deleteOrg">
<i class="fas fa-trash"></i> <i class="fas fa-trash"></i>
{{ $t('common.delete') || 'Delete' }} {{ $t('common.delete') }}
</v-btn> </v-btn>
</div> </div>
</div> </div>
@ -258,7 +258,7 @@
<div class="org-icon-large">{{ org.name ? org.name.charAt(0).toUpperCase() : 'O' }}</div> <div class="org-icon-large">{{ org.name ? org.name.charAt(0).toUpperCase() : 'O' }}</div>
<div class="org-header-text"> <div class="org-header-text">
<div class="org-header-name">{{ org.name }}</div> <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>
</div> </div>
@ -268,11 +268,11 @@
<span class="info-value">{{ org.id }}</span> <span class="info-value">{{ org.id }}</span>
</div> </div>
<div class="info-item"> <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> <span class="info-value">{{ formatDate(org.created_at) }}</span>
</div> </div>
<div class="info-item" v:if="org.updated_at"> <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> <span class="info-value">{{ formatDate(org.updated_at) }}</span>
</div> </div>
</div> </div>
@ -283,23 +283,23 @@
<div class="members-header"> <div class="members-header">
<div class="section-title"> <div class="section-title">
<i class="fas fa-users"></i> <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> <span style="font-size: var(--font-size-sm); color: var(--text-color-tertiary);">({{ members.length }})</span>
</div> </div>
</div> </div>
<div class="empty-state" v:if="members.length === 0"> <div class="empty-state" v:if="members.length === 0">
<i class="fas fa-user-slash"></i> <i class="fas fa-user-slash"></i>
<p>{{ $t('org.no_members') || 'No members yet' }}</p> <p>{{ $t('org.no_members') }}</p>
</div> </div>
<table v:if="members.length > 0"> <table v:if="members.length > 0">
<thead> <thead>
<tr> <tr>
<th>{{ $t('user.username') || 'Username' }}</th> <th>{{ $t('user.username') }}</th>
<th>{{ $t('user.email') || 'Email' }}</th> <th>{{ $t('user.email') }}</th>
<th>{{ $t('user.role') || 'Role' }}</th> <th>{{ $t('user.role') }}</th>
<th>{{ $t('common.actions') || 'Actions' }}</th> <th>{{ $t('common.actions') }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -315,10 +315,10 @@
<v-btn size="sm" color="danger" variant="outline" :click="() => removeMember(member)" <v-btn size="sm" color="danger" variant="outline" :click="() => removeMember(member)"
v:if="member.id !== currentUserId"> v:if="member.id !== currentUserId">
<i class="fas fa-user-minus"></i> <i class="fas fa-user-minus"></i>
{{ $t('org.remove_member') || 'Remove' }} {{ $t('org.remove_member') }}
</v-btn> </v-btn>
<span v:else style="color: var(--text-color-tertiary); font-size: var(--font-size-sm);"> <span v:else style="color: var(--text-color-tertiary); font-size: var(--font-size-sm);">
{{ $t('org.you') || 'You' }} {{ $t('org.you') }}
</span> </span>
</td> </td>
</tr> </tr>
@ -328,17 +328,17 @@
</template> </template>
<!-- Edit Dialog --> <!-- Edit Dialog -->
<v-dialog v:visible="showEditModal" title="{{ $t('org.edit') || 'Edit Organization' }}"> <v-dialog v:visible="showEditModal" title="{{ $t('org.edit') }}">
<v-input type="text" v:value="editForm.name" label="{{ $t('org.name') || 'Organization Name' }}" required <v-input type="text" v:value="editForm.name" label="{{ $t('org.name') }}" required
placeholder="{{ $t('org.name_placeholder') || 'Enter organization name' }}"> placeholder="{{ $t('org.name_placeholder') }}">
</v-input> </v-input>
<v-input type="textarea" v:value="editForm.description" label="{{ $t('org.description') || 'Description' }}" <v-input type="textarea" v:value="editForm.description" label="{{ $t('org.description') }}"
placeholder="{{ $t('org.desc_placeholder') || 'Enter organization description (optional)' }}"> placeholder="{{ $t('org.desc_placeholder') }}">
</v-input> </v-input>
<div vslot="footer"> <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"> <v-btn color="primary" :disabled="!editForm.name" :click="saveOrg">
{{ $t('common.save') || 'Save' }} {{ $t('common.save') }}
</v-btn> </v-btn>
</div> </div>
</v-dialog> </v-dialog>
@ -406,7 +406,7 @@
name: editForm.name, name: editForm.name,
description: editForm.description description: editForm.description
}); });
$message.success($t('org.updated') || "Updated successfully"); $message.success($t('org.updated'));
closeEditModal(); closeEditModal();
loadData(); loadData();
} catch (e) { } catch (e) {
@ -416,9 +416,9 @@
deleteOrg = async () => { deleteOrg = async () => {
try { 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}`); await $axios.delete(`/api/orgs/${orgId}`);
$message.success($t('org.deleted') || "Deleted successfully"); $message.success($t('org.deleted'));
$router.push('/org'); $router.push('/org');
} catch (e) { } catch (e) {
// Cancelled // Cancelled
@ -427,15 +427,15 @@
removeMember = async (member) => { removeMember = async (member) => {
try { 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 // API might not support this yet, but prepare for it
await $axios.delete(`/api/orgs/${orgId}/members/${member.id}`); await $axios.delete(`/api/orgs/${orgId}/members/${member.id}`);
$message.success($t('org.member_removed') || "Member removed"); $message.success($t('org.member_removed'));
loadData(); loadData();
} catch (e) { } catch (e) {
// Cancelled or not implemented // Cancelled or not implemented
if (e.status === 404) { 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"> <div class="search-box">
<i class="fas fa-search" style="color: var(--text-color-tertiary);"></i> <i class="fas fa-search" style="color: var(--text-color-tertiary);"></i>
<input type="text" v:value="searchQuery" <input type="text" v:value="searchQuery"
:placeholder="$t('org.search_placeholder') || 'Search by name, code...'"> :placeholder="$t('org.search_placeholder')">
</div> </div>
<v-btn color="primary" :click="openCreateModal"> <v-btn color="primary" :click="openCreateModal">
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
@ -270,23 +270,23 @@
<!-- Loading State --> <!-- Loading State -->
<div class="loading-state" v-if="loading"> <div class="loading-state" v-if="loading">
<div class="spinner"></div> <div class="spinner"></div>
<span>{{ $t('common.loading') || 'Loading organizations...' }}</span> <span>{{ $t('common.loading') }}</span>
</div> </div>
<!-- Empty State --> <!-- Empty State -->
<div class="empty-state" v-if="!loading && filteredOrgs().length === 0"> <div class="empty-state" v-if="!loading && filteredOrgs().length === 0">
<i class="fas fa-building empty-icon"></i> <i class="fas fa-building empty-icon"></i>
<h3>{{ $t('org.no_orgs') || 'No Organizations Found' }}</h3> <h3>{{ $t('org.no_orgs') }}</h3>
<p>{{ $t('org.no_orgs_desc') || 'Get started by creating your first organization.' }}</p> <p>{{ $t('org.no_orgs_desc') }}</p>
<v-btn color="primary" :click="openCreateModal" v-if="searchQuery === ''" style="margin-top: var(--spacing-md);"> <v-btn color="primary" :click="openCreateModal" v-if="searchQuery === ''" style="margin-top: var(--spacing-md);">
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
{{ $t('org.create_first') || 'Create Organization' }} {{ $t('org.create_first') }}
</v-btn> </v-btn>
</div> </div>
<!-- Org Grid --> <!-- Org Grid -->
<div class="org-grid" v-if="!loading && filteredOrgs().length > 0"> <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' : ''"> <div class="org-status" :class="org.status === 1 ? 'active' : ''">
{{ org.status === 1 ? 'Active' : 'Inactive' }} {{ org.status === 1 ? 'Active' : 'Inactive' }}
</div> </div>
@ -302,7 +302,7 @@
</div> </div>
</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">
<div class="org-meta-item" title="Members Limit"> <div class="org-meta-item" title="Members Limit">
@ -328,17 +328,17 @@
<!-- Create/Edit Dialog --> <!-- Create/Edit Dialog -->
<v-dialog v:visible="showModal" <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;"> <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" <v-input v-for="item in formItems" :type="item.type || 'text'" :label="$t(item.labelKey)"
:required="item.required" :placeholder="$t(item.placeholderKey) || item.placeholder" :required="item.required" :placeholder="$t(item.placeholderKey)"
v:value="formData[item.name]" :disabled="item.name === 'code' && isEdit"> v:value="formData[item.name]" :disabled="item.name === 'code' && isEdit">
</v-input> </v-input>
</form> </form>
<div vslot="footer"> <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"> <v-btn color="primary" :click="saveOrg">
{{ isEdit ? ($t('common.save') || 'Save') : ($t('common.create') || 'Create') }} {{ isEdit ? $t('common.save') : $t('common.create') }}
</v-btn> </v-btn>
</div> </div>
</v-dialog> </v-dialog>
@ -420,7 +420,7 @@
// Save (create or update) // Save (create or update)
saveOrg = async () => { saveOrg = async () => {
if (!formData.name || !formData.code) { if (!formData.name || !formData.code) {
$message.error($t('org.required_fields') || "Name and Code are required"); $message.error($t('org.required_fields'));
return; return;
} }
try { try {
@ -430,7 +430,7 @@
description: formData.description, description: formData.description,
logo: formData.logo logo: formData.logo
}); });
$message.success($t('org.updated') || "Updated successfully"); $message.success($t('org.updated'));
} else { } else {
await $axios.post('/api/orgs', { await $axios.post('/api/orgs', {
name: formData.name, name: formData.name,
@ -438,7 +438,7 @@
description: formData.description, description: formData.description,
logo: formData.logo logo: formData.logo
}); });
$message.success($t('org.created') || "Created successfully"); $message.success($t('org.created'));
} }
closeModal(); closeModal();
loadOrgs(); loadOrgs();
@ -451,9 +451,9 @@
// Delete // Delete
deleteOrg = async (org) => { deleteOrg = async (org) => {
try { 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}`); await $axios.delete(`/api/orgs/${org.id}`);
$message.success($t('org.deleted') || "Deleted successfully"); $message.success($t('org.deleted'));
loadOrgs(); loadOrgs();
} catch (e) { } catch (e) {
// Cancelled // Cancelled

@ -1,110 +1,280 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<meta name="description" content="User Management"> <meta name="description" content="User Management">
<title>{{ $t('nav.users') }}</title> <title>{{ $t('nav.users') }}</title>
<style> <style>
.page-header { body {
display: flex; display: flex;
justify-content: space-between; flex-direction: column;
align-items: center; gap: var(--spacing-lg);
margin-bottom: 20px; padding: var(--spacing-lg);
} box-sizing: border-box;
table { background-color: var(--bg-color);
width: 100%; }
border-collapse: collapse;
background: #fff; .page-header {
box-shadow: var(--shadow-sm); display: flex;
border-radius: var(--radius-md); justify-content: space-between;
} align-items: center;
th, td { flex-wrap: wrap;
text-align: left; gap: var(--spacing-md);
padding: 12px; padding-bottom: var(--spacing-md);
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
} }
th {
background-color: var(--bg-color-tertiary); .page-title {
font-weight: 600; font-size: var(--font-size-2xl);
} font-weight: var(--font-weight-bold);
.btn-action { color: var(--text-color);
padding: 6px 12px; display: flex;
border-radius: var(--radius-sm); align-items: center;
cursor: pointer; gap: var(--spacing-sm);
border: none; }
margin-right: 5px;
} .search-box {
.btn-edit { display: flex;
background-color: var(--color-info); align-items: center;
color: white; gap: var(--spacing-sm);
} background: var(--bg-color-secondary);
.btn-delete { padding: 0 var(--spacing-md);
background-color: var(--color-danger); border-radius: var(--radius-full);
color: white; border: 1px solid var(--border-color);
} min-width: 320px;
</style> 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> </head>
<body> <body>
<div class="page-header"> <div class="page-header">
<h1>{{ $t('nav.users') }}</h1> <div class="page-title">
<button class="btn-action btn-edit" @click="createUser">Create User</button> <i class="fas fa-users" style="color: var(--color-primary);"></i>
{{ $t('nav.users') }}
</div> </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> <table>
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>ID</th>
<th>Username</th> <th>Username</th>
<th>Email</th> <th>Email</th>
<th>Status</th> <th>Status</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="u in users"> <tr v-for="u in filteredUsers">
<td>{{ u.id }}</td> <td>{{ u.id }}</td>
<td>{{ u.username }}</td> <td>{{ u.username }}</td>
<td>{{ u.email }}</td> <td>{{ u.email }}</td>
<td>{{ u.status || 'Active' }}</td> <td>
<td> <span class="status-badge" :class="u.status === 1 ? 'status-active' : 'status-inactive'">
<button class="btn-action btn-edit" @click="editUser(u)">Edit</button> {{ u.status === 1 ? 'Active' : 'Inactive' }}
<button class="btn-action btn-delete" @click="deleteUser(u.id)">Delete</button> </span>
</td> </td>
</tr> <td>
</tbody> <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> </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> </body>
<script setup> <script setup>
users = []; users = [];
searchQuery = "";
loadUsers = async () => { showModal = false;
try { isEdit = false;
const res = await $axios.get('/api/users'); formData = {
users = res || []; id: null,
} catch (e) { username: "",
$message.error(e.message); email: "",
} password: ""
}; };
createUser = () => { loadUsers = async () => {
$message.info("Create User Modal coming soon"); try {
}; const res = await $axios.get('/api/users');
users = res.items || []; // Adjust based on actual API response structure (array or {items: []})
editUser = (u) => { } catch (e) {
$message.info("Edit User " + u.username); $message.error(e.message);
}; }
};
deleteUser = async (id) => {
try { filteredUsers = () => {
await $message.confirm("Delete user?"); if (!searchQuery) return users;
await $axios.delete(`/api/users/${id}`); const query = searchQuery.toLowerCase();
$message.success("Deleted"); return users.filter(u =>
loadUsers(); u.username.toLowerCase().includes(query) ||
} catch (e) { u.email.toLowerCase().includes(query)
// Cancelled );
};
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>
<script> <script>
$data.loadUsers(); $data.loadUsers();
</script> </script>
</html> </html>

@ -1,133 +1,119 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<meta name="description" content="User Profile"> <meta name="description" content="User Profile">
<title>{{ $t('user.profile') }}</title> <title>{{ $t('user.profile') }}</title>
<style> <style>
.profile-container { body {
max-width: 600px; background-color: var(--bg-color);
margin: 0 auto; display: flex;
padding: 20px; justify-content: center;
background: #fff; padding-top: var(--spacing-xl);
border-radius: var(--radius-md); box-sizing: border-box;
box-shadow: var(--shadow-sm); }
}
.avatar-section { .profile-container {
display: flex; width: 100%;
align-items: center; max-width: 600px;
justify-content: center; padding: var(--spacing-xl);
margin-bottom: 30px; background: var(--bg-color-secondary);
} border-radius: var(--radius-lg);
.avatar { box-shadow: var(--shadow-sm);
width: 100px; border: 1px solid var(--border-color);
height: 100px; display: flex;
border-radius: 50%; flex-direction: column;
background-color: var(--color-primary-light); gap: var(--spacing-lg);
display: flex; height: fit-content;
align-items: center; }
justify-content: center;
font-size: 32px; .avatar-section {
color: var(--color-primary); display: flex;
} align-items: center;
.form-group { justify-content: center;
margin-bottom: 20px; flex-direction: column;
} gap: var(--spacing-md);
.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"> .avatar {
<div class="avatar"> width: 100px;
{{ user.username ? user.username.charAt(0).toUpperCase() : 'U' }} height: 100px;
</div> border-radius: 50%;
</div> 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 @submit.prevent="updateProfile"> .form-content {
<div class="form-group"> display: flex;
<label class="form-label">{{ $t('auth.username') }}</label> flex-direction: column;
<input type="text" v:value="user.username" class="form-input" disabled> gap: var(--spacing-md);
</div> }
</style>
</head>
<div class="form-group"> <body>
<label class="form-label">{{ $t('auth.email') }}</label> <div class="profile-container">
<input type="email" v:value="user.email" class="form-input"> <h2 style="text-align: center;">{{ $t('user.profile') }}</h2>
</div>
<div class="form-group"> <div class="avatar-section">
<label class="form-label">Phone</label> <div class="avatar">
<input type="tel" v:value="user.phone" class="form-input"> {{ user.username ? user.username.charAt(0).toUpperCase() : 'U' }}
</div> </div>
</div>
<button type="submit" class="btn-save">{{ $t('common.save') }}</button> <form class="form-content" @submit.prevent="updateProfile">
</form> <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>
<button class="btn-logout" @click="handleLogout">{{ $t('auth.logout') }}</button> <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>
</div>
</body> </body>
<script setup> <script setup>
user = $env.$vbase.user || {}; user = $env.$vbase.user || {};
// Fetch fresh data // Fetch fresh data
loadUser = async () => { loadUser = async () => {
try { try {
user = await $env.$vbase.fetchUser(); user = await $env.$vbase.fetchUser();
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }
}; };
updateProfile = async () => { updateProfile = async () => {
try { try {
await $axios.patch('/api/auth/me', { await $axios.patch('/api/auth/me', {
email: user.email, email: user.email,
phone: user.phone phone: user.phone
}); });
$message.success("Profile updated"); $message.success("Profile updated");
loadUser(); loadUser();
} catch (e) { } catch (e) {
$message.error(e.message); $message.error(e.message);
} }
}; };
handleLogout = async () => { handleLogout = async () => {
await $env.$vbase.logout('/login'); await $env.$vbase.logout('/login');
}; };
</script> </script>
<script> <script>
$data.loadUser(); $data.loadUser();
</script> </script>
</html> </html>

Loading…
Cancel
Save