feat: backend auth, admin panel, role-based routing

This commit is contained in:
Zuev
2026-02-14 02:05:37 +03:00
parent 61a5cf5cce
commit b6ff6c457a
28 changed files with 1844 additions and 10 deletions

4
.gitignore vendored
View File

@@ -1,5 +1,5 @@
# Игнорируем базу данных (обязательно!)
db/
# Игнорируем данные БД (но не init-скрипты)
db/data/
postgres_data/
# Игнорируем секреты

12
backend/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM maven:3.9-eclipse-temurin-17 AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline -B
COPY src ./src
RUN mvn package -DskipTests -B
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY --from=build /app/target/app.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

57
backend/pom.xml Normal file
View File

@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
<relativePath/>
</parent>
<groupId>com.magistr</groupId>
<artifactId>app</artifactId>
<version>1.0.0</version>
<name>magistr-backend</name>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<!-- Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- JPA + PostgreSQL -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- BCrypt (only crypto, no full Security auto-config) -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
</dependency>
</dependencies>
<build>
<finalName>app</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,12 @@
package com.magistr.app;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}

View File

@@ -0,0 +1,14 @@
package com.magistr.app.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
@Configuration
public class AppConfig {
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}

View File

@@ -0,0 +1,50 @@
package com.magistr.app.config;
import com.magistr.app.model.Role;
import com.magistr.app.model.User;
import com.magistr.app.repository.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Component;
import java.util.Optional;
@Component
public class DataInitializer implements CommandLineRunner {
private static final Logger log = LoggerFactory.getLogger(DataInitializer.class);
private final UserRepository userRepository;
private final BCryptPasswordEncoder passwordEncoder;
public DataInitializer(UserRepository userRepository, BCryptPasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
@Override
public void run(String... args) {
Optional<User> existing = userRepository.findByUsername("admin");
if (existing.isEmpty()) {
User admin = new User();
admin.setUsername("admin");
admin.setPassword(passwordEncoder.encode("admin"));
admin.setRole(Role.ADMIN);
userRepository.save(admin);
log.info("Created default admin user");
} else {
User admin = existing.get();
if (!passwordEncoder.matches("admin", admin.getPassword())) {
admin.setPassword(passwordEncoder.encode("admin"));
admin.setRole(Role.ADMIN);
userRepository.save(admin);
log.info("Reset admin password (hash was invalid)");
} else {
log.info("Admin user already exists with correct password");
}
}
}
}

View File

@@ -0,0 +1,51 @@
package com.magistr.app.controller;
import com.magistr.app.dto.LoginRequest;
import com.magistr.app.dto.LoginResponse;
import com.magistr.app.model.User;
import com.magistr.app.repository.UserRepository;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final UserRepository userRepository;
private final BCryptPasswordEncoder passwordEncoder;
private static final Map<String, String> ROLE_REDIRECTS = Map.of(
"ADMIN", "/admin/",
"TEACHER", "/teacher/",
"STUDENT", "/student/"
);
public AuthController(UserRepository userRepository, BCryptPasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request) {
Optional<User> userOpt = userRepository.findByUsername(request.getUsername());
if (userOpt.isEmpty() ||
!passwordEncoder.matches(request.getPassword(), userOpt.get().getPassword())) {
return ResponseEntity
.status(401)
.body(new LoginResponse(false, "Неверное имя пользователя или пароль", null, null, null));
}
User user = userOpt.get();
String token = UUID.randomUUID().toString();
String roleName = user.getRole().name();
String redirect = ROLE_REDIRECTS.getOrDefault(roleName, "/");
return ResponseEntity.ok(new LoginResponse(true, "OK", token, roleName, redirect));
}
}

View File

