login is ready
This commit is contained in:
@@ -0,0 +1,104 @@
|
|||||||
|
## Admin Users API
|
||||||
|
|
||||||
|
Требуется `Authorization: Bearer <admin_access_token>` и роль `ROLE_ADMIN`.
|
||||||
|
|
||||||
|
### Создать пользователя
|
||||||
|
|
||||||
|
POST `/api/admin/users`
|
||||||
|
|
||||||
|
Body:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "email": "user@example.com", "password": "StrongPass123!" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Ответ: `200 OK` (пусто)
|
||||||
|
|
||||||
|
Ошибки: 400 (валидация), 401/403 (нет прав), 409 (email занят)
|
||||||
|
|
||||||
|
### Назначить роли пользователю
|
||||||
|
|
||||||
|
POST `/api/admin/users/roles`
|
||||||
|
|
||||||
|
Body:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "email": "user@example.com", "roles": ["ROLE_USER", "ROLE_ADMIN"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Ответ: `200 OK` (пусто)
|
||||||
|
|
||||||
|
Ошибки: 400 (невалидная роль/пользователь не найден), 401/403
|
||||||
|
|
||||||
|
### Список доступных ролей
|
||||||
|
|
||||||
|
GET `/api/admin/roles`
|
||||||
|
|
||||||
|
Ответ:
|
||||||
|
|
||||||
|
```json
|
||||||
|
["ROLE_ADMIN", "ROLE_USER"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Ошибки: 401/403
|
||||||
|
|
||||||
|
### Список пользователей (пагинация)
|
||||||
|
|
||||||
|
GET `/api/admin/users?page=0&size=20`
|
||||||
|
|
||||||
|
Ответ (`Page<UserSummary>`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{ "id": 1, "email": "root@konturai.local", "roles": "ROLE_ADMIN,ROLE_USER" }
|
||||||
|
],
|
||||||
|
"pageable": { "pageNumber": 0, "pageSize": 20, ... },
|
||||||
|
"totalElements": 1,
|
||||||
|
"totalPages": 1,
|
||||||
|
"last": true,
|
||||||
|
"size": 20,
|
||||||
|
"number": 0,
|
||||||
|
"sort": { ... },
|
||||||
|
"first": true,
|
||||||
|
"numberOfElements": 1,
|
||||||
|
"empty": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ошибки: 401/403
|
||||||
|
|
||||||
|
### Обновить пользователя
|
||||||
|
|
||||||
|
PUT `/api/admin/users/update`
|
||||||
|
|
||||||
|
Body (любые поля опциональны кроме `id`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"email": "new@mail.com",
|
||||||
|
"password": "NewPass123!",
|
||||||
|
"roles": ["ROLE_USER"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ответ: `200 OK` (пусто)
|
||||||
|
|
||||||
|
Ошибки: 400 (невалидные данные/роль), 401/403, 404 (пользователь не найден)
|
||||||
|
|
||||||
|
### Удалить пользователя
|
||||||
|
|
||||||
|
DELETE `/api/admin/users?id=1`
|
||||||
|
|
||||||
|
Ответ: `200 OK` (пусто)
|
||||||
|
|
||||||
|
Ошибки: 401/403
|
||||||
|
|
||||||
|
### Формат ошибок
|
||||||
|
|
||||||
|
Все ошибки возвращают JSON:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "message": "описание ошибки" }
|
||||||
|
```
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
## Admin: Пользователи и роли
|
||||||
|
|
||||||
|
Требуется роль `ROLE_ADMIN` и заголовок `Authorization: Bearer <admin_access_token>`.
|
||||||
|
|
||||||
|
### Создать пользователя
|
||||||
|
|
||||||
|
POST `/api/admin/users`
|
||||||
|
|
||||||
|
Body:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "email": "user@example.com", "password": "StrongPass123!" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Ответ: `200 OK` (пусто)
|
||||||
|
|
||||||
|
Ошибки: 400, 401/403, 409
|
||||||
|
|
||||||
|
### Назначить роли пользователю
|
||||||
|
|
||||||
|
POST `/api/admin/users/roles`
|
||||||
|
|
||||||
|
Body:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "email": "user@example.com", "roles": ["ROLE_USER", "ROLE_ADMIN"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Ответ: `200 OK` (пусто)
|
||||||
|
|
||||||
|
Ошибки: 400 (invalid role / user not found), 401/403
|
||||||
|
|
||||||
|
### Список доступных ролей
|
||||||
|
|
||||||
|
GET `/api/admin/roles`
|
||||||
|
|
||||||
|
Ответ:
|
||||||
|
|
||||||
|
```json
|
||||||
|
["ROLE_ADMIN", "ROLE_USER"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Ошибки: 401/403
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
## Документация по аутентификации (Frontend)
|
||||||
|
|
||||||
|
### Обзор
|
||||||
|
|
||||||
|
JWT-аутентификация. Публичные и админские эндпоинты:
|
||||||
|
|
||||||
|
- POST `/api/auth/signin` — вход и выдача JWT + refreshToken
|
||||||
|
- POST `/api/auth/refresh` — обновление пары токенов (ротация)
|
||||||
|
- POST `/api/auth/logout` — выход (инвалидация refreshToken)
|
||||||
|
- POST `/api/admin/users` — создать пользователя (только `ROLE_ADMIN`)
|
||||||
|
|
||||||
|
Все запросы и ответы — JSON (`Content-Type: application/json`).
|
||||||
|
|
||||||
|
### Базовый URL
|
||||||
|
|
||||||
|
- Прод: укажите боевой домен/порт
|
||||||
|
- Dev локально: `http://localhost:8080`
|
||||||
|
|
||||||
|
### Переменные окружения (frontend)
|
||||||
|
|
||||||
|
- REACT_APP_API_URL или NEXT_PUBLIC_API_URL: базовый URL API
|
||||||
|
- Храните токен безопасно (HttpOnly cookie предпочтительно; если localStorage — учитывайте XSS риски)
|
||||||
|
|
||||||
|
## Создание пользователя (Admin)
|
||||||
|
|
||||||
|
### POST /api/admin/users
|
||||||
|
|
||||||
|
Требует: `Authorization: Bearer <admin_access_token>` и роль `ROLE_ADMIN`.
|
||||||
|
|
||||||
|
Запрос:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"email": "user@example.com",
|
||||||
|
"password": "StrongPass123!"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Успех:
|
||||||
|
|
||||||
|
- 200 OK, пустой ответ
|
||||||
|
|
||||||
|
Ошибки:
|
||||||
|
|
||||||
|
- 400 Bad Request — невалидный email/пароль
|
||||||
|
- 401/403 — нет прав администратора
|
||||||
|
- 409 Conflict — email уже зарегистрирован
|
||||||
|
|
||||||
|
Пример (fetch):
|
||||||
|
|
||||||
|
```js
|
||||||
|
await fetch(`${API_URL}/api/admin/users`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${adminAccessToken}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ email, password })
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Вход
|
||||||
|
|
||||||
|
### POST /api/auth/signin
|
||||||
|
|
||||||
|
Запрос:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"email": "user@example.com",
|
||||||
|
"password": "StrongPass123!"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Успех:
|
||||||
|
|
||||||
|
- 200 OK
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"accessToken": "<JWT>",
|
||||||
|
"tokenType": "Bearer",
|
||||||
|
"refreshToken": "<refresh-token>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ошибки:
|
||||||
|
|
||||||
|
- 400/401 — неверные учетные данные
|
||||||
|
|
||||||
|
Пример (fetch):
|
||||||
|
|
||||||
|
```js
|
||||||
|
const res = await fetch(`${API_URL}/api/auth/signin`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password })
|
||||||
|
});
|
||||||
|
const { accessToken, refreshToken } = await res.json();
|
||||||
|
// Сохраните токен и используйте в Authorization заголовке
|
||||||
|
```
|
||||||
|
|
||||||
|
## Обновление токена (Refresh Token)
|
||||||
|
|
||||||
|
### Общая схема
|
||||||
|
|
||||||
|
- При успешном входе фронт получает пару токенов: `accessToken` (короткоживущий) и `refreshToken` (длинноживущий).
|
||||||
|
- `accessToken` используется в `Authorization: Bearer <JWT>`.
|
||||||
|
- Когда `accessToken` истекает (HTTP 401), фронт вызывает `/api/auth/refresh` с `refreshToken`, получает новую пару токенов (ротация) и повторяет запрос.
|
||||||
|
|
||||||
|
### Рекомендации по хранению
|
||||||
|
|
||||||
|
- `refreshToken` предпочтительно хранить в HttpOnly Secure SameSite cookie (сервер ставит Set-Cookie).
|
||||||
|
- Альтернатива (менее безопасная): хранить в памяти/secure storage и передавать в теле запроса.
|
||||||
|
|
||||||
|
### POST /api/auth/refresh
|
||||||
|
|
||||||
|
Запрос (вариант с телом):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"refreshToken": "<refresh-token>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Успех:
|
||||||
|
|
||||||
|
- 200 OK
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"accessToken": "<new-jwt>",
|
||||||
|
"tokenType": "Bearer",
|
||||||
|
"refreshToken": "<new-refresh-token>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ошибки:
|
||||||
|
|
||||||
|
- 400 — отсутствует/некорректный refreshToken
|
||||||
|
- 401 — просрочен/отозван/невалиден
|
||||||
|
|
||||||
|
Пример (fetch, с телом):
|
||||||
|
|
||||||
|
```js
|
||||||
|
const res = await fetch(`${API_URL}/api/auth/refresh`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ refreshToken })
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Refresh failed');
|
||||||
|
const { accessToken: newAccess, refreshToken: newRefresh } = await res.json();
|
||||||
|
```
|
||||||
|
|
||||||
|
Пример (cookie-стратегия):
|
||||||
|
|
||||||
|
```js
|
||||||
|
const res = await fetch(`${API_URL}/api/auth/refresh`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
const { accessToken } = await res.json();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ротация refreshToken
|
||||||
|
|
||||||
|
- При каждом refresh возвращайте новый `refreshToken` и инвалидируйте старый.
|
||||||
|
- На фронте заменяйте сохранённый refreshToken на новый.
|
||||||
|
|
||||||
|
### TTL (рекомендации)
|
||||||
|
|
||||||
|
- accessToken: 5–15 минут
|
||||||
|
- refreshToken: 7–30 дней
|
||||||
|
|
||||||
|
## Logout
|
||||||
|
|
||||||
|
- Если refreshToken хранится в cookie: `POST /api/auth/logout` — сервер чистит cookie и отмечает refreshToken как отозванный.
|
||||||
|
- Если в хранилище фронта — удалите локальные токены и по возможности вызовите `logout` для аннулирования на бэке.
|
||||||
|
|
||||||
|
## Авторизация последующих запросов
|
||||||
|
|
||||||
|
Передавайте JWT в заголовке:
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer <JWT>
|
||||||
|
```
|
||||||
|
|
||||||
|
Пример защищенного вызова:
|
||||||
|
|
||||||
|
```js
|
||||||
|
await fetch(`${API_URL}/api/private/profile`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Формат ошибок (пример)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"timestamp": "2025-09-11T12:34:56Z",
|
||||||
|
"status": 409,
|
||||||
|
"error": "Conflict",
|
||||||
|
"message": "Email already registered",
|
||||||
|
"path": "/api/auth/signup"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Валидация на фронте
|
||||||
|
|
||||||
|
- Email: RFC-проверка и нормализация в lowercase
|
||||||
|
- Пароль: минимум 8 символов, цифра, буква, спецсимвол
|
||||||
|
- Обработайте статусы 400/401/409 и показывайте человекочитаемые сообщения
|
||||||
|
|
||||||
|
## Хранение токена
|
||||||
|
|
||||||
|
- Предпочтительно: HttpOnly Secure cookie, получаемое от бэкенда
|
||||||
|
- Альтернатива: `localStorage`/`sessionStorage` (учтите XSS; не вставляйте токен в DOM)
|
||||||
|
|
||||||
|
## Примеры cURL
|
||||||
|
|
||||||
|
Создание пользователя (admin):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "$API_URL/api/admin/users" \
|
||||||
|
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"email":"user@example.com","password":"StrongPass123!"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Вход:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "$API_URL/api/auth/signin" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"email":"user@example.com","password":"StrongPass123!"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Заметки по безопасности
|
||||||
|
|
||||||
|
- Не логируйте пароли и JWT
|
||||||
|
- Реализуйте logout (инвалидация на клиенте или список отозванных токенов на бэке, если нужно)
|
||||||
|
- Рекомендуется троттлинг/капча для /signin и создания пользователя
|
||||||
|
|
||||||
|
## Изменения в будущем
|
||||||
|
|
||||||
|
- Ротация refresh-токенов реализована; можно добавить список отозванных токенов
|
||||||
|
- Подтверждение email
|
||||||
|
- Сброс пароля через почту
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
## Identity
|
||||||
|
|
||||||
|
Требуется авторизация `Authorization: Bearer <access_token>`.
|
||||||
|
|
||||||
|
### Текущий пользователь
|
||||||
|
|
||||||
|
GET `/api/identity/me`
|
||||||
|
|
||||||
|
Ответ:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"email": "root@konturai.local",
|
||||||
|
"roles": ["ROLE_ADMIN", "ROLE_USER"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ошибки: 401 — нет или просрочен токен
|
||||||
+10
-12
@@ -1,17 +1,15 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>KonturAI</title>
|
||||||
|
<link href="https://fonts.cdnfonts.com/css/lato" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
|
||||||
<head>
|
<body>
|
||||||
<meta charset="UTF-8">
|
|
||||||
<link rel="icon" href="/favicon.ico">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Sakai Vue</title>
|
|
||||||
<link href="https://fonts.cdnfonts.com/css/lato" rel="stylesheet">
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/main.js"></script>
|
<script type="module" src="/src/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
/>
|
/>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ function smoothScroll(id) {
|
|||||||
/>
|
/>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</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>
|
</a>
|
||||||
<Button
|
<Button
|
||||||
class="lg:!hidden"
|
class="lg:!hidden"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="layout-footer">
|
<div class="layout-footer">
|
||||||
SAKAI by
|
KonturAI
|
||||||
<a href="https://primevue.org" target="_blank" rel="noopener noreferrer" class="text-primary font-bold hover:underline">PrimeVue</a>
|
<!-- <a href="https://primevue.org" target="_blank" rel="noopener noreferrer" class="text-primary font-bold hover:underline">PrimeVue</a> -->
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+132
-125
@@ -6,136 +6,143 @@ import AppMenuItem from './AppMenuItem.vue';
|
|||||||
const model = ref([
|
const model = ref([
|
||||||
{
|
{
|
||||||
label: 'Home',
|
label: 'Home',
|
||||||
items: [{ label: 'Dashboard', icon: 'pi pi-fw pi-home', to: '/' }]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'UI Components',
|
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Form Layout', icon: 'pi pi-fw pi-id-card', to: '/uikit/formlayout' },
|
// { label: 'Dashboard', icon: 'pi pi-fw pi-home', to: '/' },
|
||||||
{ label: 'Input', icon: 'pi pi-fw pi-check-square', to: '/uikit/input' },
|
{ label: 'New Dashboard', icon: 'pi pi-fw pi-home', to: '/dashboard-new' }
|
||||||
{ 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',
|
label: 'Admin',
|
||||||
icon: 'pi pi-fw pi-briefcase',
|
items: [{ label: 'Users', icon: 'pi pi-fw pi-users', to: '/admin/users' }]
|
||||||
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: '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>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { useLayout } from '@/layout/composables/layout';
|
import { useLayout } from '@/layout/composables/layout';
|
||||||
|
import AuthService from '@/service/AuthService';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
import AppConfigurator from './AppConfigurator.vue';
|
import AppConfigurator from './AppConfigurator.vue';
|
||||||
|
|
||||||
const { toggleMenu, toggleDarkMode, isDarkTheme } = useLayout();
|
const { toggleMenu, toggleDarkMode, isDarkTheme } = useLayout();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
async function onLogout() {
|
||||||
|
await AuthService.logout();
|
||||||
|
router.replace({ name: 'login' });
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -30,7 +38,7 @@ const { toggleMenu, toggleDarkMode, isDarkTheme } = useLayout();
|
|||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
<span>SAKAI</span>
|
<span>KonturAI</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -68,9 +76,9 @@ const { toggleMenu, toggleDarkMode, isDarkTheme } = useLayout();
|
|||||||
<i class="pi pi-inbox"></i>
|
<i class="pi pi-inbox"></i>
|
||||||
<span>Messages</span>
|
<span>Messages</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="layout-topbar-action">
|
<button type="button" class="layout-topbar-action" @click="onLogout">
|
||||||
<i class="pi pi-user"></i>
|
<i class="pi pi-sign-out"></i>
|
||||||
<span>Profile</span>
|
<span>Logout</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+80
-19
@@ -1,4 +1,6 @@
|
|||||||
import AppLayout from '@/layout/AppLayout.vue';
|
import AppLayout from '@/layout/AppLayout.vue';
|
||||||
|
import AuthService from '@/service/AuthService';
|
||||||
|
import IdentityService from '@/service/IdentityService';
|
||||||
import { createRouter, createWebHistory } from 'vue-router';
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
@@ -11,98 +13,136 @@ const router = createRouter({
|
|||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
name: 'dashboard',
|
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',
|
path: '/uikit/formlayout',
|
||||||
name: 'formlayout',
|
name: 'formlayout',
|
||||||
component: () => import('@/views/uikit/FormLayout.vue')
|
component: () => import('@/views/uikit/FormLayout.vue'),
|
||||||
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/uikit/input',
|
path: '/uikit/input',
|
||||||
name: 'input',
|
name: 'input',
|
||||||
component: () => import('@/views/uikit/InputDoc.vue')
|
component: () => import('@/views/uikit/InputDoc.vue'),
|
||||||
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/uikit/button',
|
path: '/uikit/button',
|
||||||
name: 'button',
|
name: 'button',
|
||||||
component: () => import('@/views/uikit/ButtonDoc.vue')
|
component: () => import('@/views/uikit/ButtonDoc.vue'),
|
||||||
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/uikit/table',
|
path: '/uikit/table',
|
||||||
name: 'table',
|
name: 'table',
|
||||||
component: () => import('@/views/uikit/TableDoc.vue')
|
component: () => import('@/views/uikit/TableDoc.vue'),
|
||||||
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/uikit/list',
|
path: '/uikit/list',
|
||||||
name: 'list',
|
name: 'list',
|
||||||
component: () => import('@/views/uikit/ListDoc.vue')
|
component: () => import('@/views/uikit/ListDoc.vue'),
|
||||||
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/uikit/tree',
|
path: '/uikit/tree',
|
||||||
name: 'tree',
|
name: 'tree',
|
||||||
component: () => import('@/views/uikit/TreeDoc.vue')
|
component: () => import('@/views/uikit/TreeDoc.vue'),
|
||||||
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/uikit/panel',
|
path: '/uikit/panel',
|
||||||
name: 'panel',
|
name: 'panel',
|
||||||
component: () => import('@/views/uikit/PanelsDoc.vue')
|
component: () => import('@/views/uikit/PanelsDoc.vue'),
|
||||||
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
path: '/uikit/overlay',
|
path: '/uikit/overlay',
|
||||||
name: 'overlay',
|
name: 'overlay',
|
||||||
component: () => import('@/views/uikit/OverlayDoc.vue')
|
component: () => import('@/views/uikit/OverlayDoc.vue'),
|
||||||
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/uikit/media',
|
path: '/uikit/media',
|
||||||
name: 'media',
|
name: 'media',
|
||||||
component: () => import('@/views/uikit/MediaDoc.vue')
|
component: () => import('@/views/uikit/MediaDoc.vue'),
|
||||||
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/uikit/message',
|
path: '/uikit/message',
|
||||||
name: 'message',
|
name: 'message',
|
||||||
component: () => import('@/views/uikit/MessagesDoc.vue')
|
component: () => import('@/views/uikit/MessagesDoc.vue'),
|
||||||
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/uikit/file',
|
path: '/uikit/file',
|
||||||
name: 'file',
|
name: 'file',
|
||||||
component: () => import('@/views/uikit/FileDoc.vue')
|
component: () => import('@/views/uikit/FileDoc.vue'),
|
||||||
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/uikit/menu',
|
path: '/uikit/menu',
|
||||||
name: 'menu',
|
name: 'menu',
|
||||||
component: () => import('@/views/uikit/MenuDoc.vue')
|
component: () => import('@/views/uikit/MenuDoc.vue'),
|
||||||
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/uikit/charts',
|
path: '/uikit/charts',
|
||||||
name: 'charts',
|
name: 'charts',
|
||||||
component: () => import('@/views/uikit/ChartDoc.vue')
|
component: () => import('@/views/uikit/ChartDoc.vue'),
|
||||||
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/uikit/misc',
|
path: '/uikit/misc',
|
||||||
name: 'misc',
|
name: 'misc',
|
||||||
component: () => import('@/views/uikit/MiscDoc.vue')
|
component: () => import('@/views/uikit/MiscDoc.vue'),
|
||||||
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/uikit/timeline',
|
path: '/uikit/timeline',
|
||||||
name: 'timeline',
|
name: 'timeline',
|
||||||
component: () => import('@/views/uikit/TimelineDoc.vue')
|
component: () => import('@/views/uikit/TimelineDoc.vue'),
|
||||||
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/pages/empty',
|
path: '/pages/empty',
|
||||||
name: 'empty',
|
name: 'empty',
|
||||||
component: () => import('@/views/pages/Empty.vue')
|
component: () => import('@/views/pages/Empty.vue'),
|
||||||
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/pages/crud',
|
path: '/pages/crud',
|
||||||
name: 'crud',
|
name: 'crud',
|
||||||
component: () => import('@/views/pages/Crud.vue')
|
component: () => import('@/views/pages/Crud.vue'),
|
||||||
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/documentation',
|
path: '/documentation',
|
||||||
name: '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;
|
export default router;
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<div class="font-semibold text-2xl mb-4">Documentation</div>
|
<div class="font-semibold text-2xl mb-4">Documentation</div>
|
||||||
<div class="font-semibold text-xl mb-4">Get Started</div>
|
<div class="font-semibold text-xl mb-4">Get Started</div>
|
||||||
<p class="text-lg mb-4">
|
<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.
|
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>
|
</p>
|
||||||
<pre class="app-code">
|
<pre class="app-code">
|
||||||
|
|||||||
@@ -1,10 +1,31 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import FloatingConfigurator from '@/components/FloatingConfigurator.vue';
|
import FloatingConfigurator from '@/components/FloatingConfigurator.vue';
|
||||||
|
import AuthService from '@/service/AuthService';
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
const email = ref('');
|
const email = ref('');
|
||||||
const password = ref('');
|
const password = ref('');
|
||||||
const checked = ref(false);
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -31,7 +52,7 @@ const checked = ref(false);
|
|||||||
/>
|
/>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</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>
|
<span class="text-muted-color font-medium">Sign in to continue</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -49,7 +70,8 @@ const checked = ref(false);
|
|||||||
</div>
|
</div>
|
||||||
<span class="font-medium no-underline ml-2 text-right cursor-pointer text-primary">Forgot password?</span>
|
<span class="font-medium no-underline ml-2 text-right cursor-pointer text-primary">Forgot password?</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user