Initial commit: LMS microservices scaffold

- Gradle multi-module: libs/{common-http,security-jwt,markdown,test-support},
  services/{identity,academic,progress,gateway}, web (Thymeleaf+htmx)
- identity-service: JWT auth (RSA), accounts, students, groups, enrollment,
  import from XLSX/CSV, credentials export, password reset
- academic-service: lab import from JSON/zip (markdown + YAML + variants),
  levels, API
- progress-service: schema for student levels and variant assignments
- gateway: Spring Cloud Gateway, JWT validation, route propagation
- web: Server-side UI with flex/footer layout, light/dark theme toggle,
  admin dashboard, groups CRUD, student portal placeholder
- docker-compose: PostgreSQL 18
- Tests: identity smoke test with Testcontainers
This commit is contained in:
2026-09-10 19:46:13 +03:00
commit 9b7748571e
119 changed files with 4671 additions and 0 deletions

23
web/build.gradle.kts Normal file
View File

@@ -0,0 +1,23 @@
plugins {
id("lms.spring-boot")
}
dependencies {
implementation(platform(libs.springBootBom))
implementation(libs.springBootStarterWeb)
implementation(libs.springBootStarterThymeleaf)
implementation(libs.springBootStarterValidation)
implementation(libs.springBootStarterActuator)
implementation(project(":libs:common-http"))
developmentOnly(platform(libs.springBootBom))
developmentOnly(libs.springBootDevtools)
testImplementation(project(":libs:test-support"))
}
springBoot {
mainClass.set("ru.ysu.lms.web.WebApplicationKt")
}

View File

@@ -0,0 +1,11 @@
package ru.ysu.lms.web
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
@SpringBootApplication
class WebApplication
fun main(args: Array<String>) {
runApplication<WebApplication>(*args)
}

View File