@@ -0,0 +1,70 @@
package com.magistr.app.controller;
import com.magistr.app.dto.CreateUserRequest;
import com.magistr.app.dto.UserResponse;
import com.magistr.app.model.Role;
import com.magistr.app.model.User;
import com.magistr.app.repository.UserRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserRepository userRepository;
private final BCryptPasswordEncoder passwordEncoder;
public UserController(UserRepository userRepository, BCryptPasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
@GetMapping
public List<UserResponse> getAllUsers() {
return userRepository.findAll().stream()
.map(u -> new UserResponse(u.getId(), u.getUsername(), u.getRole().name()))
.toList();
}
@PostMapping
public ResponseEntity<?> createUser(@RequestBody CreateUserRequest request) {
if (request.getUsername() == null || request.getUsername().isBlank()) {
return ResponseEntity.badRequest().body(Map.of("message", "Имя пользователя обязательно"));
}
if (request.getPassword() == null || request.getPassword().length() < 4) {
return ResponseEntity.badRequest().body(Map.of("message", "Пароль минимум 4 символа"));
}
if (userRepository.findByUsername(request.getUsername()).isPresent()) {
return ResponseEntity.badRequest().body(Map.of("message", "Пользователь уже существует"));
}
Role role;
try {
role = Role.valueOf(request.getRole());
} catch (Exception e) {
return ResponseEntity.badRequest().body(Map.of("message", "Недопустимая роль"));
}
User user = new User();
user.setUsername(request.getUsername());
user.setPassword(passwordEncoder.encode(request.getPassword()));
user.setRole(role);
userRepository.save(user);
return ResponseEntity.ok(new UserResponse(user.getId(), user.getUsername(), user.getRole().name()));
}
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteUser(@PathVariable Long id) {
if (!userRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
userRepository.deleteById(id);
return ResponseEntity.ok(Map.of("message", "Пользователь удалён"));
}
}

View File

@@ -0,0 +1,35 @@
package com.magistr.app.dto;
public class CreateUserRequest {
private String username;
private String password;
private String role;
public CreateUserRequest() {
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public String getRole() {
return role;
}
public void setRole(String role) {
this.role = role;
}
}

View File

@@ -0,0 +1,26 @@
package com.magistr.app.dto;
public class LoginRequest {
private String username;
private String password;
public LoginRequest() {
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}

View File

@@ -0,0 +1,61 @@
package com.magistr.app.dto;
public class LoginResponse {
private boolean success;
private String message;
private String token;
private String role;
private String redirect;
public LoginResponse() {
}
public LoginResponse(boolean success, String message, String token, String role, String redirect) {
this.success = success;
this.message = message;
this.token = token;
this.role = role;
this.redirect = redirect;
}
public boolean isSuccess() {
return success;
}
public void setSuccess(boolean success) {
this.success = success;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
public String getToken() {
return token;
}
public void setToken(String token) {
this.token = token;
}
public String getRole() {
return role;
}
public void setRole(String role) {
this.role = role;
}
public String getRedirect() {
return redirect;
}
public void setRedirect(String redirect) {
this.redirect = redirect;
}
}

View File

@@ -0,0 +1,41 @@
package com.magistr.app.dto;
public class UserResponse {
private Long id;
private String username;
private String role;
public UserResponse() {
}
public UserResponse(Long id, String username, String role) {
this.id = id;
this.username = username;
this.role = role;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getRole() {
return role;
}
public void setRole(String role) {
this.role = role;
}
}

View File

@@ -0,0 +1,7 @@
package com.magistr.app.model;
public enum Role {
ADMIN,
TEACHER,
STUDENT
}

View File

@@ -0,0 +1,57 @@
package com.magistr.app.model;
import jakarta.persistence.*;
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false, length = 50)
private String username;
@Column(nullable = false, length = 255)
private String password;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private Role role = Role.STUDENT;
public User() {
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public Role getRole() {
return role;
}
public void setRole(Role role) {
this.role = role;
}
}

View File

@@ -0,0 +1,11 @@
package com.magistr.app.repository;
import com.magistr.app.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
}

View File

@@ -0,0 +1,12 @@
server.port=8080
# PostgreSQL
spring.datasource.url=jdbc:postgresql://db:5432/app_db
spring.datasource.username=${POSTGRES_USER}
spring.datasource.password=${POSTGRES_PASSWORD}
spring.datasource.driver-class-name=org.postgresql.Driver
# JPA
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.show-sql=false
spring.jpa.open-in-view=false

View File

@@ -5,12 +5,14 @@ services:
build:
context: ./backend
dockerfile: Dockerfile
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
networks:
- proxy
depends_on:
db:
condition: service_healthy
frontend:
container_name: frontend
restart: always
@@ -21,7 +23,6 @@ services:
- proxy
depends_on:
- backend
db:
image: postgres:alpine3.23
container_name: db
@@ -31,15 +32,17 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: app_db
volumes:
- ./db:/var/lib/postgresql/data
- ./db/data:/var/lib/postgresql/data
- ./db/init:/docker-entrypoint-initdb.d:ro
networks:
- proxy
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d app_db"]
test:
- CMD-SHELL
- pg_isready -U ${POSTGRES_USER} -d app_db
interval: 10s
timeout: 5s
retries: 5
networks:
proxy:
external: true

13
db/init/init.sql Normal file
View File

@@ -0,0 +1,13 @@
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'STUDENT'
);
-- Админ по умолчанию: admin / admin (bcrypt через pgcrypto)
INSERT INTO users (username, password, role)
VALUES ('admin', crypt('admin', gen_salt('bf', 10)), 'ADMIN')
ON CONFLICT (username) DO NOTHING;

2
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
Dockerfile
.dockerignore

2
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,2 @@
FROM httpd:alpine
COPY . /usr/local/apache2/htdocs/

382
frontend/admin/admin.css Normal file
View File

@@ -0,0 +1,382 @@
/* ===== Reset & Base ===== */
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg-primary: #0f0f1a;
--bg-sidebar: rgba(255, 255, 255, 0.03);
--bg-card: rgba(255, 255, 255, 0.05);
--bg-card-border: rgba(255, 255, 255, 0.08);
--bg-input: rgba(255, 255, 255, 0.06);
--bg-input-focus: rgba(255, 255, 255, 0.1);
--bg-hover: rgba(255, 255, 255, 0.06);
--text-primary: #f0f0f5;
--text-secondary: #9ca3af;
--text-placeholder: #6b7280;
--accent: #6366f1;
--accent-hover: #818cf8;
--accent-glow: rgba(99, 102, 241, 0.35);
--error: #f87171;
--success: #34d399;
--warning: #fbbf24;
--radius-sm: 8px;
--radius-md: 12px;
--transition: 0.2s ease;
}
html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
display: flex;
}
/* ===== Sidebar ===== */
.sidebar {
width: 240px;
min-height: 100vh;
background: var(--bg-sidebar);
border-right: 1px solid var(--bg-card-border);
display: flex;
flex-direction: column;
position: fixed;
left: 0;
top: 0;
bottom: 0;
z-index: 10;
}
.sidebar-header {
padding: 1.25rem;
border-bottom: 1px solid var(--bg-card-border);
}
.logo {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 1.15rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.sidebar-nav {
flex: 1;
padding: 0.75rem;
}
.nav-item {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.65rem 0.8rem;
border-radius: var(--radius-sm);
color: var(--text-secondary);
text-decoration: none;
font-size: 0.9rem;
font-weight: 500;
transition: background var(--transition), color var(--transition);
}
.nav-item:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.nav-item.active {
background: rgba(99, 102, 241, 0.12);
color: var(--accent-hover);
}
.sidebar-footer {
padding: 0.75rem;
border-top: 1px solid var(--bg-card-border);
}
.btn-logout {
width: 100%;
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.65rem 0.8rem;
border: none;
border-radius: var(--radius-sm);
background: none;
color: var(--text-secondary);
font-family: inherit;
font-size: 0.9rem;
cursor: pointer;
transition: background var(--transition), color var(--transition);
}
.btn-logout:hover {
background: rgba(248, 113, 113, 0.1);
color: var(--error);
}
/* ===== Main ===== */
.main {
flex: 1;
margin-left: 240px;
min-height: 100vh;
}
.topbar {
padding: 1.5rem 2rem;
border-bottom: 1px solid var(--bg-card-border);
}
.topbar h1 {
font-size: 1.3rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.content {
padding: 1.5rem 2rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
/* ===== Cards ===== */
.card {
background: var(--bg-card);
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-md);
padding: 1.5rem;
}
.card h2 {
font-size: 1rem;
font-weight: 600;
margin-bottom: 1rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.8rem;
}
/* ===== Create Form ===== */
.form-row {
display: flex;
gap: 1rem;
align-items: flex-end;
flex-wrap: wrap;
}
.form-row .form-group {
flex: 1;
min-width: 160px;
}
.form-group label {
display: block;
font-size: 0.78rem;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 0.4rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.form-group input,
.form-group select {
width: 100%;
padding: 0.65rem 0.8rem;
background: var(--bg-input);
border: 1px solid transparent;
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: inherit;
font-size: 0.9rem;
outline: none;
transition: background var(--transition), border-color var(--transition), box-shadow var(--transition);
}
.form-group input::placeholder {
color: var(--text-placeholder);
}
.form-group input:focus,
.form-group select:focus {
background: var(--bg-input-focus);
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.form-group select {
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1.5L6 6.5L11 1.5' stroke='%239ca3af' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.75rem center;
padding-right: 2.25rem;
}
.form-group select option {
background: #1a1a2e;
color: var(--text-primary);
}
.btn-create {
padding: 0.65rem 1.5rem;
background: linear-gradient(135deg, var(--accent), #8b5cf6);
border: none;
border-radius: var(--radius-sm);
color: #fff;
font-family: inherit;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
transition: transform var(--transition), box-shadow var(--transition);
box-shadow: 0 2px 10px var(--accent-glow);
}
.btn-create:hover {
transform: translateY(-1px);
box-shadow: 0 4px 16px var(--accent-glow);
}
.form-alert {
display: none;
padding: 0.6rem 1rem;
border-radius: var(--radius-sm);
font-size: 0.85rem;
margin-top: 0.75rem;
}
.form-alert.error {
display: block;
background: rgba(248, 113, 113, 0.1);
border: 1px solid rgba(248, 113, 113, 0.2);
color: var(--error);
}
.form-alert.success {
display: block;
background: rgba(52, 211, 153, 0.1);
border: 1px solid rgba(52, 211, 153, 0.2);
color: var(--success);
}
/* ===== Table ===== */
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
thead th {
text-align: left;
font-size: 0.78rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.6rem 0.8rem;
border-bottom: 1px solid var(--bg-card-border);
}
tbody td {
padding: 0.7rem 0.8rem;
font-size: 0.9rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
}
tbody tr {
transition: background var(--transition);
}
tbody tr:hover {
background: var(--bg-hover);
}
.loading-row {
text-align: center;
color: var(--text-secondary);
padding: 2rem !important;
}
/* ===== Role Badges ===== */
.badge {
display: inline-block;
padding: 0.2rem 0.6rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.badge-admin {
background: rgba(248, 113, 113, 0.15);
color: var(--error);
}
.badge-teacher {
background: rgba(251, 191, 36, 0.15);
color: var(--warning);
}
.badge-student {
background: rgba(52, 211, 153, 0.15);
color: var(--success);
}
/* ===== Delete Button ===== */
.btn-delete {
padding: 0.35rem 0.7rem;
background: rgba(248, 113, 113, 0.1);
border: 1px solid rgba(248, 113, 113, 0.2);
border-radius: var(--radius-sm);
color: var(--error);
font-family: inherit;
font-size: 0.8rem;
cursor: pointer;
transition: background var(--transition);
}
.btn-delete:hover {
background: rgba(248, 113, 113, 0.2);
}
/* ===== Responsive ===== */
@media (max-width: 768px) {
.sidebar {
width: 60px;
}
.sidebar-header span,
.nav-item span,
.btn-logout span {
display: none;
}
.main {
margin-left: 60px;
}
.content {
padding: 1rem;
}
.form-row {
flex-direction: column;
}
}

146
frontend/admin/admin.js Normal file
View File

@@ -0,0 +1,146 @@
(() => {
'use strict';
const token = localStorage.getItem('token');
const role = localStorage.getItem('role');
if (!token || role !== 'ADMIN') {
window.location.href = '/';
return;
}
const tbody = document.getElementById('users-tbody');
const createForm = document.getElementById('create-form');
const createAlert = document.getElementById('create-alert');
const btnLogout = document.getElementById('btn-logout');
const ROLE_LABELS = {
ADMIN: 'Администратор',
TEACHER: 'Преподаватель',
STUDENT: 'Студент',
};
const ROLE_BADGE = {
ADMIN: 'badge-admin',
TEACHER: 'badge-teacher',
STUDENT: 'badge-student',
};
// ---- Load Users ----
async function loadUsers() {
try {
const res = await fetch('/api/users', {
headers: { 'Authorization': 'Bearer ' + token },
});
const users = await res.json();
renderUsers(users);
} catch (e) {
tbody.innerHTML = '<tr><td colspan="4" class="loading-row">Ошибка загрузки</td></tr>';
}
}
function renderUsers(users) {
if (!users.length) {
tbody.innerHTML = '<tr><td colspan="4" class="loading-row">Нет пользователей</td></tr>';
return;
}
tbody.innerHTML = users.map(u => `
<tr>
<td>${u.id}</td>
<td>${escapeHtml(u.username)}</td>
<td><span class="badge ${ROLE_BADGE[u.role] || ''}">${ROLE_LABELS[u.role] || u.role}</span></td>
<td><button class="btn-delete" data-id="${u.id}">Удалить</button></td>
</tr>
`).join('');
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// ---- Create User ----
createForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert();
const username = document.getElementById('new-username').value.trim();
const password = document.getElementById('new-password').value;
const role = document.getElementById('new-role').value;
if (!username || !password) {
showAlert('Заполните все поля', 'error');
return;
}
try {
const res = await fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token,
},
body: JSON.stringify({ username, password, role }),
});
const data = await res.json();
if (res.ok) {
showAlert(`Пользователь "${data.username}" создан`, 'success');
createForm.reset();
loadUsers();
} else {
showAlert(data.message || 'Ошибка создания', 'error');
}
} catch (e) {
showAlert('Ошибка соединения', 'error');
}
});
// ---- Delete User ----
tbody.addEventListener('click', async (e) => {
const btn = e.target.closest('.btn-delete');
if (!btn) return;
const id = btn.dataset.id;
if (!confirm('Удалить пользователя?')) return;
try {
const res = await fetch('/api/users/' + id, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + token },
});
if (res.ok) {
loadUsers();
} else {
alert('Ошибка удаления');
}
} catch (e) {
alert('Ошибка соединения');
}
});
// ---- Logout ----
btnLogout.addEventListener('click', () => {
localStorage.removeItem('token');
localStorage.removeItem('role');
window.location.href = '/';
});
// ---- Helpers ----
function showAlert(msg, type) {
createAlert.className = 'form-alert ' + type;
createAlert.textContent = msg;
}
function hideAlert() {
createAlert.className = 'form-alert';
createAlert.textContent = '';
}
// Init
loadUsers();
})();

