From b6ff6c457a5a161789dd303b8a5d62ae6b8b3c70 Mon Sep 17 00:00:00 2001 From: Zuev Date: Sat, 14 Feb 2026 02:05:37 +0300 Subject: [PATCH] feat: backend auth, admin panel, role-based routing --- .gitignore | 4 +- backend/Dockerfile | 12 + backend/pom.xml | 57 +++ .../java/com/magistr/app/Application.java | 12 + .../com/magistr/app/config/AppConfig.java | 14 + .../magistr/app/config/DataInitializer.java | 50 +++ .../app/controller/AuthController.java | 51 +++ .../app/controller/UserController.java | 70 ++++ .../magistr/app/dto/CreateUserRequest.java | 35 ++ .../com/magistr/app/dto/LoginRequest.java | 26 ++ .../com/magistr/app/dto/LoginResponse.java | 61 +++ .../com/magistr/app/dto/UserResponse.java | 41 ++ .../main/java/com/magistr/app/model/Role.java | 7 + .../main/java/com/magistr/app/model/User.java | 57 +++ .../app/repository/UserRepository.java | 11 + .../src/main/resources/application.properties | 12 + compose.yaml | 19 +- db/init/init.sql | 13 + frontend/.dockerignore | 2 + frontend/Dockerfile | 2 + frontend/admin/admin.css | 382 ++++++++++++++++++ frontend/admin/admin.js | 146 +++++++ frontend/admin/index.html | 119 ++++++ frontend/index.html | 86 ++++ frontend/script.js | 106 +++++ frontend/student/index.html | 59 +++ frontend/style.css | 341 ++++++++++++++++ frontend/teacher/index.html | 59 +++ 28 files changed, 1844 insertions(+), 10 deletions(-) create mode 100644 backend/Dockerfile create mode 100644 backend/pom.xml create mode 100644 backend/src/main/java/com/magistr/app/Application.java create mode 100644 backend/src/main/java/com/magistr/app/config/AppConfig.java create mode 100644 backend/src/main/java/com/magistr/app/config/DataInitializer.java create mode 100644 backend/src/main/java/com/magistr/app/controller/AuthController.java create mode 100644 backend/src/main/java/com/magistr/app/controller/UserController.java create mode 100644 backend/src/main/java/com/magistr/app/dto/CreateUserRequest.java create mode 100644 backend/src/main/java/com/magistr/app/dto/LoginRequest.java create mode 100644 backend/src/main/java/com/magistr/app/dto/LoginResponse.java create mode 100644 backend/src/main/java/com/magistr/app/dto/UserResponse.java create mode 100644 backend/src/main/java/com/magistr/app/model/Role.java create mode 100644 backend/src/main/java/com/magistr/app/model/User.java create mode 100644 backend/src/main/java/com/magistr/app/repository/UserRepository.java create mode 100644 backend/src/main/resources/application.properties create mode 100644 db/init/init.sql create mode 100644 frontend/.dockerignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/admin/admin.css create mode 100644 frontend/admin/admin.js create mode 100644 frontend/admin/index.html create mode 100644 frontend/index.html create mode 100644 frontend/script.js create mode 100644 frontend/student/index.html create mode 100644 frontend/style.css create mode 100644 frontend/teacher/index.html diff --git a/.gitignore b/.gitignore index 9db5ca1..e200438 100755 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ -# Игнорируем базу данных (обязательно!) -db/ +# Игнорируем данные БД (но не init-скрипты) +db/data/ postgres_data/ # Игнорируем секреты diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..08a9825 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/pom.xml b/backend/pom.xml new file mode 100644 index 0000000..6f9485a --- /dev/null +++ b/backend/pom.xml @@ -0,0 +1,57 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.5 + + + + com.magistr + app + 1.0.0 + magistr-backend + + + 17 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.postgresql + postgresql + runtime + + + + + org.springframework.security + spring-security-crypto + + + + + app + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/backend/src/main/java/com/magistr/app/Application.java b/backend/src/main/java/com/magistr/app/Application.java new file mode 100644 index 0000000..3464603 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/Application.java @@ -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); + } +} diff --git a/backend/src/main/java/com/magistr/app/config/AppConfig.java b/backend/src/main/java/com/magistr/app/config/AppConfig.java new file mode 100644 index 0000000..f51438d --- /dev/null +++ b/backend/src/main/java/com/magistr/app/config/AppConfig.java @@ -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(); + } +} diff --git a/backend/src/main/java/com/magistr/app/config/DataInitializer.java b/backend/src/main/java/com/magistr/app/config/DataInitializer.java new file mode 100644 index 0000000..ef011b3 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/config/DataInitializer.java @@ -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 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"); + } + } + } +} diff --git a/backend/src/main/java/com/magistr/app/controller/AuthController.java b/backend/src/main/java/com/magistr/app/controller/AuthController.java new file mode 100644 index 0000000..1dfc482 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/controller/AuthController.java @@ -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 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 login(@RequestBody LoginRequest request) { + Optional 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)); + } +} diff --git a/backend/src/main/java/com/magistr/app/controller/UserController.java b/backend/src/main/java/com/magistr/app/controller/UserController.java new file mode 100644 index 0000000..cd256ef --- /dev/null +++ b/backend/src/main/java/com/magistr/app/controller/UserController.java @@ -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 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", "Пользователь удалён")); + } +} diff --git a/backend/src/main/java/com/magistr/app/dto/CreateUserRequest.java b/backend/src/main/java/com/magistr/app/dto/CreateUserRequest.java new file mode 100644 index 0000000..1493eaa --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/CreateUserRequest.java @@ -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; + } +} diff --git a/backend/src/main/java/com/magistr/app/dto/LoginRequest.java b/backend/src/main/java/com/magistr/app/dto/LoginRequest.java new file mode 100644 index 0000000..353a4c0 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/LoginRequest.java @@ -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; + } +} diff --git a/backend/src/main/java/com/magistr/app/dto/LoginResponse.java b/backend/src/main/java/com/magistr/app/dto/LoginResponse.java new file mode 100644 index 0000000..7fa87cd --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/LoginResponse.java @@ -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; + } +} diff --git a/backend/src/main/java/com/magistr/app/dto/UserResponse.java b/backend/src/main/java/com/magistr/app/dto/UserResponse.java new file mode 100644 index 0000000..b1d3cdb --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/UserResponse.java @@ -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; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/Role.java b/backend/src/main/java/com/magistr/app/model/Role.java new file mode 100644 index 0000000..d2cfc71 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/Role.java @@ -0,0 +1,7 @@ +package com.magistr.app.model; + +public enum Role { + ADMIN, + TEACHER, + STUDENT +} diff --git a/backend/src/main/java/com/magistr/app/model/User.java b/backend/src/main/java/com/magistr/app/model/User.java new file mode 100644 index 0000000..eb2ba7e --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/User.java @@ -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; + } +} diff --git a/backend/src/main/java/com/magistr/app/repository/UserRepository.java b/backend/src/main/java/com/magistr/app/repository/UserRepository.java new file mode 100644 index 0000000..5c7ae4a --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/UserRepository.java @@ -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 { + + Optional findByUsername(String username); +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties new file mode 100644 index 0000000..35a3635 --- /dev/null +++ b/backend/src/main/resources/application.properties @@ -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 diff --git a/compose.yaml b/compose.yaml index 0a26e8f..8ffccc9 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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,25 +23,26 @@ services: - proxy depends_on: - backend - db: image: postgres:alpine3.23 container_name: db restart: always - environment: - POSTGRES_USER: ${POSTGRES_USER} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + environment: + POSTGRES_USER: ${POSTGRES_USER} + 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 diff --git a/db/init/init.sql b/db/init/init.sql new file mode 100644 index 0000000..c0df4b1 --- /dev/null +++ b/db/init/init.sql @@ -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; diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..4a246ec --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,2 @@ +Dockerfile +.dockerignore diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..93424dd --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,2 @@ +FROM httpd:alpine +COPY . /usr/local/apache2/htdocs/ diff --git a/frontend/admin/admin.css b/frontend/admin/admin.css new file mode 100644 index 0000000..a47a8fb --- /dev/null +++ b/frontend/admin/admin.css @@ -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; + } +} \ No newline at end of file diff --git a/frontend/admin/admin.js b/frontend/admin/admin.js new file mode 100644 index 0000000..16caffc --- /dev/null +++ b/frontend/admin/admin.js @@ -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 = 'Ошибка загрузки'; + } + } + + function renderUsers(users) { + if (!users.length) { + tbody.innerHTML = 'Нет пользователей'; + return; + } + + tbody.innerHTML = users.map(u => ` + + ${u.id} + ${escapeHtml(u.username)} + ${ROLE_LABELS[u.role] || u.role} + + + `).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(); +})(); diff --git a/frontend/admin/index.html b/frontend/admin/index.html new file mode 100644 index 0000000..8ceae50 --- /dev/null +++ b/frontend/admin/index.html @@ -0,0 +1,119 @@ + + + + + + + Админ-панель + + + + + + + + + + + +
+
+