@@ -0,0 +1,124 @@
package ru.ysu.lms.web.admin
import jakarta.servlet.http.HttpSession
import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.multipart.MultipartFile
import org.springframework.web.servlet.mvc.support.RedirectAttributes
import ru.ysu.lms.web.api.ApiClient
import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.session.SessionKeys
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
@Controller
@RequestMapping("/admin")
class AdminController(private val api: ApiClient) {
@GetMapping
fun dashboard(session: HttpSession, model: Model): String {
val groups = api.listGroups(token(session))
model.addAttribute("groupCount", groups.size)
model.addAttribute("studentCount", groups.sumOf { it.studentCount })
model.addAttribute("groups", groups.take(5))
return "admin/dashboard"
}
@GetMapping("/groups")
fun groups(session: HttpSession, model: Model): String {
model.addAttribute("groups", api.listGroups(token(session)))
return "admin/groups"
}
@PostMapping("/groups")
fun createGroup(
@RequestParam name: String,
session: HttpSession,
redirect: RedirectAttributes,
): String = try {
api.createGroup(token(session), name.trim())
redirect.addFlashAttribute("notice", "Группа создана")
"redirect:/admin/groups"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/groups"
}
@PostMapping("/groups/import")
fun importGroup(
@RequestParam("file") file: MultipartFile,
@RequestParam(name = "name", required = false) name: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String {
if (file.isEmpty) {
redirect.addFlashAttribute("error", "Выберите файл со списком группы")
return "redirect:/admin/groups"
}
return try {
val result = api.importGroup(
token(session),
name,
file.originalFilename ?: "group.xlsx",
file.bytes,
)
redirect.addFlashAttribute("importResult", result)
"redirect:/admin/groups/${result.groupId}"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/groups"
}
}
@GetMapping("/groups/{id}")
fun groupDetail(
@PathVariable id: String,
session: HttpSession,
model: Model,
): String {
model.addAttribute("group", api.groupDetail(token(session), id))
return "admin/group"
}
@PostMapping("/groups/{id}/students/{studentId}/reset-password")
fun resetPassword(
@PathVariable id: String,
@PathVariable studentId: String,
session: HttpSession,
redirect: RedirectAttributes,
): String = try {
val result = api.resetPassword(token(session), studentId)
redirect.addFlashAttribute("resetPassword", result.password)
redirect.addFlashAttribute("resetFor", studentId)
"redirect:/admin/groups/$id"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/groups/$id"
}
@GetMapping("/groups/{id}/credentials")
fun credentials(
@PathVariable id: String,
@RequestParam(defaultValue = "xlsx") format: String,
session: HttpSession,
): ResponseEntity<ByteArray> {
val download = api.credentials(token(session), id, format)
val encoded = URLEncoder.encode(download.filename, StandardCharsets.UTF_8).replace("+", "%20")
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''$encoded")
.contentType(MediaType.parseMediaType(download.contentType))
.body(download.bytes)
}
private fun token(session: HttpSession): String =
session.getAttribute(SessionKeys.TOKEN) as? String
?: throw ApiException(401, "Сессия истекла, войдите заново")
}

View File

@@ -0,0 +1,149 @@
package ru.ysu.lms.web.api
import org.springframework.beans.factory.annotation.Value
import org.springframework.core.ParameterizedTypeReference
import org.springframework.core.io.ByteArrayResource
import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
import org.springframework.stereotype.Component
import org.springframework.util.LinkedMultiValueMap
import org.springframework.web.client.RestClient
import org.springframework.web.client.RestClientException
import org.springframework.web.client.RestClientResponseException
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
@Component
class ApiClient(
@Value("\${lms.gateway.base-url}") baseUrl: String,
) {
private val client: RestClient = RestClient.builder().baseUrl(baseUrl).build()
fun adminExists(): Boolean =
call { client.get().uri("/api/v1/auth/health").retrieve().body(HealthDto::class.java) }.adminExists
fun setup(login: String, password: String, password2: String): AuthResponseDto =
call {
client.post().uri("/api/v1/auth/setup")
.contentType(MediaType.APPLICATION_JSON)
.body(SetupPayload(login, password, password2))
.retrieve().body(AuthResponseDto::class.java)
}
fun login(login: String, password: String): AuthResponseDto =
call {
client.post().uri("/api/v1/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.body(LoginPayload(login, password))
.retrieve().body(AuthResponseDto::class.java)
}
fun changePassword(token: String, old: String, new: String, new2: String) {
call {
client.post().uri("/api/v1/auth/change-password")
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.APPLICATION_JSON)
.body(ChangePasswordPayload(old, new, new2))
.retrieve().toBodilessEntity()
}
}
fun listGroups(token: String): List<GroupDto> {
val type = object : ParameterizedTypeReference<List<GroupDto>>() {}
return call {
client.get().uri("/api/v1/groups")
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve().body(type)
} ?: emptyList()
}
fun createGroup(token: String, name: String): GroupDto =
call {
client.post().uri("/api/v1/groups")
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.APPLICATION_JSON)
.body(CreateGroupPayload(name))
.retrieve().body(GroupDto::class.java)
}
fun importGroup(
token: String,
name: String?,
filename: String,
bytes: ByteArray,
): ImportResultDto {
val parts = LinkedMultiValueMap<String, Any>()
parts.add(
"file",
object : ByteArrayResource(bytes) {
override fun getFilename(): String = filename
},
)
if (!name.isNullOrBlank()) {
parts.add("name", name)
}
return call {
client.post().uri("/api/v1/groups/import")
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.MULTIPART_FORM_DATA)
.body(parts)
.retrieve().body(ImportResultDto::class.java)
}
}
fun groupDetail(token: String, groupId: String): GroupDetailDto =
call {
client.get().uri("/api/v1/groups/{id}", groupId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve().body(GroupDetailDto::class.java)
}
fun resetPassword(token: String, studentId: String): ResetPasswordDto =
call {
client.post().uri("/api/v1/students/{id}/reset-password", studentId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve().body(ResetPasswordDto::class.java)
}
fun credentials(token: String, groupId: String, format: String): DownloadDto {
val entity = call {
client.get()
.uri { builder ->
builder.path("/api/v1/groups/{id}/credentials").queryParam("format", format).build(groupId)
}
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve().toEntity(ByteArray::class.java)
}
val disposition = entity.headers.getFirst(HttpHeaders.CONTENT_DISPOSITION).orEmpty()
val rawName = disposition.substringAfter("UTF-8''", "").substringBefore(';').ifBlank { "credentials.$format" }
val filename = URLDecoder.decode(rawName, StandardCharsets.UTF_8)
val contentType = entity.headers.contentType?.toString() ?: "application/octet-stream"
return DownloadDto(entity.body ?: ByteArray(0), filename, contentType)
}
private fun bearer(token: String): String = "Bearer $token"
private fun <T> call(block: () -> T?): T =
try {
block() ?: throw ApiException(502, "Пустой ответ сервиса")
} catch (ex: ApiException) {
throw ex
} catch (ex: RestClientResponseException) {
throw ApiException(ex.statusCode.value(), detail(ex))
} catch (ex: RestClientException) {
throw ApiException(502, "Сервис недоступен. Проверьте, что gateway запущен.")
}
private fun detail(ex: RestClientResponseException): String {
val body = ex.responseBodyAsString
val match = DETAIL_PATTERN.find(body)
val parsed = match?.groupValues?.get(1)
?.replace("\\\"", "\"")
?.replace("\\n", "\n")
return parsed?.takeIf { it.isNotBlank() } ?: "Ошибка запроса (${ex.statusCode.value()})"
}
private companion object {
val DETAIL_PATTERN = Regex("\"detail\"\\s*:\\s*\"((?:\\\\.|[^\"\\\\])*)\"")
}
}

View File

@@ -0,0 +1,78 @@
package ru.ysu.lms.web.api
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
@JsonIgnoreProperties(ignoreUnknown = true)
data class HealthDto(
val ok: Boolean = false,
val adminExists: Boolean = false,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class AuthResponseDto(
val token: String = "",
val id: String = "",
val login: String = "",
val role: String = "",
val name: String = "",
val mustChangePassword: Boolean = false,
)
data class LoginPayload(val login: String, val password: String)
data class SetupPayload(val login: String, val password: String, val password2: String)
data class ChangePasswordPayload(val old: String, val new: String, val new2: String)
@JsonIgnoreProperties(ignoreUnknown = true)
data class GroupDto(
val id: String = "",
val name: String = "",
val studentCount: Long = 0,
val taskDurationMin: Int = 30,
)
data class CreateGroupPayload(val name: String)
@JsonIgnoreProperties(ignoreUnknown = true)
data class CredentialDto(
val id: String = "",
val studentNo: String = "",
val fullName: String = "",
val password: String = "",
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class ImportResultDto(
val groupId: String = "",
val groupName: String = "",
val added: List<CredentialDto> = emptyList(),
val updated: List<String> = emptyList(),
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class StudentRowDto(
val id: String = "",
val studentNo: String = "",
val fullName: String = "",
val basis: String = "",
val mustChangePassword: Boolean = false,
val tempPassword: String? = null,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class GroupDetailDto(
val id: String = "",
val name: String = "",
val taskDurationMin: Int = 30,
val students: List<StudentRowDto> = emptyList(),
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class ResetPasswordDto(val password: String = "")
class DownloadDto(
val bytes: ByteArray,
val filename: String,
val contentType: String,
)

View File

@@ -0,0 +1,6 @@
package ru.ysu.lms.web.api
class ApiException(
val status: Int,
override val message: String,
) : RuntimeException(message)

View File

@@ -0,0 +1,126 @@
package ru.ysu.lms.web.auth
import jakarta.servlet.http.HttpSession
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestParam
import ru.ysu.lms.web.api.ApiClient
import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.api.AuthResponseDto
import ru.ysu.lms.web.session.SessionKeys
import ru.ysu.lms.web.session.SessionUser
@Controller
class AuthPageController(private val api: ApiClient) {
@GetMapping("/")
fun root(session: HttpSession): String {
val user = currentUser(session) ?: return "redirect:/login"
return if (user.isAdmin) "redirect:/admin" else "redirect:/student"
}
@GetMapping("/login")
fun loginPage(session: HttpSession, model: Model): String {
if (currentUser(session) != null) {
return "redirect:/"
}
model.addAttribute("adminExists", api.adminExists())
return "login"
}
@PostMapping("/login")
fun login(
@RequestParam login: String,
@RequestParam password: String,
session: HttpSession,
model: Model,
): String = try {
val auth = api.login(login.trim(), password)
store(session, auth)
redirectFor(auth)
} catch (ex: ApiException) {
model.addAttribute("adminExists", api.adminExists())
model.addAttribute("error", ex.message)
model.addAttribute("login", login)
"login"
}
@GetMapping("/setup")
fun setupPage(model: Model): String {
if (api.adminExists()) {
return "redirect:/login"
}
return "setup"
}
@PostMapping("/setup")
fun setup(
@RequestParam login: String,
@RequestParam password: String,
@RequestParam password2: String,
session: HttpSession,
model: Model,
): String = try {
val auth = api.setup(login.trim(), password, password2)
store(session, auth)
"redirect:/admin"
} catch (ex: ApiException) {
model.addAttribute("error", ex.message)
model.addAttribute("login", login)
"setup"
}
@GetMapping("/change-password")
fun changePasswordPage(): String = "change_password"
@PostMapping("/change-password")
fun changePassword(
@RequestParam old: String,
@RequestParam("new") new: String,
@RequestParam new2: String,
session: HttpSession,
model: Model,
): String {
val user = currentUser(session) ?: return "redirect:/login"
val token = session.getAttribute(SessionKeys.TOKEN) as? String ?: return "redirect:/login"
return try {
api.changePassword(token, old, new, new2)
session.setAttribute(SessionKeys.USER, user.copy(mustChangePassword = false))
"redirect:/"
} catch (ex: ApiException) {
model.addAttribute("error", ex.message)
"change_password"
}
}
@PostMapping("/logout")
fun logout(session: HttpSession): String {
session.invalidate()
return "redirect:/login"
}
private fun currentUser(session: HttpSession): SessionUser? =
session.getAttribute(SessionKeys.USER) as? SessionUser
private fun store(session: HttpSession, auth: AuthResponseDto) {
session.setAttribute(SessionKeys.TOKEN, auth.token)
session.setAttribute(
SessionKeys.USER,
SessionUser(
id = auth.id,
login = auth.login,
role = auth.role,
name = auth.name,
mustChangePassword = auth.mustChangePassword,
),
)
}
private fun redirectFor(auth: AuthResponseDto): String = when {
auth.mustChangePassword -> "redirect:/change-password"
auth.role == "ADMIN" -> "redirect:/admin"
else -> "redirect:/student"
}
}

View File

@@ -0,0 +1,34 @@
package ru.ysu.lms.web.config
import jakarta.servlet.http.HttpServletRequest
import org.springframework.context.annotation.Configuration
import org.springframework.web.bind.annotation.ControllerAdvice
import org.springframework.web.bind.annotation.ModelAttribute
import org.springframework.web.servlet.config.annotation.InterceptorRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
import ru.ysu.lms.web.session.AdminInterceptor
import ru.ysu.lms.web.session.AuthInterceptor
import ru.ysu.lms.web.session.SessionKeys
import ru.ysu.lms.web.session.SessionUser
@Configuration
class WebConfig(
private val authInterceptor: AuthInterceptor,
private val adminInterceptor: AdminInterceptor,
) : WebMvcConfigurer {
override fun addInterceptors(registry: InterceptorRegistry) {
registry.addInterceptor(authInterceptor)
.addPathPatterns("/admin/**", "/student/**", "/change-password")
registry.addInterceptor(adminInterceptor)
.addPathPatterns("/admin/**")
}
}
@ControllerAdvice
class GlobalModelAdvice {
@ModelAttribute("currentUser")
fun currentUser(request: HttpServletRequest): SessionUser? =
request.getSession(false)?.getAttribute(SessionKeys.USER) as? SessionUser
}

View File

@@ -0,0 +1,19 @@
package ru.ysu.lms.web.config
import jakarta.servlet.http.HttpServletResponse
import org.springframework.ui.Model
import org.springframework.web.bind.annotation.ControllerAdvice
import org.springframework.web.bind.annotation.ExceptionHandler
import ru.ysu.lms.web.api.ApiException
@ControllerAdvice
class WebErrorAdvice {
@ExceptionHandler(ApiException::class)
fun handleApiError(ex: ApiException, response: HttpServletResponse, model: Model): String {
response.status = ex.status
model.addAttribute("status", ex.status)
model.addAttribute("message", ex.message)
return "error"
}
}

View File

@@ -0,0 +1,23 @@
package ru.ysu.lms.web.session
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.stereotype.Component
import org.springframework.web.servlet.HandlerInterceptor
@Component
class AdminInterceptor : HandlerInterceptor {
override fun preHandle(
request: HttpServletRequest,
response: HttpServletResponse,
handler: Any,
): Boolean {
val user = request.getSession(false)?.getAttribute(SessionKeys.USER) as? SessionUser
if (user != null && !user.isAdmin) {
response.sendRedirect("/student")
return false
}
return true
}
}

View File

@@ -0,0 +1,29 @@
package ru.ysu.lms.web.session
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.stereotype.Component
import org.springframework.web.servlet.HandlerInterceptor
@Component
class AuthInterceptor : HandlerInterceptor {
override fun preHandle(
request: HttpServletRequest,
response: HttpServletResponse,
handler: Any,
): Boolean {
val session = request.getSession(false)
val user = session?.getAttribute(SessionKeys.USER) as? SessionUser
if (user == null) {
response.sendRedirect("/login")
return false
}
val uri = request.requestURI
if (user.mustChangePassword && !uri.startsWith("/change-password")) {
response.sendRedirect("/change-password")
return false
}
return true
}
}

View File

@@ -0,0 +1,17 @@
package ru.ysu.lms.web.session
data class SessionUser(
val id: String,
val login: String,
val role: String,
val name: String,
val mustChangePassword: Boolean,
) {
val isAdmin: Boolean get() = role == "ADMIN"
val isStudent: Boolean get() = role == "STUDENT"
}
object SessionKeys {
const val TOKEN = "lms.token"
const val USER = "lms.user"
}

View File

@@ -0,0 +1,13 @@
package ru.ysu.lms.web.student
import org.springframework.stereotype.Controller
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
@Controller
@RequestMapping("/student")
class StudentController {
@GetMapping
fun dashboard(): String = "student/dashboard"
}

View File

@@ -0,0 +1,18 @@
server:
port: ${SERVER_PORT:8090}
spring:
application:
name: web
thymeleaf:
cache: false
lms:
gateway:
base-url: ${LMS_GATEWAY_URL:http://localhost:8080}
management:
endpoints:
web:
exposure:
include: health,info

View File

@@ -0,0 +1,81 @@
*, *::before, *::after { box-sizing: border-box; }
html { height: 100%; }
:root {
--c-bg: #f8fafc;
--c-surface: #ffffff;
--c-text: #0f172a;
--c-text-muted: #64748b;
--c-text-link: #6366f1;
--c-text-link-hover: #4f46e5;
--c-border: #e2e8f0;
--c-accent: #6366f1;
--c-badge-ok-bg: #dcfce7;
--c-badge-ok-text: #166534;
--c-badge-warn-bg: #fef3c7;
--c-badge-warn-text: #92400e;
}
[data-theme="dark"] {
--c-bg: #0f1115;
--c-surface: #171a21;
--c-text: #e7e9ee;
--c-text-muted: #9aa3b2;
--c-text-link: #6ea8fe;
--c-text-link-hover: #8bb9fe;
--c-border: #262b36;
--c-accent: #6ea8fe;
--c-badge-ok-bg: rgba(16, 185, 129, 0.15);
--c-badge-ok-text: #6ee7b7;
--c-badge-warn-bg: rgba(251, 191, 36, 0.15);
--c-badge-warn-text: #fcd34d;
}
body {
margin: 0;
background: var(--c-bg);
color: var(--c-text);
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
line-height: 1.5;
}
a { color: var(--c-text-link); }
a:hover { color: var(--c-text-link-hover); }
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem;
border-radius: 0.5rem; padding: 0.5rem 1rem; font-size: 0.875rem; font-weight: 500;
border: none; cursor: pointer; transition: opacity 0.15s;
}
.btn:active { opacity: 0.8; }
.btn-primary { background: var(--c-accent); color: white; }
.btn-primary:hover { opacity: 0.9; }
.btn-secondary {
background: var(--c-surface); color: var(--c-text);
box-shadow: inset 0 0 0 1px var(--c-border);
}
.btn-secondary:hover { background: var(--c-border); }
.input {
width: 100%; border-radius: 0.5rem; background: var(--c-bg); color: var(--c-text);
padding: 0.5rem 0.75rem; font-size: 0.875rem; border: 1px solid var(--c-border);
outline: none;
}
.input:focus { border-color: var(--c-accent); box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2); }
.input::placeholder { color: var(--c-text-muted); }
.label { display: block; margin-bottom: 0.25rem; font-size: 0.875rem; font-weight: 500; }
.card {
border-radius: 0.75rem; background: var(--c-surface); border: 1px solid var(--c-border);
}
.badge { display: inline-flex; align-items: center; border-radius: 9999px; padding: 0.125rem 0.625rem; font-size: 0.75rem; font-weight: 500; }
.badge-ok { background: var(--c-badge-ok-bg); color: var(--c-badge-ok-text); }
.badge-warn { background: var(--c-badge-warn-bg); color: var(--c-badge-warn-text); }
table { width: 100%; font-size: 0.875rem; border-collapse: collapse; }
th { text-align: left; font-weight: 500; color: var(--c-text-muted); padding: 0.75rem 1.5rem; border-bottom: 1px solid var(--c-border); }
td { padding: 0.75rem 1.5rem; border-bottom: 1px solid var(--c-border); }
tr:last-child td { border-bottom: none; }

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="ru" xmlns:th="http://www.thymeleaf.org"
th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
<head><title>Панель — LMS</title></head>
<body>
<div id="content">
<h1 style="margin:0 0 1.5rem;font-size:1.5rem;font-weight:600;">Панель преподавателя</h1>
<div style="display:grid;gap:1rem;grid-template-columns:repeat(auto-fill,minmax(12rem,1fr));">
<div class="card" style="padding:1.5rem;">
<p style="font-size:0.875rem;color:var(--c-text-muted);margin:0;">Группы</p>
<p style="margin:0.5rem 0 0;font-size:1.875rem;font-weight:600;" th:text="${groupCount}">0</p>
</div>
<div class="card" style="padding:1.5rem;">
<p style="font-size:0.875rem;color:var(--c-text-muted);margin:0;">Студенты</p>
<p style="margin:0.5rem 0 0;font-size:1.875rem;font-weight:600;" th:text="${studentCount}">0</p>
</div>
<div class="card" style="padding:1.5rem;">
<p style="font-size:0.875rem;color:var(--c-text-muted);margin:0;">Лабораторные</p>
<p style="margin:0.5rem 0 0;font-size:1.875rem;font-weight:600;color:var(--c-text-muted);">скоро</p>
</div>
</div>
<div style="margin-top:2rem;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.75rem;">
<h2 style="margin:0;font-size:1.125rem;font-weight:500;">Группы</h2>
<a th:href="@{/admin/groups}" style="font-size:0.875rem;">Все группы →</a>
</div>
<div class="card" style="padding:0;">
<table>
<thead>
<tr><th>Название</th><th>Студентов</th></tr>
</thead>
<tbody>
<tr th:each="group : ${groups}">
<td><a th:href="@{/admin/groups/{id}(id=${group.id})}" th:text="${group.name}">—</a></td>
<td th:text="${group.studentCount}">0</td>
</tr>
<tr th:if="${#lists.isEmpty(groups)}">
<td colspan="2" style="text-align:center;color:var(--c-text-muted);padding:1.5rem;">Групп пока нет.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,83 @@
<!DOCTYPE html>
<html lang="ru" xmlns:th="http://www.thymeleaf.org"
th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
<head><title th:text="${group.name} + ' — LMS'">Группа</title></head>
<body>
<div id="content">
<a th:href="@{/admin/groups}" style="font-size:0.875rem;">← Все группы</a>
<div style="margin-top:1rem;margin-bottom:1.5rem;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:1rem;">
<div>
<h1 style="margin:0;font-size:1.5rem;font-weight:600;" th:text="${group.name}">Группа</h1>
<p style="margin:0.25rem 0 0;font-size:0.875rem;color:var(--c-text-muted);">
Студентов: <span th:text="${#lists.size(group.students)}">0</span>
</p>
</div>
<div style="display:flex;gap:0.5rem;">
<a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='xlsx')}">Выгрузить XLSX</a>
<a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='csv')}">CSV</a>
</div>
</div>
<div th:if="${notice}" style="margin-bottom:1rem;padding:0.75rem 1rem;border-radius:0.5rem;font-size:0.875rem;background:#dcfce7;color:#166534;" th:text="${notice}"></div>
<div th:if="${error}" style="margin-bottom:1rem;padding:0.75rem 1rem;border-radius:0.5rem;font-size:0.875rem;background:#fef2f2;color:#991b1b;" th:text="${error}"></div>
<div th:if="${resetPassword}" style="margin-bottom:1rem;padding:0.75rem 1rem;border-radius:0.5rem;font-size:0.875rem;background:#fef3c7;color:#92400e;">
Новый пароль: <code style="padding:0.125rem 0.5rem;border-radius:0.25rem;font-family:monospace;" th:text="${resetPassword}">password</code>
</div>
<div th:if="${importResult}" class="card" style="margin-bottom:1.5rem;padding:1.5rem;">
<h2 style="margin:0 0 0.25rem;font-size:1.125rem;font-weight:500;">
Импорт: <span th:text="${importResult.groupName}">группа</span>
</h2>
<p style="margin:0;font-size:0.875rem;color:var(--c-text-muted);">
Новых студентов: <span th:text="${#lists.size(importResult.added)}">0</span>,
обновлено: <span th:text="${#lists.size(importResult.updated)}">0</span>.
</p>
<div th:if="${not #lists.isEmpty(importResult.added)}" style="margin-top:1rem;overflow-x:auto;">
<p style="margin:0 0 0.5rem;font-size:0.875rem;color:#92400e;">Сохраните пароли — они показываются для выдачи студентам.</p>
<table>
<thead>
<tr><th>ФИО</th><th>Логин</th><th>Пароль</th></tr>
</thead>
<tbody>
<tr th:each="credential : ${importResult.added}">
<td th:text="${credential.fullName}">—</td>
<td style="font-family:monospace;" th:text="${credential.studentNo}">—</td>
<td style="font-family:monospace;font-weight:600;" th:text="${credential.password}">—</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="card" style="padding:0;">
<table>
<thead>
<tr><th>№</th><th>ФИО</th><th>Логин</th><th>Основа</th><th>Пароль</th><th></th></tr>
</thead>
<tbody>
<tr th:each="student, iter : ${group.students}">
<td style="color:var(--c-text-muted);" th:text="${iter.count}">1</td>
<td th:text="${student.fullName}">—</td>
<td style="font-family:monospace;" th:text="${student.studentNo}">—</td>
<td style="color:var(--c-text-muted);" th:text="${student.basis}">—</td>
<td>
<span th:if="${student.mustChangePassword}" class="badge badge-warn">временный</span>
<span th:unless="${student.mustChangePassword}" class="badge badge-ok">изменён</span>
</td>
<td style="text-align:right;">
<form th:action="@{/admin/groups/{gid}/students/{sid}/reset-password(gid=${group.id}, sid=${student.id})}" method="post">
<button type="submit" style="font-size:0.875rem;border:none;background:none;cursor:pointer;color:var(--c-text-link);">Сбросить пароль</button>
</form>
</td>
</tr>
<tr th:if="${#lists.isEmpty(group.students)}">
<td colspan="6" style="text-align:center;color:var(--c-text-muted);padding:1.5rem;">В группе пока нет студентов.</td>
</tr>
</tbody>
</table>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="ru" xmlns:th="http://www.thymeleaf.org"
th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
<head><title>Группы — LMS</title></head>
<body>
<div id="content">
<h1 style="margin:0 0 1.5rem;font-size:1.5rem;font-weight:600;">Группы</h1>
<div th:if="${notice}" style="margin-bottom:1rem;padding:0.75rem 1rem;border-radius:0.5rem;font-size:0.875rem;background:#dcfce7;color:#166534;" th:text="${notice}"></div>
<div th:if="${error}" style="margin-bottom:1rem;padding:0.75rem 1rem;border-radius:0.5rem;font-size:0.875rem;background:#fef2f2;color:#991b1b;" th:text="${error}"></div>
<div style="display:grid;gap:1rem;grid-template-columns:repeat(auto-fill,minmax(22rem,1fr));">
<form class="card" style="padding:1.5rem;display:flex;flex-direction:column;gap:1rem;" th:action="@{/admin/groups}" method="post">
<h2 style="margin:0;font-size:1.125rem;font-weight:500;">Создать группу</h2>
<div>
<label class="label" for="name">Название</label>
<input class="input" id="name" name="name" placeholder="ПИ-11БО" required>
</div>
<button class="btn btn-primary" type="submit">Создать</button>
</form>
<form class="card" style="padding:1.5rem;display:flex;flex-direction:column;gap:1rem;" th:action="@{/admin/groups/import}" method="post" enctype="multipart/form-data">
<h2 style="margin:0;font-size:1.125rem;font-weight:500;">Импорт студентов</h2>
<p style="margin:0;font-size:0.875rem;color:var(--c-text-muted);">
Файл .xls, .xlsx или .csv со столбцами: студенческий номер, ФИО, основа обучения.
</p>
<div>
<label class="label" for="file">Файл со списком</label>
<input class="input" id="file" name="file" type="file" accept=".xls,.xlsx,.csv" required>
</div>
<div>
<label class="label" for="groupName">Название группы (необязательно)</label>
<input class="input" id="groupName" name="name" placeholder="Возьмём из файла">
</div>
<button class="btn btn-primary" type="submit">Импортировать</button>
</form>
</div>
<div class="card" style="margin-top:2rem;padding:0;">
<table>
<thead>
<tr><th>Название</th><th>Студентов</th><th>Выгрузка</th></tr>
</thead>
<tbody>
<tr th:each="group : ${groups}">
<td><a th:href="@{/admin/groups/{id}(id=${group.id})}" th:text="${group.name}">—</a></td>
<td th:text="${group.studentCount}">0</td>
<td>
<a th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='xlsx')}">XLSX</a>
<span style="margin:0 0.5rem;color:var(--c-text-muted);">·</span>
<a th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='csv')}">CSV</a>
</td>
</tr>
<tr th:if="${#lists.isEmpty(groups)}">
<td colspan="3" style="text-align:center;color:var(--c-text-muted);padding:1.5rem;">Групп пока нет.</td>
</tr>
</tbody>
</table>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="ru" xmlns:th="http://www.thymeleaf.org"
th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
<head><title>Смена пароля — LMS</title></head>
<body>
<div id="content">
<div style="max-width:28rem;margin:0 auto;">
<h1 style="margin:0 0 0.5rem;font-size:1.5rem;font-weight:600;">Смена пароля</h1>
<p style="margin:0 0 1.5rem;font-size:0.875rem;color:var(--c-text-muted);">При первом входе пароль нужно сменить.</p>
<div th:if="${error}" style="margin-bottom:1rem;padding:0.75rem 1rem;border-radius:0.5rem;font-size:0.875rem;background:#fef2f2;color:#991b1b;"
th:text="${error}"></div>
<form class="card" style="padding:1.5rem;display:flex;flex-direction:column;gap:1rem;" th:action="@{/change-password}" method="post">
<div>
<label class="label" for="old">Текущий пароль</label>
<input class="input" id="old" name="old" type="password" required>
</div>
<div>
<label class="label" for="new">Новый пароль</label>
<input class="input" id="new" name="new" type="password" required>
<p style="margin-top:0.25rem;font-size:0.75rem;color:var(--c-text-muted);">Минимум 8 символов.</p>
</div>
<div>
<label class="label" for="new2">Повторите новый пароль</label>
<input class="input" id="new2" name="new2" type="password" required>
</div>
<button class="btn btn-primary" type="submit">Сохранить</button>
</form>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="ru" xmlns:th="http://www.thymeleaf.org"
th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
<head><title>Ошибка — LMS</title></head>
<body>
<div id="content">
<div style="max-width:28rem;margin:0 auto;text-align:center;">
<p style="font-size:4rem;font-weight:700;color:var(--c-text-muted);opacity:0.3;" th:text="${status}">500</p>
<p style="margin-top:1rem;" th:text="${message}">Что-то пошло не так</p>
<a class="btn btn-secondary" style="margin-top:1.5rem;display:inline-flex;" th:href="@{/}">На главную</a>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:fragment="layout(title, content)">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title th:replace="${title}">LMS</title>
<link rel="stylesheet" th:href="@{/css/app.css}">
<script th:src="@{/js/htmx.min.js}" defer></script>
<script>
(function(){
var t = localStorage.getItem('lms-theme') || 'light';
document.documentElement.setAttribute('data-theme', t);
})();
function toggleTheme() {
var e = document.documentElement;
var t = e.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
e.setAttribute('data-theme', t);
localStorage.setItem('lms-theme', t);
}
</script>
</head>
<body class="flex flex-col" style="min-height:100vh">
<header style="position:sticky;top:0;z-index:10;border-bottom:1px solid var(--c-border);background:var(--c-surface);">
<div style="max-width:1152px;margin:0 auto;display:flex;align-items:center;justify-content:space-between;padding:0.75rem 1.5rem;">
<a th:href="@{/}" style="font-size:1.125rem;font-weight:600;text-decoration:none;">LMS</a>
<nav style="display:flex;align-items:center;gap:1rem;font-size:0.875rem;" th:if="${currentUser != null}">
<a th:if="${currentUser.admin}" th:href="@{/admin}">Панель</a>
<a th:if="${currentUser.admin}" th:href="@{/admin/groups}">Группы</a>
<span style="color:var(--c-text-muted)" th:text="${currentUser.name}">—</span>
<button onclick="toggleTheme()" class="btn btn-secondary" style="padding:0.25rem 0.5rem;font-size:0.75rem;">☀/🌙</button>
<form th:action="@{/logout}" method="post">
<button class="btn btn-secondary" type="submit">Выйти</button>
</form>
</nav>
</div>
</header>
<main style="flex:1;width:100%;max-width:1152px;margin:0 auto;padding:2rem 1.5rem;">
<th:block th:replace="${content}" />
</main>
<footer style="border-top:1px solid var(--c-border);padding:1rem;text-align:center;font-size:0.75rem;color:var(--c-text-muted);">
LMS v0.1.1
</footer>
</body>
</html>