119
frontend/admin/index.html Normal file
View File

@@ -0,0 +1,119 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Админ-панель</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="admin.css">
</head>
<body>
<!-- Sidebar -->
<aside class="sidebar">
<div class="sidebar-header">
<div class="logo">
<svg width="32" height="32" viewBox="0 0 40 40" fill="none">
<rect width="40" height="40" rx="12" fill="url(#lg)" />
<path d="M12 20L18 26L28 14" stroke="#fff" stroke-width="3" stroke-linecap="round"
stroke-linejoin="round" />
<defs>
<linearGradient id="lg" x1="0" y1="0" x2="40" y2="40">
<stop stop-color="#6366f1" />
<stop offset="1" stop-color="#8b5cf6" />
</linearGradient>
</defs>
</svg>
<span>Magistr</span>
</div>
</div>
<nav class="sidebar-nav">
<a href="/admin/" class="nav-item active">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
Пользователи
</a>
</nav>
<div class="sidebar-footer">
<button class="btn-logout" id="btn-logout">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
Выйти
</button>
</div>
</aside>
<!-- Main -->
<main class="main">
<header class="topbar">
<h1>Управление пользователями</h1>
</header>
<section class="content">
<!-- Create User Card -->
<div class="card create-card">
<h2>Новый пользователь</h2>
<form id="create-form">
<div class="form-row">
<div class="form-group">
<label for="new-username">Имя пользователя</label>
<input type="text" id="new-username" placeholder="username" required>
</div>
<div class="form-group">
<label for="new-password">Пароль</label>
<input type="text" id="new-password" placeholder="password" required>
</div>
<div class="form-group">
<label for="new-role">Роль</label>
<select id="new-role">
<option value="STUDENT">Студент</option>
<option value="TEACHER">Преподаватель</option>
<option value="ADMIN">Администратор</option>
</select>
</div>
<button type="submit" class="btn-create">Создать</button>
</div>
<div class="form-alert" id="create-alert" role="alert"></div>
</form>
</div>
<!-- Users Table -->
<div class="card">
<h2>Все пользователи</h2>
<div class="table-wrap">
<table id="users-table">
<thead>
<tr>
<th>ID</th>
<th>Имя пользователя</th>
<th>Роль</th>
<th></th>
</tr>
</thead>
<tbody id="users-tbody">
<tr>
<td colspan="4" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
</main>
<script src="admin.js"></script>
</body>
</html>

