refactor(ui): simplify role management UI and auth flow

- Replace permission selector dialog with inline add form (scope/id/level)
    - Replace per-user role API calls with batch PUT /api/roles/{id}/users
    - Add isLogin() async method with lazy _ensureAuth initialization
    - Clean up login page CSS: replace hardcoded colors with CSS variables
    - Add Chrome autofill style override for dark theme support
    - Use @submit.prevent instead of manual e.preventDefault()
    - Remove redundant inline comments from script sections
master
veypi 3 weeks ago
parent ac7a8d2108
commit adf0cd36ca

@ -13,14 +13,13 @@
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
font-family: var(--font-family-sans, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
} }
h1 { h1 {
font-weight: bold; font-weight: bold;
margin: 0; margin: 0;
margin-bottom: 15px; margin-bottom: 15px;
color: var(--color-text, #1f2937); color: var(--text-color);
} }
p { p {
@ -29,33 +28,32 @@
line-height: 20px; line-height: 20px;
letter-spacing: 0.5px; letter-spacing: 0.5px;
margin: 20px 0 30px; margin: 20px 0 30px;
color: var(--color-text, #1f2937); color: var(--text-color);
} }
span { span {
font-size: 12px; font-size: 12px;
margin: 15px 0; margin: 15px 0;
color: var(--color-text-light, #6b7280);
} }
a { a {
color: var(--color-primary, #4f46e5); color: var(--color-primary);
font-size: 14px; font-size: 14px;
text-decoration: none; text-decoration: none;
margin: 15px 0; margin: 15px 0;
} }
button { button {
border-radius: var(--border-radius, 8px); border-radius: var(--radius-lg);
border: 1px solid var(--color-primary, #4f46e5); border: 1px solid var(--color-primary);
background-color: var(--color-primary, #4f46e5); background-color: var(--color-primary);
color: #ffffff; color: var(--color-primary-text);
font-size: 12px; font-size: 12px;
font-weight: bold; font-weight: bold;
padding: 12px 45px; padding: 12px 45px;
letter-spacing: 1px; letter-spacing: 1px;
text-transform: uppercase; text-transform: uppercase;
transition: transform 80ms ease-in, background-color 0.2s; transition: transform 80ms ease-in, background-color var(--transition-fast);
cursor: pointer; cursor: pointer;
overflow: hidden; overflow: hidden;
} }
@ -70,8 +68,8 @@
button.ghost { button.ghost {
background-color: transparent; background-color: transparent;
border-color: #ffffff; border-color: #fff;
color: #ffffff; color: #fff;
} }
button.ghost:hover { button.ghost:hover {
@ -79,35 +77,33 @@
} }
button:disabled { button:disabled {
background-color: var(--color-border, #d1d5db); background-color: var(--text-color-disabled);
border-color: var(--color-border, #d1d5db); border-color: var(--text-color-disabled);
cursor: not-allowed; cursor: not-allowed;
} }
/* Code input row */
.code-input-row { .code-input-row {
display: flex; display: flex;
gap: 8px; gap: var(--spacing-sm);
align-items: center; align-items: center;
} }
.code-input-row input { .code-input-row input {
flex: 1; flex: 1;
margin: 8px 0; margin: var(--spacing-sm) 0;
} }
button.send-code-btn { button.send-code-btn {
width: auto; width: auto;
min-width: 90px; min-width: 90px;
padding: 10px 14px; padding: 10px 14px;
margin: 8px 0; margin: var(--spacing-sm) 0;
white-space: nowrap; white-space: nowrap;
text-transform: none; text-transform: none;
font-size: 13px; font-size: 13px;
letter-spacing: 0; letter-spacing: 0;
} }
/* Loading spinner */
.loading-spinner { .loading-spinner {
position: absolute; position: absolute;
top: 50%; top: 50%;
@ -116,7 +112,7 @@
width: 16px; width: 16px;
height: 16px; height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3); border: 2px solid rgba(255, 255, 255, 0.3);
border-top: 2px solid #ffffff; border-top: 2px solid #fff;
border-radius: 50%; border-radius: 50%;
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
} }
@ -136,7 +132,7 @@
} }
form { form {
background-color: var(--bg-color-secondary, #ffffff); background-color: var(--bg-color-secondary);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -147,32 +143,40 @@
} }
input { input {
background-color: var(--bg-color-secondary, #ffffff); background-color: var(--bg-color-secondary);
border: 1px solid var(--color-border, #d1d5db); border: 1px solid var(--border-color);
width: 100%; width: 100%;
padding: 10px 12px; padding: 10px 12px;
margin: 8px 0; margin: var(--spacing-sm) 0;
border-radius: var(--border-radius, 8px); border-radius: var(--radius-lg);
color: var(--color-text, #1f2937); color: var(--text-color);
transition: border-color 0.2s, box-shadow 0.2s; transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
font-size: 14px; font-size: 14px;
} }
input:focus { input:focus {
outline: none; outline: none;
border-color: var(--color-primary, #4f46e5); border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.15); box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary), transparent 85%);
} }
input::placeholder { input::placeholder {
color: var(--color-text-light, #9ca3af); color: var(--text-color-disabled);
}
/* Chrome 自动填充覆盖 */
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus {
-webkit-box-shadow: 0 0 0 30px var(--bg-color-secondary) inset !important;
-webkit-text-fill-color: var(--text-color) !important;
} }
.container { .container {
position: relative; position: relative;
background-color: var(--bg-color-secondary, #ffffff); background-color: var(--bg-color-secondary);
border-radius: 10px; border-radius: 10px;
box-shadow: 0 14px 28px rgba(0, 0, 0, 0.15), 0 10px 10px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-lg);
overflow: hidden; overflow: hidden;
width: 768px; width: 768px;
max-width: 100%; max-width: 100%;
@ -227,12 +231,8 @@
.overlay { .overlay {
position: relative; position: relative;
background: var(--color-primary, #4f46e5); background: linear-gradient(to right, var(--color-secondary), var(--color-primary));
background: linear-gradient(to right, var(--color-secondary, #7c3aed), var(--color-primary, #4f46e5)); color: var(--color-primary-text);
background-repeat: no-repeat;
background-size: cover;
background-position: 0 0;
color: #ffffff;
left: -100%; left: -100%;
height: 100%; height: 100%;
width: 200%; width: 200%;
@ -281,7 +281,7 @@
} }
.social-container a { .social-container a {
border: 1px solid #dddddd; border: 1px solid var(--border-color);
border-radius: 50%; border-radius: 50%;
display: inline-flex; display: inline-flex;
justify-content: center; justify-content: center;
@ -290,13 +290,13 @@
height: 40px; height: 40px;
width: 40px; width: 40px;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all var(--transition-fast);
overflow: hidden; overflow: hidden;
font-size: 14px; font-size: 14px;
} }
.social-container a:hover { .social-container a:hover {
background-color: #f3f4f6; background-color: var(--bg-color-tertiary);
border-color: var(--color-primary); border-color: var(--color-primary);
color: var(--color-primary); color: var(--color-primary);
} }
@ -311,16 +311,16 @@
} }
.error-message { .error-message {
color: var(--color-danger, #ef4444); color: var(--color-danger);
font-size: 12px; font-size: 12px;
margin-top: 8px; margin-top: var(--spacing-sm);
min-height: 18px; min-height: 18px;
} }
.login-tab { .login-tab {
display: flex; display: flex;
margin-bottom: 15px; margin-bottom: 15px;
border-bottom: 1px solid #eee; border-bottom: 1px solid var(--border-color);
width: 100%; width: 100%;
} }
@ -329,15 +329,15 @@
cursor: pointer; cursor: pointer;
flex: 1; flex: 1;
text-align: center; text-align: center;
color: #666; color: var(--text-color-secondary);
font-size: 13px; font-size: 13px;
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;
transition: all 0.3s; transition: all var(--transition-base);
} }
.tab-item.active { .tab-item.active {
color: var(--color-primary, #4f46e5); color: var(--color-primary);
border-bottom-color: var(--color-primary, #4f46e5); border-bottom-color: var(--color-primary);
font-weight: 600; font-weight: 600;
} }
@ -350,7 +350,6 @@
margin-top: 10px; margin-top: 10px;
} }
/* Responsive */
@media (max-width: 768px) { @media (max-width: 768px) {
.container { .container {
width: 100%; width: 100%;
@ -389,7 +388,7 @@
display: block !important; display: block !important;
margin-top: 20px; margin-top: 20px;
font-size: 14px; font-size: 14px;
color: var(--color-primary, #4f46e5); color: var(--color-primary);
cursor: pointer; cursor: pointer;
} }
} }
@ -406,7 +405,7 @@
<div class="container" :class="{ 'right-panel-active': isSignUp }"> <div class="container" :class="{ 'right-panel-active': isSignUp }">
<!-- Register Form --> <!-- Register Form -->
<div class="form-container sign-up-container"> <div class="form-container sign-up-container">
<form @submit="handleSignUp"> <form @submit.prevent="handleSignUp">
<h1>{{ $t('auth.create_account') }}</h1> <h1>{{ $t('auth.create_account') }}</h1>
<div class="social-container"> <div class="social-container">
<a v-for="p in providers" @click="handleOAuth(p.code)" :title="p.name" class="social-btn"> <a v-for="p in providers" @click="handleOAuth(p.code)" :title="p.name" class="social-btn">
@ -452,7 +451,7 @@
<!-- Login Form --> <!-- Login Form -->
<div class="form-container sign-in-container"> <div class="form-container sign-in-container">
<form @submit="handleSignIn"> <form @submit.prevent="handleSignIn">
<h1>{{ $t('auth.login') }}</h1> <h1>{{ $t('auth.login') }}</h1>
<div class="social-container"> <div class="social-container">
<a v-for="p in providers" @click="handleOAuth(p.code)" :title="p.name" class="social-btn"> <a v-for="p in providers" @click="handleOAuth(p.code)" :title="p.name" class="social-btn">
@ -529,7 +528,6 @@
</body> </body>
<script setup> <script setup>
// Reactive data - use = for reactive state (NO let/const/var)
isSignUp = false; isSignUp = false;
loginType = 'username'; loginType = 'username';
redirect = $router.query.redirect || '/'; redirect = $router.query.redirect || '/';
@ -564,7 +562,6 @@
signUpLoading = false; signUpLoading = false;
providers = []; providers = [];
// Switch to sign up panel
switchToSignUp = () => { switchToSignUp = () => {
if (signInLoading || signUpLoading) return; if (signInLoading || signUpLoading) return;
isSignUp = true; isSignUp = true;
@ -572,7 +569,6 @@
signInError = ''; signInError = '';
}; };
// Switch to sign in panel
switchToSignIn = () => { switchToSignIn = () => {
if (signInLoading || signUpLoading) return; if (signInLoading || signUpLoading) return;
isSignUp = false; isSignUp = false;
@ -580,7 +576,6 @@
signInError = ''; signInError = '';
}; };
// Switch login type
switchLoginType = (type) => { switchLoginType = (type) => {
if (signInLoading) return; if (signInLoading) return;
loginType = type; loginType = type;
@ -591,7 +586,6 @@
signInForm.code = ''; signInForm.code = '';
}; };
// Handle OAuth login
handleOAuth = async (provider) => { handleOAuth = async (provider) => {
try { try {
const params = new URLSearchParams({provider, redirect}); const params = new URLSearchParams({provider, redirect});
@ -609,7 +603,6 @@
} }
}; };
// Load configuration
loadConfig = async () => { loadConfig = async () => {
try { try {
const res = await fetch('/api/info'); const res = await fetch('/api/info');
@ -639,7 +632,7 @@
console.error('Failed to load config:', e); console.error('Failed to load config:', e);
} }
}; };
// Send verification code
sendCode = async () => { sendCode = async () => {
if (countDown > 0 || sendCodeLoading) return; if (countDown > 0 || sendCodeLoading) return;
@ -685,16 +678,13 @@
} }
}; };
// Handle Sign In
handleSignIn = async (e) => { handleSignIn = async (e) => {
e.preventDefault();
if (signInLoading) return; if (signInLoading) return;
signInError = ''; signInError = '';
signInLoading = true; signInLoading = true;
try { try {
// Code login
if (loginType === 'code') { if (loginType === 'code') {
if (!signInForm.target || !signInForm.code) { if (!signInForm.target || !signInForm.code) {
throw new Error($t('auth.target_and_code_required')); throw new Error($t('auth.target_and_code_required'));
@ -721,7 +711,6 @@
throw new Error('Login failed'); throw new Error('Login failed');
} }
} else { } else {
// Username/Password login
if (!signInForm.username || !signInForm.password) { if (!signInForm.username || !signInForm.password) {
throw new Error($t('auth.fill_all_fields')); throw new Error($t('auth.fill_all_fields'));
} }
@ -741,14 +730,11 @@
} }
}; };
// Handle Sign Up
handleSignUp = async (e) => { handleSignUp = async (e) => {
e.preventDefault();
if (signUpLoading) return; if (signUpLoading) return;
signUpError = ''; signUpError = '';
// Validation
if (!signUpForm.username || !signUpForm.password || !signUpForm.confirmPassword) { if (!signUpForm.username || !signUpForm.password || !signUpForm.confirmPassword) {
signUpError = $t('auth.fill_all_fields'); signUpError = $t('auth.fill_all_fields');
return; return;
@ -772,7 +758,6 @@
signUpLoading = true; signUpLoading = true;
try { try {
// Use vbase.request for consistent API handling
const res = await fetch('/api/auth/register', { const res = await fetch('/api/auth/register', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
@ -804,7 +789,6 @@
} }
}; };
// Initialize
loadConfig(); loadConfig();
</script> </script>

@ -322,8 +322,7 @@
<v-btn icon size="sm" variant="outline" :click="() => openDetailModal(r)" :title="$t('common.detail')"> <v-btn icon size="sm" variant="outline" :click="() => openDetailModal(r)" :title="$t('common.detail')">
<i class="fas fa-eye"></i> <i class="fas fa-eye"></i>
</v-btn> </v-btn>
<v-btn icon size="sm" variant="outline" :click="() => openEditModal(r)" :title="$t('common.edit')" <v-btn icon size="sm" variant="outline" :click="() => openEditModal(r)" :title="$t('common.edit')">
:disabled="r.is_system">
<i class="fas fa-edit"></i> <i class="fas fa-edit"></i>
</v-btn> </v-btn>
<v-btn icon size="sm" color="danger" variant="outline" :click="() => deleteRole(r)" <v-btn icon size="sm" color="danger" variant="outline" :click="() => deleteRole(r)"
@ -372,10 +371,33 @@
<span style="color: var(--text-color-secondary); font-size: var(--font-size-sm);"> <span style="color: var(--text-color-secondary); font-size: var(--font-size-sm);">
{{ $t('role.permissions_desc') }} {{ $t('role.permissions_desc') }}
</span> </span>
<v-btn size="sm" color="primary" :click="openPermissionSelector" :disabled="currentRole?.is_system"> <v-btn size="sm" color="primary" @click="showPermForm = true">
<i class="fas fa-plus"></i> {{ $t('role.add_permission') }} <i class="fas fa-plus"></i> {{ $t('role.add_permission') }}
</v-btn> </v-btn>
</div> </div>
<!-- Add Permission Inline Form -->
<div v-if="showPermForm"
style="display: flex; gap: 8px; align-items: flex-end; padding: 12px; background: var(--bg-color-tertiary); border-radius: var(--radius-md); flex-wrap: wrap;">
<div style="display: flex; flex-direction: column; gap: 4px; flex: 1; min-width: 120px;">
<label style="font-size: 12px; color: var(--text-color-secondary);">Scope</label>
<v-input type="text" v:value="newPerm.scope" placeholder="vb" style="margin: 0;" />
</div>
<div style="display: flex; flex-direction: column; gap: 4px; flex: 2; min-width: 180px;">
<label style="font-size: 12px; color: var(--text-color-secondary);">Permission ID</label>
<v-input type="text" v:value="newPerm.permission_id" placeholder="resource:instance:*" style="margin: 0;" />
</div>
<div style="display: flex; flex-direction: column; gap: 4px; width: 100px;">
<label style="font-size: 12px; color: var(--text-color-secondary);">Level</label>
<v-input v:value="newPerm.level" type='select' :opts="{options: permission_levels}" style="margin: 0;">
</v-input>
</div>
<v-btn size="sm" color="primary" :click="addPermission" :disabled="permAdding">
{{ permAdding ? '...' : $t('common.save') }}
</v-btn>
<v-btn size="sm" variant="outline" @click="showPermForm = false">{{ $t('common.cancel') }}</v-btn>
</div>
<div class="permission-list"> <div class="permission-list">
<div v-for="p in rolePermissions" class="permission-item"> <div v-for="p in rolePermissions" class="permission-item">
<div class="permission-info"> <div class="permission-info">
@ -385,12 +407,12 @@
<div class="permission-level"> <div class="permission-level">
<span :class="'level-badge level-' + p.level">{{ formatLevel(p.level) }}</span> <span :class="'level-badge level-' + p.level">{{ formatLevel(p.level) }}</span>
</div> </div>
<v-btn v-if="!currentRole?.is_system" icon size="xs" color="danger" variant="ghost" <v-btn icon size="xs" color="danger" variant="ghost" :click="() => removePermission(p)"
:click="() => removePermission(p)" :title="$t('common.remove')"> :title="$t('common.remove')">
<i class="fas fa-times"></i> <i class="fas fa-times"></i>
</v-btn> </v-btn>
</div> </div>
<div v-if="rolePermissions.length === 0" class="empty-state"> <div v-if="rolePermissions.length === 0 && !showPermForm" class="empty-state">
<i class="fas fa-shield-alt" style="font-size: 48px; color: var(--text-color-disabled);"></i> <i class="fas fa-shield-alt" style="font-size: 48px; color: var(--text-color-disabled);"></i>
<p>{{ $t('role.no_permissions') }}</p> <p>{{ $t('role.no_permissions') }}</p>
</div> </div>
@ -404,7 +426,7 @@
<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="userSearchQuery" :placeholder="$t('role.search_users')"> <input type="text" v:value="userSearchQuery" :placeholder="$t('role.search_users')">
</div> </div>
<v-btn size="sm" color="primary" :click="openUserSelector" :disabled="currentRole?.is_system"> <v-btn size="sm" color="primary" :click="openUserSelector">
<i class="fas fa-plus"></i> {{ $t('role.add_user') }} <i class="fas fa-plus"></i> {{ $t('role.add_user') }}
</v-btn> </v-btn>
</div> </div>
@ -432,32 +454,6 @@
</div> </div>
</v-dialog> </v-dialog>
<!-- Permission Selector Dialog -->
<v-dialog v:visible="showPermissionSelector" width="600px" :title="$t('role.select_permissions')">
<div style="display: flex; flex-direction: column; gap: 16px;">
<div class="search-box">
<i class="fas fa-search" style="color: var(--text-color-tertiary);"></i>
<input type="text" v:value="permissionSearchQuery" :placeholder="$t('role.search_permissions')">
</div>
<div class="available-permission-list">
<div v-for="p in filteredAvailablePermissions" class="available-permission-item">
<input type="checkbox" :checked="isPermissionSelected(p.id)" @change="togglePermission(p.id)">
<div class="permission-info">
<div class="permission-id">{{ p.permission_id }}</div>
<div class="permission-scope">{{ p.scope }}</div>
</div>
</div>
<div v-if="filteredAvailablePermissions.length === 0" class="empty-state">
<p>{{ $t('role.no_available_permissions') }}</p>
</div>
</div>
</div>
<div vslot="footer">
<v-btn variant="outline" :click="closePermissionSelector">{{ $t('common.cancel') }}</v-btn>
<v-btn color="primary" :click="saveRolePermissions">{{ $t('common.save') }}</v-btn>
</div>
</v-dialog>
<!-- User Selector Dialog --> <!-- User Selector Dialog -->
<v-dialog v:visible="showUserSelector" width="600px" :title="$t('role.select_users')"> <v-dialog v:visible="showUserSelector" width="600px" :title="$t('role.select_users')">
<div style="display: flex; flex-direction: column; gap: 16px;"> <div style="display: flex; flex-direction: column; gap: 16px;">
@ -498,6 +494,13 @@
name: "", name: "",
description: "" description: ""
}; };
permission_levels = [
{value: 1, label: $t('permission.level.create') || 'Create'},
{value: 2, label: $t('permission.level.read') || 'Read'},
{value: 4, label: $t('permission.level.write') || 'Write'},
{value: 6, label: $t('permission.level.rw') || 'Read+Write'},
{value: 7, label: $t('permission.level.admin') || 'Admin'},
]
// Detail modal data // Detail modal data
showDetailModal = false; showDetailModal = false;
@ -506,10 +509,9 @@
// Permissions data // Permissions data
rolePermissions = []; rolePermissions = [];
showPermissionSelector = false; showPermForm = false;
permissionSearchQuery = ""; permAdding = false;
availablePermissions = []; newPerm = {scope: 'vb', permission_id: '', level: 7};
selectedPermissionIds = [];
// Users data // Users data
roleUsers = []; roleUsers = [];
@ -635,94 +637,27 @@
const err = await res.json(); const err = await res.json();
throw new Error(err.message); throw new Error(err.message);
} }
rolePermissions = res || []; rolePermissions = await res.json() || [];
} catch (e) { } catch (e) {
$message.error(e.message); $message.error(e.message);
} }
}; };
openPermissionSelector = async () => { addPermission = async () => {
selectedPermissionIds = rolePermissions.map(p => p.id); if (!currentRole || !newPerm.permission_id || !newPerm.scope) {
showPermissionSelector = true; $message.error('Scope and Permission ID are required');
await loadAvailablePermissions(); return;
};
closePermissionSelector = () => {
showPermissionSelector = false;
permissionSearchQuery = "";
selectedPermissionIds = [];
};
loadAvailablePermissions = async () => {
// Get all permissions from user's own permissions or a dedicated endpoint
// For now, we'll use a mock approach - in real implementation,
// you might need a /api/permissions endpoint to list all available permissions
try {
// This is a placeholder - adjust based on your actual API
const res = await fetch('/api/auth/me');
if (res.status !== 200) {
const err = await res.json();
throw new Error(err.message);
}
const me = await res.json();
if (me.permissions) {
// Extract unique permission IDs
const uniquePerms = [];
const seen = new Set();
me.permissions.forEach(p => {
if (!seen.has(p.permission_id)) {
seen.add(p.permission_id);
uniquePerms.push({
id: p.permission_id,
permission_id: p.permission_id,
scope: p.scope,
level: p.level
});
}
});
availablePermissions = uniquePerms;
}
} catch (e) {
// Fallback: use current role permissions as base
availablePermissions = rolePermissions.map(p => ({
id: p.id,
permission_id: p.permission_id,
scope: p.scope,
level: p.level
}));
}
};
filteredAvailablePermissions = () => {
if (!permissionSearchQuery) return availablePermissions;
const query = permissionSearchQuery.toLowerCase();
return availablePermissions.filter(p =>
p.permission_id.toLowerCase().includes(query) ||
p.scope.toLowerCase().includes(query)
);
};
isPermissionSelected = (id) => {
return selectedPermissionIds.includes(id);
};
togglePermission = (id) => {
const idx = selectedPermissionIds.indexOf(id);
if (idx > -1) {
selectedPermissionIds.splice(idx, 1);
} else {
selectedPermissionIds.push(id);
} }
// Trigger reactivity permAdding = true;
selectedPermissionIds = [...selectedPermissionIds];
};
saveRolePermissions = async () => {
if (!currentRole) return;
try { try {
const res = await fetch(`/api/roles/${currentRole.id}/permissions`, { const res = await fetch(`/api/roles/${currentRole.id}/permissions`, {
method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ method: 'PUT', headers: {'Content-Type': 'application/json'},
permission_ids: selectedPermissionIds body: JSON.stringify({
permissions: [{
scope: newPerm.scope,
permission_id: newPerm.permission_id,
level: parseInt(newPerm.level)
}]
}) })
}); });
if (res.status !== 200) { if (res.status !== 200) {
@ -730,23 +665,24 @@
throw new Error(err.message); throw new Error(err.message);
} }
$message.success($t('role.permissions_updated')); $message.success($t('role.permissions_updated'));
closePermissionSelector(); showPermForm = false;
newPerm = {scope: 'vb', permission_id: '', level: 7};
loadRolePermissions(); loadRolePermissions();
} catch (e) { } catch (e) {
$message.error(e.message); $message.error(e.message);
} finally {
permAdding = false;
} }
}; };
removePermission = async (p) => { removePermission = async (p) => {
if (!currentRole || currentRole.is_system) return; if (!currentRole) return;
try { try {
await $message.confirm($t('role.remove_permission_confirm')); await $message.confirm($t('role.remove_permission_confirm'));
const newPermissions = rolePermissions
.filter(rp => rp.id !== p.id)
.map(rp => rp.id);
const res = await fetch(`/api/roles/${currentRole.id}/permissions`, { const res = await fetch(`/api/roles/${currentRole.id}/permissions`, {
method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ method: 'PUT', headers: {'Content-Type': 'application/json'},
permission_ids: newPermissions body: JSON.stringify({
remove: [p.id]
}) })
}); });
if (res.status !== 200) { if (res.status !== 200) {
@ -761,48 +697,21 @@
}; };
formatLevel = (level) => { formatLevel = (level) => {
const levels = { const item = permission_levels.find(l => l.value === level);
1: $t('permission.level.create') || 'Create', return item ? item.label : 'Level ' + level;
2: $t('permission.level.read') || 'Read',
4: $t('permission.level.write') || 'Write',
6: $t('permission.level.rw') || 'Read+Write',
7: $t('permission.level.admin') || 'Admin'
};
return levels[level] || `Level ${level}`;
}; };
// User functions // User functions
loadRoleUsers = async () => { loadRoleUsers = async () => {
if (!currentRole) return; if (!currentRole) return;
try { try {
// Get users who have this role const res = await fetch(`/api/roles/${currentRole.id}/users?page=1&page_size=100`);
// We need to fetch users and filter by role
const params = new URLSearchParams({page_size: 1000});
let res = await fetch('/api/users?' + params);
if (res.status !== 200) { if (res.status !== 200) {
const err = await res.json(); const err = await res.json();
throw new Error(err.message); throw new Error(err.message);
} }
const data = await res.json(); const data = await res.json();
const allUsers = data.items || []; roleUsers = data.items || [];
// For each user, check if they have this role
const usersWithRole = [];
for (const user of allUsers) {
try {
res = await fetch(`/api/users/${user.id}/roles`);
if (res.status !== 200) {
const err = await res.json();
throw new Error(err.message);
}
const userRoles = await res.json();
if (userRoles.some(ur => ur.id === currentRole.id)) {
usersWithRole.push(user);
}
} catch (e) {
// Skip users we can't check
}
}
roleUsers = usersWithRole;
} catch (e) { } catch (e) {
$message.error(e.message); $message.error(e.message);
} }
@ -835,8 +744,8 @@
const params = new URLSearchParams({keyword: availableUserSearchQuery, limit: 50}); const params = new URLSearchParams({keyword: availableUserSearchQuery, limit: 50});
const res = await fetch('/api/auth/users?' + params); const res = await fetch('/api/auth/users?' + params);
const json_ = await res.json(); const json_ = await res.json();
if (json_.code !== 200) throw new Error(json_.message); if (res.status !== 200) throw new Error(json_.message);
availableUsers = json_.data?.items || []; availableUsers = json_?.items || [];
} catch (e) { } catch (e) {
$message.error(e.message); $message.error(e.message);
} }
@ -860,45 +769,15 @@
saveRoleUsers = async () => { saveRoleUsers = async () => {
if (!currentRole) return; if (!currentRole) return;
try { try {
// Update each user's roles const res = await fetch(`/api/roles/${currentRole.id}/users`, {
const currentUserIds = roleUsers.map(u => u.id); method: 'PUT',
headers: {'Content-Type': 'application/json'},
// Users to add body: JSON.stringify({user_ids: selectedUserIds})
const toAdd = selectedUserIds.filter(id => !currentUserIds.includes(id)); });
// Users to remove if (res.status !== 200) {
const toRemove = currentUserIds.filter(id => !selectedUserIds.includes(id)); const err = await res.json();
throw new Error(err.message);
let res = null
for (const userId of toAdd) {
res = await fetch(`/api/users/${userId}/roles`);
if (res.status !== 200) {
const err = await res.json();
throw new Error(err.message);
}
const userRoles = await res.json();
const roleIds = [...userRoles.map(r => r.id), currentRole.id];
res = await fetch(`/api/users/${userId}/roles`, {method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({role_ids: roleIds})});
if (res.status !== 200) {
const err = await res.json();
throw new Error(err.message);
}
}
for (const userId of toRemove) {
res = await fetch(`/api/users/${userId}/roles`);
if (res.status !== 200) {
const err = await res.json();
throw new Error(err.message);
}
const userRoles = await res.json();
const roleIds = userRoles.filter(r => r.id !== currentRole.id).map(r => r.id);
res = await fetch(`/api/users/${userId}/roles`, {method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({role_ids: roleIds})});
if (res.status !== 200) {
const err = await res.json();
throw new Error(err.message);
}
} }
$message.success($t('role.users_updated')); $message.success($t('role.users_updated'));
closeUserSelector(); closeUserSelector();
loadRoleUsers(); loadRoleUsers();
@ -911,14 +790,12 @@
if (!currentRole || currentRole.is_system) return; if (!currentRole || currentRole.is_system) return;
try { try {
await $message.confirm($t('role.remove_user_confirm')); await $message.confirm($t('role.remove_user_confirm'));
let res = await fetch(`/api/users/${u.id}/roles`); const newIds = roleUsers.filter(ru => ru.id !== u.id).map(ru => ru.id);
if (res.status !== 200) { const res = await fetch(`/api/roles/${currentRole.id}/users`, {
const err = await res.json(); method: 'PUT',
throw new Error(err.message); headers: {'Content-Type': 'application/json'},
} body: JSON.stringify({user_ids: newIds})
const userRoles = await res.json(); });
const roleIds = userRoles.filter(r => r.id !== currentRole.id).map(r => r.id);
res = await fetch(`/api/users/${u.id}/roles`, {method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({role_ids: roleIds})});
if (res.status !== 200) { if (res.status !== 200) {
const err = await res.json(); const err = await res.json();
throw new Error(err.message); throw new Error(err.message);

@ -39,17 +39,12 @@ export default ({ $mod }) => ({
routes: routes, routes: routes,
beforeEnter: async (to, from, next) => { beforeEnter: async (to, from, next) => {
const isAuth = to.meta && to.meta.auth; const isAuth = to.meta && to.meta.auth;
const roles = to.meta && to.meta.roles; // Array of required roles
const vbase = $mod.$auth const vbase = $mod.$auth
if (isAuth) { if (isAuth) {
if (!vbase.user) { if (!(await vbase.isLogin())) {
try { vbase.logout();
await vbase.fetchUser(); return false;
} catch (e) {
vbase.logout('/login?redirect=' + encodeURIComponent(to.fullPath));
return false;
}
} }
// Permission Check // Permission Check

@ -42,11 +42,16 @@ class VBase {
this._loadingUserIDs = new Set(); this._loadingUserIDs = new Set();
this._resolvedUserIDs = new Set(); this._resolvedUserIDs = new Set();
this._pendingUserFlush = null; this._pendingUserFlush = null;
}
// 初始化鉴权:有用户缓存就验证 + 定时刷新 /** 异步判断是否已登录 */
if (this._user) { async isLogin() {
this._ensureAuth(); if (!this._user) return false;
if (!this._initDone) {
this._initDone = true;
await this._ensureAuth();
} }
return !!this._user;
} }
async _ensureAuth() { async _ensureAuth() {

Loading…
Cancel
Save