login is ready

This commit is contained in:
root
2025-09-12 10:02:09 +05:00
parent c52b1c72e7
commit 4f18dd6ae3
18 changed files with 1050 additions and 170 deletions
+1 -1
View File
@@ -20,7 +20,7 @@
/>
</g>
</svg>
<h4 class="font-medium text-3xl text-surface-900 dark:text-surface-0">SAKAI</h4>
<h4 class="font-medium text-3xl text-surface-900 dark:text-surface-0">KonturAI</h4>
</a>
</div>
+1 -1
View File
@@ -30,7 +30,7 @@ function smoothScroll(id) {
/>
</g>
</svg>
<span class="text-surface-900 dark:text-surface-0 font-medium text-2xl leading-normal mr-20">SAKAI</span>
<span class="text-surface-900 dark:text-surface-0 font-medium text-2xl leading-normal mr-20">KonturAI</span>
</a>
<Button
class="lg:!hidden"
+2 -2
View File
@@ -2,7 +2,7 @@
<template>
<div class="layout-footer">
SAKAI by
<a href="https://primevue.org" target="_blank" rel="noopener noreferrer" class="text-primary font-bold hover:underline">PrimeVue</a>
KonturAI
<!-- <a href="https://primevue.org" target="_blank" rel="noopener noreferrer" class="text-primary font-bold hover:underline">PrimeVue</a> -->
</div>
</template>
+132 -125
View File
@@ -6,136 +6,143 @@ import AppMenuItem from './AppMenuItem.vue';
const model = ref([
{
label: 'Home',
items: [{ label: 'Dashboard', icon: 'pi pi-fw pi-home', to: '/' }]
},
{
label: 'UI Components',
items: [
{ label: 'Form Layout', icon: 'pi pi-fw pi-id-card', to: '/uikit/formlayout' },
{ label: 'Input', icon: 'pi pi-fw pi-check-square', to: '/uikit/input' },
{ label: 'Button', icon: 'pi pi-fw pi-mobile', to: '/uikit/button', class: 'rotated-icon' },
{ label: 'Table', icon: 'pi pi-fw pi-table', to: '/uikit/table' },
{ label: 'List', icon: 'pi pi-fw pi-list', to: '/uikit/list' },
{ label: 'Tree', icon: 'pi pi-fw pi-share-alt', to: '/uikit/tree' },
{ label: 'Panel', icon: 'pi pi-fw pi-tablet', to: '/uikit/panel' },
{ label: 'Overlay', icon: 'pi pi-fw pi-clone', to: '/uikit/overlay' },
{ label: 'Media', icon: 'pi pi-fw pi-image', to: '/uikit/media' },
{ label: 'Menu', icon: 'pi pi-fw pi-bars', to: '/uikit/menu' },
{ label: 'Message', icon: 'pi pi-fw pi-comment', to: '/uikit/message' },
{ label: 'File', icon: 'pi pi-fw pi-file', to: '/uikit/file' },
{ label: 'Chart', icon: 'pi pi-fw pi-chart-bar', to: '/uikit/charts' },
{ label: 'Timeline', icon: 'pi pi-fw pi-calendar', to: '/uikit/timeline' },
{ label: 'Misc', icon: 'pi pi-fw pi-circle', to: '/uikit/misc' }
// { label: 'Dashboard', icon: 'pi pi-fw pi-home', to: '/' },
{ label: 'New Dashboard', icon: 'pi pi-fw pi-home', to: '/dashboard-new' }
]
},
{
label: 'Pages',
icon: 'pi pi-fw pi-briefcase',
to: '/pages',
items: [
{
label: 'Landing',
icon: 'pi pi-fw pi-globe',
to: '/landing'
},
{
label: 'Auth',
icon: 'pi pi-fw pi-user',
items: [
{
label: 'Login',
icon: 'pi pi-fw pi-sign-in',
to: '/auth/login'
},
{
label: 'Error',
icon: 'pi pi-fw pi-times-circle',
to: '/auth/error'
},
{
label: 'Access Denied',
icon: 'pi pi-fw pi-lock',
to: '/auth/access'
}
]
},
{
label: 'Crud',
icon: 'pi pi-fw pi-pencil',
to: '/pages/crud'
},
{
label: 'Not Found',
icon: 'pi pi-fw pi-exclamation-circle',
to: '/pages/notfound'
},
{
label: 'Empty',
icon: 'pi pi-fw pi-circle-off',
to: '/pages/empty'
}
]
},
{
label: 'Hierarchy',
items: [
{
label: 'Submenu 1',
icon: 'pi pi-fw pi-bookmark',
items: [
{
label: 'Submenu 1.1',
icon: 'pi pi-fw pi-bookmark',
items: [
{ label: 'Submenu 1.1.1', icon: 'pi pi-fw pi-bookmark' },
{ label: 'Submenu 1.1.2', icon: 'pi pi-fw pi-bookmark' },
{ label: 'Submenu 1.1.3', icon: 'pi pi-fw pi-bookmark' }
]
},
{
label: 'Submenu 1.2',
icon: 'pi pi-fw pi-bookmark',
items: [{ label: 'Submenu 1.2.1', icon: 'pi pi-fw pi-bookmark' }]
}
]
},
{
label: 'Submenu 2',
icon: 'pi pi-fw pi-bookmark',
items: [
{
label: 'Submenu 2.1',
icon: 'pi pi-fw pi-bookmark',
items: [
{ label: 'Submenu 2.1.1', icon: 'pi pi-fw pi-bookmark' },
{ label: 'Submenu 2.1.2', icon: 'pi pi-fw pi-bookmark' }
]
},
{
label: 'Submenu 2.2',
icon: 'pi pi-fw pi-bookmark',
items: [{ label: 'Submenu 2.2.1', icon: 'pi pi-fw pi-bookmark' }]
}
]
}
]
},
{
label: 'Get Started',
items: [
{
label: 'Documentation',
icon: 'pi pi-fw pi-book',
to: '/documentation'
},
{
label: 'View Source',
icon: 'pi pi-fw pi-github',
url: 'https://github.com/primefaces/sakai-vue',
target: '_blank'
}
]
label: 'Admin',
items: [{ label: 'Users', icon: 'pi pi-fw pi-users', to: '/admin/users' }]
}
// {
// label: 'UI Components',
// items: [
// { label: 'Form Layout', icon: 'pi pi-fw pi-id-card', to: '/uikit/formlayout' },
// { label: 'Input', icon: 'pi pi-fw pi-check-square', to: '/uikit/input' },
// { label: 'Button', icon: 'pi pi-fw pi-mobile', to: '/uikit/button', class: 'rotated-icon' },
// { label: 'Table', icon: 'pi pi-fw pi-table', to: '/uikit/table' },
// { label: 'List', icon: 'pi pi-fw pi-list', to: '/uikit/list' },
// { label: 'Tree', icon: 'pi pi-fw pi-share-alt', to: '/uikit/tree' },
// { label: 'Panel', icon: 'pi pi-fw pi-tablet', to: '/uikit/panel' },
// { label: 'Overlay', icon: 'pi pi-fw pi-clone', to: '/uikit/overlay' },
// { label: 'Media', icon: 'pi pi-fw pi-image', to: '/uikit/media' },
// { label: 'Menu', icon: 'pi pi-fw pi-bars', to: '/uikit/menu' },
// { label: 'Message', icon: 'pi pi-fw pi-comment', to: '/uikit/message' },
// { label: 'File', icon: 'pi pi-fw pi-file', to: '/uikit/file' },
// { label: 'Chart', icon: 'pi pi-fw pi-chart-bar', to: '/uikit/charts' },
// { label: 'Timeline', icon: 'pi pi-fw pi-calendar', to: '/uikit/timeline' },
// { label: 'Misc', icon: 'pi pi-fw pi-circle', to: '/uikit/misc' }
// ]
// },
// {
// label: 'Pages',
// icon: 'pi pi-fw pi-briefcase',
// to: '/pages',
// items: [
// {
// label: 'Landing',
// icon: 'pi pi-fw pi-globe',
// to: '/landing'
// },
// {
// label: 'Auth',
// icon: 'pi pi-fw pi-user',
// items: [
// {
// label: 'Login',
// icon: 'pi pi-fw pi-sign-in',
// to: '/auth/login'
// },
// {
// label: 'Error',
// icon: 'pi pi-fw pi-times-circle',
// to: '/auth/error'
// },
// {
// label: 'Access Denied',
// icon: 'pi pi-fw pi-lock',
// to: '/auth/access'
// }
// ]
// },
// {
// label: 'Crud',
// icon: 'pi pi-fw pi-pencil',
// to: '/pages/crud'
// },
// {
// label: 'Not Found',
// icon: 'pi pi-fw pi-exclamation-circle',
// to: '/pages/notfound'
// },
// {
// label: 'Empty',
// icon: 'pi pi-fw pi-circle-off',
// to: '/pages/empty'
// }
// ]
// },
// {
// label: 'Hierarchy',
// items: [
// {
// label: 'Submenu 1',
// icon: 'pi pi-fw pi-bookmark',
// items: [
// {
// label: 'Submenu 1.1',
// icon: 'pi pi-fw pi-bookmark',
// items: [
// { label: 'Submenu 1.1.1', icon: 'pi pi-fw pi-bookmark' },
// { label: 'Submenu 1.1.2', icon: 'pi pi-fw pi-bookmark' },
// { label: 'Submenu 1.1.3', icon: 'pi pi-fw pi-bookmark' }
// ]
// },
// {
// label: 'Submenu 1.2',
// icon: 'pi pi-fw pi-bookmark',
// items: [{ label: 'Submenu 1.2.1', icon: 'pi pi-fw pi-bookmark' }]
// }
// ]
// },
// {
// label: 'Submenu 2',
// icon: 'pi pi-fw pi-bookmark',
// items: [
// {
// label: 'Submenu 2.1',
// icon: 'pi pi-fw pi-bookmark',
// items: [
// { label: 'Submenu 2.1.1', icon: 'pi pi-fw pi-bookmark' },
// { label: 'Submenu 2.1.2', icon: 'pi pi-fw pi-bookmark' }
// ]
// },
// {
// label: 'Submenu 2.2',
// icon: 'pi pi-fw pi-bookmark',
// items: [{ label: 'Submenu 2.2.1', icon: 'pi pi-fw pi-bookmark' }]
// }
// ]
// }
// ]
// },
// {
// label: 'Get Started',
// items: [
// {
// label: 'Documentation',
// icon: 'pi pi-fw pi-book',
// to: '/documentation'
// },
// {
// label: 'View Source',
// icon: 'pi pi-fw pi-github',
// url: 'https://github.com/primefaces/sakai-vue',
// target: '_blank'
// }
// ]
// }
]);
</script>
+12 -4
View File
@@ -1,8 +1,16 @@
<script setup>
import { useLayout } from '@/layout/composables/layout';
import AuthService from '@/service/AuthService';
import { useRouter } from 'vue-router';
import AppConfigurator from './AppConfigurator.vue';
const { toggleMenu, toggleDarkMode, isDarkTheme } = useLayout();
const router = useRouter();
async function onLogout() {
await AuthService.logout();
router.replace({ name: 'login' });
}
</script>
<template>
@@ -30,7 +38,7 @@ const { toggleMenu, toggleDarkMode, isDarkTheme } = useLayout();
</g>
</svg>
<span>SAKAI</span>
<span>KonturAI</span>
</router-link>
</div>
@@ -68,9 +76,9 @@ const { toggleMenu, toggleDarkMode, isDarkTheme } = useLayout();
<i class="pi pi-inbox"></i>
<span>Messages</span>
</button>
<button type="button" class="layout-topbar-action">
<i class="pi pi-user"></i>
<span>Profile</span>
<button type="button" class="layout-topbar-action" @click="onLogout">
<i class="pi pi-sign-out"></i>
<span>Logout</span>
</button>
</div>
</div>
+80 -19
View File
@@ -1,4 +1,6 @@
import AppLayout from '@/layout/AppLayout.vue';
import AuthService from '@/service/AuthService';
import IdentityService from '@/service/IdentityService';
import { createRouter, createWebHistory } from 'vue-router';
const router = createRouter({
@@ -11,98 +13,136 @@ const router = createRouter({
{
path: '/',
name: 'dashboard',
component: () => import('@/views/Dashboard.vue')
component: () => import('@/views/Dashboard.vue'),
meta: { requiresAuth: true }
},
{
path: '/dashboard-new',
name: 'dashboard-new',
component: () => import('@/views/DashboardNew.vue'),
meta: { requiresAuth: true }
},
{
path: '/uikit/formlayout',
name: 'formlayout',
component: () => import('@/views/uikit/FormLayout.vue')
component: () => import('@/views/uikit/FormLayout.vue'),
meta: { requiresAuth: true }
},
{
path: '/uikit/input',
name: 'input',
component: () => import('@/views/uikit/InputDoc.vue')
component: () => import('@/views/uikit/InputDoc.vue'),
meta: { requiresAuth: true }
},
{
path: '/uikit/button',
name: 'button',
component: () => import('@/views/uikit/ButtonDoc.vue')
component: () => import('@/views/uikit/ButtonDoc.vue'),
meta: { requiresAuth: true }
},
{
path: '/uikit/table',
name: 'table',
component: () => import('@/views/uikit/TableDoc.vue')
component: () => import('@/views/uikit/TableDoc.vue'),
meta: { requiresAuth: true }
},
{
path: '/uikit/list',
name: 'list',
component: () => import('@/views/uikit/ListDoc.vue')
component: () => import('@/views/uikit/ListDoc.vue'),
meta: { requiresAuth: true }
},
{
path: '/uikit/tree',
name: 'tree',
component: () => import('@/views/uikit/TreeDoc.vue')
component: () => import('@/views/uikit/TreeDoc.vue'),
meta: { requiresAuth: true }
},
{
path: '/uikit/panel',
name: 'panel',
component: () => import('@/views/uikit/PanelsDoc.vue')
component: () => import('@/views/uikit/PanelsDoc.vue'),
meta: { requiresAuth: true }
},
{
path: '/uikit/overlay',
name: 'overlay',
component: () => import('@/views/uikit/OverlayDoc.vue')
component: () => import('@/views/uikit/OverlayDoc.vue'),
meta: { requiresAuth: true }
},
{
path: '/uikit/media',
name: 'media',
component: () => import('@/views/uikit/MediaDoc.vue')
component: () => import('@/views/uikit/MediaDoc.vue'),
meta: { requiresAuth: true }
},
{
path: '/uikit/message',
name: 'message',
component: () => import('@/views/uikit/MessagesDoc.vue')
component: () => import('@/views/uikit/MessagesDoc.vue'),
meta: { requiresAuth: true }
},
{
path: '/uikit/file',
name: 'file',
component: () => import('@/views/uikit/FileDoc.vue')
component: () => import('@/views/uikit/FileDoc.vue'),
meta: { requiresAuth: true }
},
{
path: '/uikit/menu',
name: 'menu',
component: () => import('@/views/uikit/MenuDoc.vue')
component: () => import('@/views/uikit/MenuDoc.vue'),
meta: { requiresAuth: true }
},
{
path: '/uikit/charts',
name: 'charts',
component: () => import('@/views/uikit/ChartDoc.vue')
component: () => import('@/views/uikit/ChartDoc.vue'),
meta: { requiresAuth: true }
},
{
path: '/uikit/misc',
name: 'misc',
component: () => import('@/views/uikit/MiscDoc.vue')
component: () => import('@/views/uikit/MiscDoc.vue'),
meta: { requiresAuth: true }
},
{
path: '/uikit/timeline',
name: 'timeline',
component: () => import('@/views/uikit/TimelineDoc.vue')
component: () => import('@/views/uikit/TimelineDoc.vue'),
meta: { requiresAuth: true }
},
{
path: '/pages/empty',
name: 'empty',
component: () => import('@/views/pages/Empty.vue')
component: () => import('@/views/pages/Empty.vue'),
meta: { requiresAuth: true }
},
{
path: '/pages/crud',
name: 'crud',
component: () => import('@/views/pages/Crud.vue')
component: () => import('@/views/pages/Crud.vue'),
meta: { requiresAuth: true }
},
{
path: '/documentation',
name: 'documentation',
component: () => import('@/views/pages/Documentation.vue')
component: () => import('@/views/pages/Documentation.vue'),
meta: { requiresAuth: true }
}
]
},
{
path: '/admin',
component: AppLayout,
children: [
{
path: 'users',
name: 'admin-users',
component: () => import('@/views/pages/AdminUsers.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
}
]
},
@@ -135,4 +175,25 @@ const router = createRouter({
]
});
router.beforeEach(async (to) => {
const isAuthenticated = !!AuthService.getAccessToken();
if (to.meta.requiresAuth && !isAuthenticated) {
return { name: 'login', query: { redirect: to.fullPath } };
}
if (to.name === 'login' && isAuthenticated) {
return { path: '/' };
}
if (to.meta.requiresAdmin) {
try {
const me = await IdentityService.getCurrentUser();
const roles = Array.isArray(me?.roles) ? me.roles : [];
if (!roles.includes('ROLE_ADMIN')) {
return { name: 'accessDenied' };
}
} catch (_) {
return { name: 'login', query: { redirect: to.fullPath } };
}
}
});
export default router;
+49
View File
@@ -0,0 +1,49 @@
import AuthService from '@/service/AuthService';
const { API_URL, authFetch, readErrorMessage } = AuthService;
async function createUser(email, password) {
const res = await authFetch(`${API_URL}/api/admin/users`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
if (!res.ok) throw new Error(await readErrorMessage(res));
}
async function assignRoles(email, roles) {
const res = await authFetch(`${API_URL}/api/admin/users/roles`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, roles })
});
if (!res.ok) throw new Error(await readErrorMessage(res));
}
async function getRoles() {
const res = await authFetch(`${API_URL}/api/admin/roles`, { method: 'GET' });
if (!res.ok) throw new Error(await readErrorMessage(res));
return res.json();
}
async function listUsers(page = 0, size = 20) {
const res = await authFetch(`${API_URL}/api/admin/users?page=${page}&size=${size}`, { method: 'GET' });
if (!res.ok) throw new Error(await readErrorMessage(res));
return res.json();
}
async function updateUser(payload) {
const res = await authFetch(`${API_URL}/api/admin/users/update`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error(await readErrorMessage(res));
}
async function deleteUser(id) {
const res = await authFetch(`${API_URL}/api/admin/users?id=${encodeURIComponent(id)}`, { method: 'DELETE' });
if (!res.ok) throw new Error(await readErrorMessage(res));
}
export default { createUser, assignRoles, getRoles, listUsers, updateUser, deleteUser };
+98
View File
@@ -0,0 +1,98 @@
// Lightweight auth service to manage tokens and API calls
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080';
const TOKEN_STORAGE_KEY = 'auth.accessToken';
const REFRESH_STORAGE_KEY = 'auth.refreshToken';
function getAccessToken() {
return localStorage.getItem(TOKEN_STORAGE_KEY) || '';
}
function getRefreshToken() {
return localStorage.getItem(REFRESH_STORAGE_KEY) || '';
}
function setTokens(accessToken, refreshToken) {
if (accessToken) localStorage.setItem(TOKEN_STORAGE_KEY, accessToken);
if (refreshToken) localStorage.setItem(REFRESH_STORAGE_KEY, refreshToken);
}
function clearTokens() {
localStorage.removeItem(TOKEN_STORAGE_KEY);
localStorage.removeItem(REFRESH_STORAGE_KEY);
}
async function signin(email, password) {
const res = await fetch(`${API_URL}/api/auth/signin`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
if (!res.ok) {
let message = 'Sign in failed';
try {
const data = await res.json();
message = data.message || message;
} catch (_) {}
throw new Error(message);
}
const data = await res.json();
setTokens(data.accessToken, data.refreshToken);
return data;
}
async function refreshToken() {
const refresh = getRefreshToken();
if (!refresh) throw new Error('No refresh token');
const res = await fetch(`${API_URL}/api/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: refresh })
});
if (!res.ok) throw new Error('Refresh failed');
const data = await res.json();
setTokens(data.accessToken, data.refreshToken);
return data.accessToken;
}
async function logout() {
try {
await fetch(`${API_URL}/api/auth/logout`, { method: 'POST', headers: { 'Content-Type': 'application/json' } });
} catch (_) {
// ignore network errors on logout
}
clearTokens();
}
async function authFetch(input, init = {}) {
const headers = new Headers(init.headers || {});
const token = getAccessToken();
if (token) headers.set('Authorization', `Bearer ${token}`);
headers.set('Content-Type', headers.get('Content-Type') || 'application/json');
let response = await fetch(input, { ...init, headers });
if (response.status !== 401) return response;
// try refresh once
try {
const newAccess = await refreshToken();
const retryHeaders = new Headers(init.headers || {});
retryHeaders.set('Authorization', `Bearer ${newAccess}`);
if (!retryHeaders.get('Content-Type')) retryHeaders.set('Content-Type', 'application/json');
response = await fetch(input, { ...init, headers: retryHeaders });
} catch (_) {
await logout();
}
return response;
}
export default {
API_URL,
signin,
refreshToken,
logout,
authFetch,
getAccessToken,
getRefreshToken,
setTokens,
clearTokens
};
+15
View File
@@ -0,0 +1,15 @@
import AuthService from '@/service/AuthService';
const { API_URL, authFetch, readErrorMessage } = AuthService;
async function getCurrentUser() {
const res = await authFetch(`${API_URL}/api/identity/me`, { method: 'GET' });
if (!res.ok) {
throw new Error(await readErrorMessage(res));
}
return res.json();
}
export default {
getCurrentUser
};
+14
View File
@@ -0,0 +1,14 @@
<template>
<div class="card">
<h1 class="text-2xl font-semibold">New Dashboard</h1>
<p class="text-color-secondary">Страница пока пустая.</p>
</div>
</template>
<script>
export default {
name: 'DashboardNew'
};
</script>
<style scoped></style>
+194
View File
@@ -0,0 +1,194 @@
<script setup>
import AdminService from '@/service/AdminService';
import { onMounted, ref } from 'vue';
const createEmail = ref('');
const createPassword = ref('');
const createLoading = ref(false);
const createError = ref('');
const createSuccess = ref('');
const roles = ref([]);
const assignEmail = ref('');
const assignRoles = ref([]);
const assignLoading = ref(false);
const assignError = ref('');
const assignSuccess = ref('');
const usersPage = ref({ content: [], totalElements: 0, number: 0, size: 10 });
const tableLoading = ref(false);
const editDialogVisible = ref(false);
const editForm = ref({ id: null, email: '', password: '', roles: [] });
const editSaving = ref(false);
const editError = ref('');
async function loadRoles() {
try {
roles.value = await AdminService.getRoles();
} catch (e) {
roles.value = [];
}
}
async function loadUsers(page = 0, size = usersPage.value.size || 10) {
tableLoading.value = true;
try {
usersPage.value = await AdminService.listUsers(page, size);
} catch (e) {
usersPage.value = { content: [], totalElements: 0, number: 0, size };
} finally {
tableLoading.value = false;
}
}
onMounted(async () => {
await Promise.all([loadRoles(), loadUsers(0, 10)]);
});
async function onCreateUser() {
createError.value = '';
createSuccess.value = '';
createLoading.value = true;
try {
await AdminService.createUser(createEmail.value.trim().toLowerCase(), createPassword.value);
createSuccess.value = 'Пользователь создан';
createEmail.value = '';
createPassword.value = '';
await loadUsers(usersPage.value.number, usersPage.value.size);
} catch (e) {
createError.value = e?.message || 'Ошибка создания пользователя';
} finally {
createLoading.value = false;
}
}
async function onAssignRoles() {
assignError.value = '';
assignSuccess.value = '';
assignLoading.value = true;
try {
await AdminService.assignRoles(assignEmail.value.trim().toLowerCase(), assignRoles.value);
assignSuccess.value = 'Роли обновлены';
await loadUsers(usersPage.value.number, usersPage.value.size);
} catch (e) {
assignError.value = e?.message || 'Ошибка назначения ролей';
} finally {
assignLoading.value = false;
}
}
function openEdit(user) {
editError.value = '';
editForm.value = {
id: user.id,
email: user.email,
password: '',
roles: String(user.roles || '')
.split(',')
.map((r) => r.trim())
.filter((r) => r.length > 0)
};
editDialogVisible.value = true;
}
async function saveEdit() {
editError.value = '';
editSaving.value = true;
try {
const payload = { id: editForm.value.id };
if (editForm.value.email && editForm.value.email !== '') payload.email = editForm.value.email;
if (editForm.value.password && editForm.value.password !== '') payload.password = editForm.value.password;
if (Array.isArray(editForm.value.roles)) payload.roles = editForm.value.roles;
await AdminService.updateUser(payload);
editDialogVisible.value = false;
await loadUsers(usersPage.value.number, usersPage.value.size);
} catch (e) {
editError.value = e?.message || 'Ошибка сохранения';
} finally {
editSaving.value = false;
}
}
async function removeUser(user) {
if (!confirm('Удалить пользователя?')) return;
try {
await AdminService.deleteUser(user.id);
await loadUsers(usersPage.value.number, usersPage.value.size);
} catch (e) {
// ignore for now
}
}
</script>
<template>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8 p-6">
<div class="bg-surface-0 dark:bg-surface-900 p-6 rounded-md">
<h2 class="text-xl font-semibold mb-4">Создать пользователя</h2>
<div class="mb-4">
<label class="block mb-2">Email</label>
<InputText v-model="createEmail" placeholder="user@example.com" class="w-full" />
</div>
<div class="mb-4">
<label class="block mb-2">Пароль</label>
<Password v-model="createPassword" :feedback="false" placeholder="Пароль" fluid />
</div>
<div v-if="createError" class="text-red-500 text-sm mb-2">{{ createError }}</div>
<div v-if="createSuccess" class="text-green-500 text-sm mb-2">{{ createSuccess }}</div>
<Button :loading="createLoading" label="Создать" @click="onCreateUser" />
</div>
<div class="bg-surface-0 dark:bg-surface-900 p-6 rounded-md">
<h2 class="text-xl font-semibold mb-4">Назначить роли</h2>
<div class="mb-4">
<label class="block mb-2">Email</label>
<InputText v-model="assignEmail" placeholder="user@example.com" class="w-full" />
</div>
<div class="mb-4">
<label class="block mb-2">Роли</label>
<MultiSelect v-model="assignRoles" :options="roles" class="w-full" display="chip" />
</div>
<div v-if="assignError" class="text-red-500 text-sm mb-2">{{ assignError }}</div>
<div v-if="assignSuccess" class="text-green-500 text-sm mb-2">{{ assignSuccess }}</div>
<Button :loading="assignLoading" label="Назначить" @click="onAssignRoles" />
</div>
<div class="bg-surface-0 dark:bg-surface-900 p-6 rounded-md lg:col-span-3">
<h2 class="text-xl font-semibold mb-4">Пользователи</h2>
<DataTable :value="usersPage.content" :loading="tableLoading" tableStyle="min-width: 50rem">
<Column field="id" header="ID" style="width: 6rem" />
<Column field="email" header="Email" />
<Column field="roles" header="Роли" />
<Column header="Действия" style="width: 16rem">
<template #body="{ data }">
<Button label="Редактировать" size="small" class="mr-2" @click="openEdit(data)" />
<Button label="Удалить" size="small" severity="danger" @click="removeUser(data)" />
</template>
</Column>
</DataTable>
<div class="flex justify-end mt-4">
<Paginator :rows="usersPage.size" :totalRecords="usersPage.totalElements" :first="usersPage.number * usersPage.size" @page="(e) => loadUsers(e.page, e.rows)" />
</div>
</div>
<Dialog v-model:visible="editDialogVisible" modal header="Редактировать пользователя" :style="{ width: '30rem' }">
<div class="mb-3">
<label class="block mb-2">Email</label>
<InputText v-model="editForm.email" class="w-full" />
</div>
<div class="mb-3">
<label class="block mb-2">Пароль (опционально)</label>
<Password v-model="editForm.password" :feedback="false" toggleMask fluid />
</div>
<div class="mb-3">
<label class="block mb-2">Роли</label>
<MultiSelect v-model="editForm.roles" :options="roles" class="w-full" display="chip" />
</div>
<div v-if="editError" class="text-red-500 text-sm mb-2">{{ editError }}</div>
<div class="flex justify-end gap-2">
<Button label="Отмена" severity="secondary" @click="editDialogVisible = false" />
<Button :loading="editSaving" label="Сохранить" @click="saveEdit" />
</div>
</Dialog>
</div>
</template>
+1 -1
View File
@@ -3,7 +3,7 @@
<div class="font-semibold text-2xl mb-4">Documentation</div>
<div class="font-semibold text-xl mb-4">Get Started</div>
<p class="text-lg mb-4">
Sakai is an application template for Vue based on the <a href="https://github.com/vuejs/create-vue" class="font-medium text-primary hover:underline">create-vue</a>, the recommended way to start a <strong>Vite-powered</strong> Vue
KonturAI is an application template for Vue based on the <a href="https://github.com/vuejs/create-vue" class="font-medium text-primary hover:underline">create-vue</a>, the recommended way to start a <strong>Vite-powered</strong> Vue
projects. To get started, clone the <a href="https://github.com/primefaces/sakai-vue" class="font-medium text-primary hover:underline">repository</a> from GitHub and install the dependencies with npm or yarn.
</p>
<pre class="app-code">
+24 -2
View File
@@ -1,10 +1,31 @@
<script setup>
import FloatingConfigurator from '@/components/FloatingConfigurator.vue';
import AuthService from '@/service/AuthService';
import { ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const email = ref('');
const password = ref('');
const checked = ref(false);
const loading = ref(false);
const errorMessage = ref('');
const route = useRoute();
const router = useRouter();
async function onSubmit() {
errorMessage.value = '';
loading.value = true;
try {
await AuthService.signin(email.value.trim().toLowerCase(), password.value);
const redirect = (route.query.redirect && String(route.query.redirect)) || '/';
router.replace(redirect);
} catch (e) {
errorMessage.value = e?.message || 'Ошибка входа';
} finally {
loading.value = false;
}
}
</script>
<template>
@@ -31,7 +52,7 @@ const checked = ref(false);
/>
</g>
</svg>
<div class="text-surface-900 dark:text-surface-0 text-3xl font-medium mb-4">Welcome to PrimeLand!</div>
<div class="text-surface-900 dark:text-surface-0 text-3xl font-medium mb-4">Welcome to Konturai!</div>
<span class="text-muted-color font-medium">Sign in to continue</span>
</div>
@@ -49,7 +70,8 @@ const checked = ref(false);
</div>
<span class="font-medium no-underline ml-2 text-right cursor-pointer text-primary">Forgot password?</span>
</div>
<Button label="Sign In" class="w-full" as="router-link" to="/"></Button>
<div v-if="errorMessage" class="text-red-500 text-sm mb-4">{{ errorMessage }}</div>
<Button :loading="loading" label="Sign In" class="w-full" @click="onSubmit"></Button>
</div>
</div>
</div>