86
frontend/index.html Normal file
View File

@@ -0,0 +1,86 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Страница авторизации">
<title>Авторизация</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="background">
<div class="shape shape-1"></div>
<div class="shape shape-2"></div>
<div class="shape shape-3"></div>
</div>
<main class="login-container">
<div class="login-card">
<div class="login-header">
<div class="logo">
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="40" height="40" rx="12" fill="url(#logo-gradient)"/>
<path d="M12 20L18 26L28 14" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<defs>
<linearGradient id="logo-gradient" x1="0" y1="0" x2="40" y2="40">
<stop stop-color="#6366f1"/>
<stop offset="1" stop-color="#8b5cf6"/>
</linearGradient>
</defs>
</svg>
</div>
<h1>Добро пожаловать</h1>
<p class="subtitle">Войдите в свой аккаунт</p>
</div>
<form id="login-form" novalidate>
<div class="form-group">
<label for="username">Имя пользователя</label>
<div class="input-wrapper">
<svg class="input-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
<input type="text" id="username" name="username" placeholder="Введите имя пользователя" autocomplete="username" required>
</div>
<span class="error-message" id="username-error"></span>
</div>
<div class="form-group">
<label for="password">Пароль</label>
<div class="input-wrapper">
<svg class="input-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
<input type="password" id="password" name="password" placeholder="Введите пароль" autocomplete="current-password" required>
<button type="button" class="toggle-password" id="toggle-password" aria-label="Показать пароль">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
</button>
</div>
<span class="error-message" id="password-error"></span>
</div>
<div class="form-alert" id="form-alert" role="alert"></div>
<button type="submit" class="btn-submit" id="btn-submit">
<span class="btn-text">Войти</span>
<span class="btn-loader" hidden>
<svg class="spinner" width="20" height="20" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" fill="none" stroke-dasharray="31.4 31.4" stroke-linecap="round"/>
</svg>
</span>
</button>
</form>
</div>
</main>
<script src="script.js"></script>
</body>
</html>