Управление пользователями

+
+ +
+ +
+

Новый пользователь

+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
+ + +
+

Все пользователи

+
+ + + + + + + + + + + + + + +
IDИмя пользователяРоль
Загрузка...
+
+
+
+
+ + + + + \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..025cc5e --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,86 @@ + + + + + + + Авторизация + + + + + + +
+
+
+
+
+ +
+ +
+ + + + diff --git a/frontend/script.js b/frontend/script.js new file mode 100644 index 0000000..be7644c --- /dev/null +++ b/frontend/script.js @@ -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); + } + }); +})(); diff --git a/frontend/student/index.html b/frontend/student/index.html new file mode 100644 index 0000000..34f0a27 --- /dev/null +++ b/frontend/student/index.html @@ -0,0 +1,59 @@ + + + + + + + Панель студента + + + + + +
+

Панель студента

+

Раздел в разработке

+ Выйти +
+ + + \ No newline at end of file diff --git a/frontend/style.css b/frontend/style.css new file mode 100644 index 0000000..a1aecf7 --- /dev/null +++ b/frontend/style.css @@ -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; + } +} diff --git a/frontend/teacher/index.html b/frontend/teacher/index.html new file mode 100644 index 0000000..718bfae --- /dev/null +++ b/frontend/teacher/index.html @@ -0,0 +1,59 @@ + + + + + + + Панель преподавателя + + + + + +
+

Панель преподавателя

+

Раздел в разработке

+ Выйти +
+ + + \ No newline at end of file