View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="ru" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LMS</title>
<link rel="stylesheet" th:href="@{/css/app.css}">
</head>
<body>
<main class="container">
<h1>LMS</h1>
<p class="lead">Система для проведения практических занятий. Каркас запущен.</p>
<ul class="status">
<li>web-BFF: запущен</li>
<li>gateway: /actuator/health</li>
<li>identity / academic / progress: отдельные сервисы</li>
</ul>
</main>
</body>
</html>

View File

@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="ru" xmlns:th="http://www.thymeleaf.org"
th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
<head><title>Вход — LMS</title></head>
<body>
<div id="content">
<div style="max-width:28rem;margin:0 auto;">
<h1 style="margin:0 0 1.5rem;font-size:1.5rem;font-weight:600;">Вход</h1>
<div th:if="${error}" style="margin-bottom:1rem;padding:0.75rem 1rem;border-radius:0.5rem;font-size:0.875rem;background:#fef2f2;color:#991b1b;"
th:text="${error}"></div>
<div th:if="${!adminExists}" style="margin-bottom:1rem;padding:0.75rem 1rem;border-radius:0.5rem;font-size:0.875rem;background:#fef3c7;color:#92400e;">
Аккаунт администратора ещё не создан.
<a th:href="@{/setup}">Создать первый аккаунт</a>
</div>
<form class="card" style="padding:1.5rem;display:flex;flex-direction:column;gap:1rem;" th:action="@{/login}" method="post">
<div>
<label class="label" for="login">Логин</label>
<input class="input" id="login" name="login" th:value="${login}" autofocus required>
</div>
<div>
<label class="label" for="password">Пароль</label>
<input class="input" id="password" name="password" type="password" required>
</div>
<button class="btn btn-primary" type="submit">Войти</button>
</form>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="ru" xmlns:th="http://www.thymeleaf.org"
th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
<head><title>Первый запуск — LMS</title></head>
<body>
<div id="content">
<div style="max-width:28rem;margin:0 auto;">
<h1 style="margin:0 0 0.5rem;font-size:1.5rem;font-weight:600;">Создание администратора</h1>
<p style="margin:0 0 1.5rem;font-size:0.875rem;color:var(--c-text-muted);">
Это разовая настройка. Аккаунт преподавателя создаётся один раз.
</p>
<div th:if="${error}" style="margin-bottom:1rem;padding:0.75rem 1rem;border-radius:0.5rem;font-size:0.875rem;background:#fef2f2;color:#991b1b;"
th:text="${error}"></div>
<form class="card" style="padding:1.5rem;display:flex;flex-direction:column;gap:1rem;" th:action="@{/setup}" method="post">
<div>
<label class="label" for="login">Логин</label>
<input class="input" id="login" name="login" th:value="${login}" autofocus required>
</div>
<div>
<label class="label" for="password">Пароль</label>
<input class="input" id="password" name="password" type="password" required>
<p style="margin-top:0.25rem;font-size:0.75rem;color:var(--c-text-muted);">Минимум 8 символов.</p>
</div>
<div>
<label class="label" for="password2">Повторите пароль</label>
<input class="input" id="password2" name="password2" type="password" required>
</div>
<button class="btn btn-primary" type="submit">Создать и войти</button>
</form>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="ru" xmlns:th="http://www.thymeleaf.org"
th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
<head><title>Кабинет студента — LMS</title></head>
<body>
<div id="content">
<h1 style="margin:0 0 0.5rem;font-size:1.5rem;font-weight:600;">
Здравствуйте, <span th:text="${currentUser.name}">студент</span>
</h1>
<p style="margin:0 0 1.5rem;font-size:0.875rem;color:var(--c-text-muted);">
Здесь появятся лабораторные работы, условия вашего варианта и материалы занятий.
</p>
<div class="card" style="padding:1.5rem;">
<p>Разделы в разработке:</p>
<ul style="margin-top:0.75rem;padding-left:1.25rem;font-size:0.875rem;color:var(--c-text-muted);">
<li>список лабораторных и сроки сдачи;</li>
<li>условие закреплённого за вами варианта;</li>
<li>статьи и материалы занятий;</li>
<li>отправка решений.</li>
</ul>
</div>
</div>
</body>
</html>