106
frontend/script.js Normal file
View File

@@ -0,0 +1,106 @@
(() => {
'use strict';
const form = document.getElementById('login-form');
const usernameInput = document.getElementById('username');
const passwordInput = document.getElementById('password');
const usernameError = document.getElementById('username-error');
const passwordError = document.getElementById('password-error');
const formAlert = document.getElementById('form-alert');
const btnSubmit = document.getElementById('btn-submit');
const btnText = btnSubmit.querySelector('.btn-text');
const btnLoader = btnSubmit.querySelector('.btn-loader');
const togglePassword = document.getElementById('toggle-password');
togglePassword.addEventListener('click', () => {
const isPassword = passwordInput.type === 'password';
passwordInput.type = isPassword ? 'text' : 'password';
togglePassword.setAttribute('aria-label', isPassword ? 'Скрыть пароль' : 'Показать пароль');
});
usernameInput.addEventListener('input', () => clearFieldError(usernameInput, usernameError));
passwordInput.addEventListener('input', () => clearFieldError(passwordInput, passwordError));
function clearFieldError(input, errorEl) {
input.closest('.form-group').classList.remove('has-error');
errorEl.textContent = '';
}
function setFieldError(input, errorEl, message) {
input.closest('.form-group').classList.add('has-error');
errorEl.textContent = message;
}
function showAlert(message, type) {
formAlert.className = 'form-alert ' + type;
formAlert.textContent = message;
}
function hideAlert() {
formAlert.className = 'form-alert';
formAlert.textContent = '';
}
function setLoading(loading) {
btnSubmit.disabled = loading;
btnText.hidden = loading;
btnLoader.hidden = !loading;
}
function validate() {
let valid = true;
hideAlert();
if (!usernameInput.value.trim()) {
setFieldError(usernameInput, usernameError, 'Введите имя пользователя');
valid = false;
}
if (!passwordInput.value) {
setFieldError(passwordInput, passwordError, 'Введите пароль');
valid = false;
} else if (passwordInput.value.length < 4) {
setFieldError(passwordInput, passwordError, 'Минимум 4 символа');
valid = false;
}
return valid;
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (!validate()) return;
setLoading(true);
hideAlert();
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: usernameInput.value.trim(),
password: passwordInput.value,
}),
});
const data = await response.json();
if (response.ok) {
showAlert('Вход выполнен успешно!', 'success');
if (data.token) localStorage.setItem('token', data.token);
if (data.role) localStorage.setItem('role', data.role);
const redirect = data.redirect || '/';
setTimeout(() => { window.location.href = redirect; }, 400);
} else {
showAlert(data.message || 'Неверное имя пользователя или пароль', 'error');
}
} catch (err) {
showAlert('Ошибка соединения с сервером', 'error');
} finally {
setLoading(false);
}
});
})();

View File

@@ -0,0 +1,59 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Панель студента</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', sans-serif;
background: #0f0f1a;
color: #f0f0f5;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.placeholder {
text-align: center;
}
.placeholder h1 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
.placeholder p {
color: #9ca3af;
margin-bottom: 1.5rem;
}
.placeholder a {
color: #818cf8;
text-decoration: none;
}
.placeholder a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="placeholder">
<h1>Панель студента</h1>
<p>Раздел в разработке</p>
<a href="/" onclick="localStorage.clear()">Выйти</a>
</div>
</body>
</html>

341
frontend/style.css Normal file
View File

@@ -0,0 +1,341 @@
/* ===== Reset & Base ===== */
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg-primary: #0f0f1a;
--bg-card: rgba(255, 255, 255, 0.05);
--bg-card-border: rgba(255, 255, 255, 0.08);
--bg-input: rgba(255, 255, 255, 0.06);
--bg-input-focus: rgba(255, 255, 255, 0.1);
--text-primary: #f0f0f5;
--text-secondary: #9ca3af;
--text-placeholder: #6b7280;
--accent: #6366f1;
--accent-hover: #818cf8;
--accent-glow: rgba(99, 102, 241, 0.35);
--error: #f87171;
--success: #34d399;
--radius-sm: 8px;
--radius-md: 14px;
--radius-lg: 20px;
--transition: 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
/* ===== Animated Background ===== */
.background {
position: fixed;
inset: 0;
z-index: 0;
overflow: hidden;
}
.shape {
position: absolute;
border-radius: 50%;
filter: blur(80px);
opacity: 0.5;
animation: float 20s ease-in-out infinite;
}
.shape-1 {
width: 500px;
height: 500px;
background: radial-gradient(circle, #6366f1, transparent 70%);
top: -15%;
left: -10%;
animation-delay: 0s;
}
.shape-2 {
width: 400px;
height: 400px;
background: radial-gradient(circle, #8b5cf6, transparent 70%);
bottom: -10%;
right: -10%;
animation-delay: -7s;
}
.shape-3 {
width: 300px;
height: 300px;
background: radial-gradient(circle, #a78bfa, transparent 70%);
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
animation-delay: -14s;
}
@keyframes float {
0%, 100% { transform: translate(0, 0) scale(1); }
25% { transform: translate(30px, -40px) scale(1.05); }
50% { transform: translate(-20px, 20px) scale(0.95); }
75% { transform: translate(15px, 35px) scale(1.03); }
}
/* ===== Login Container ===== */
.login-container {
position: relative;
z-index: 1;
width: 100%;
max-width: 420px;
padding: 1rem;
animation: fadeInUp 0.6s ease-out both;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(24px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ===== Login Card (Glassmorphism) ===== */
.login-card {
background: var(--bg-card);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-lg);
padding: 2.5rem 2rem 2rem;
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
/* ===== Header ===== */
.login-header {
text-align: center;
margin-bottom: 2rem;
}
.logo {
display: inline-flex;
margin-bottom: 1rem;
animation: pulse-glow 3s ease-in-out infinite;
}
@keyframes pulse-glow {
0%, 100% { filter: drop-shadow(0 0 6px var(--accent-glow)); }
50% { filter: drop-shadow(0 0 18px var(--accent-glow)); }
}
.login-header h1 {
font-size: 1.5rem;
font-weight: 700;
letter-spacing: -0.02em;
margin-bottom: 0.35rem;
}
.subtitle {
color: var(--text-secondary);
font-size: 0.9rem;
}
/* ===== Form ===== */
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
font-size: 0.8rem;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* ===== Input Wrapper ===== */
.input-wrapper {
position: relative;
display: flex;
align-items: center;
}
.input-icon {
position: absolute;
left: 14px;
color: var(--text-placeholder);
pointer-events: none;
transition: color var(--transition);
}
.input-wrapper input {
width: 100%;
padding: 0.8rem 0.8rem 0.8rem 2.75rem;
background: var(--bg-input);
border: 1px solid transparent;
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: inherit;
font-size: 0.95rem;
outline: none;
transition:
background var(--transition),
border-color var(--transition),
box-shadow var(--transition);
}
.input-wrapper input::placeholder {
color: var(--text-placeholder);
}
.input-wrapper input:focus {
background: var(--bg-input-focus);
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.input-wrapper input:focus ~ .input-icon,
.input-wrapper input:focus + .input-icon {
color: var(--accent);
}
/* Focus icon color — using :has */
.input-wrapper:has(input:focus) .input-icon {
color: var(--accent);
}
/* ===== Toggle Password ===== */
.toggle-password {
position: absolute;
right: 12px;
background: none;
border: none;
color: var(--text-placeholder);
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
transition: color var(--transition);
}
.toggle-password:hover {
color: var(--text-primary);
}
/* ===== Error Message ===== */
.error-message {
display: block;
min-height: 1.2rem;
font-size: 0.78rem;
color: var(--error);
margin-top: 0.3rem;
transition: opacity var(--transition);
}
/* ===== Form Alert ===== */
.form-alert {
display: none;
padding: 0.75rem 1rem;
border-radius: var(--radius-sm);
font-size: 0.85rem;
margin-bottom: 1rem;
text-align: center;
}
.form-alert.error {
display: block;
background: rgba(248, 113, 113, 0.1);
border: 1px solid rgba(248, 113, 113, 0.2);
color: var(--error);
}
.form-alert.success {
display: block;
background: rgba(52, 211, 153, 0.1);
border: 1px solid rgba(52, 211, 153, 0.2);
color: var(--success);
}
/* ===== Submit Button ===== */
.btn-submit {
width: 100%;
padding: 0.85rem;
border: none;
border-radius: var(--radius-sm);
background: linear-gradient(135deg, var(--accent), #8b5cf6);
color: #fff;
font-family: inherit;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
transition:
transform var(--transition),
box-shadow var(--transition),
opacity var(--transition);
box-shadow: 0 4px 16px var(--accent-glow);
}
.btn-submit:hover {
transform: translateY(-1px);
box-shadow: 0 6px 24px var(--accent-glow);
}
.btn-submit:active {
transform: translateY(0);
}
.btn-submit:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
}
/* ===== Spinner ===== */
.spinner {
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ===== Input Error State ===== */
.form-group.has-error .input-wrapper input {
border-color: var(--error);
box-shadow: 0 0 0 3px rgba(248, 113, 113, 0.15);
}
/* ===== Responsive ===== */
@media (max-width: 480px) {
.login-card {
padding: 2rem 1.25rem 1.5rem;
}
.login-header h1 {
font-size: 1.3rem;
}
}

View File

@@ -0,0 +1,59 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Панель преподавателя</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', sans-serif;
background: #0f0f1a;
color: #f0f0f5;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.placeholder {
text-align: center;
}
.placeholder h1 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
.placeholder p {
color: #9ca3af;
margin-bottom: 1.5rem;
}
.placeholder a {
color: #818cf8;
text-decoration: none;
}
.placeholder a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="placeholder">
<h1>Панель преподавателя</h1>
<p>Раздел в разработке</p>
<a href="/" onclick="localStorage.clear()">Выйти</a>
</div>
</body>
</html>