Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package com.ericbouchut.learndev.admin;

import com.ericbouchut.learndev.audit.AuditService;
import com.ericbouchut.learndev.auth.RegistrationService;
import com.ericbouchut.learndev.auth.dto.RegisterForm;
import com.ericbouchut.learndev.user.entity.User;
import com.ericbouchut.learndev.user.repository.UserRepository;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;

import java.util.List;
import java.util.UUID;

/**
* Account administration: create instructor accounts and archive or
* reactivate any account. Archiving means {@code is_active = false}, which
* Spring Security already maps to a disabled login (v1 has no destructive
* delete); an admin cannot archive their own account (409), so the platform
* cannot lock every admin out. The initial instructor password is set by the
* admin and handed over out of band; the instructor can change it through
* the existing password-reset flow. Every action lands in the audit trail.
*/
@Service
public class AccountAdminService {

private final UserRepository users;
private final RegistrationService registration;
private final AuditService audit;

public AccountAdminService(
UserRepository users,
RegistrationService registration,
AuditService audit
) {
this.users = users;
this.registration = registration;
this.audit = audit;
}

/** Every account, for the admin user list. */
public List<User> allUsers() {
return users.findAll(Sort.by("username"));
}

/** One account, or 404. */
public User account(UUID userId) {
return users.findById(userId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
}

/**
* Create an {@code INSTRUCTOR} account (same uniqueness and hashing
* rules as self-registration, different seeded role).
*/
@Transactional
public User createInstructor(RegisterForm form, User actor, String ipAddress) {
User created = registration.register(form, "INSTRUCTOR");
audit.record("ACCOUNT_CREATED", actor, ipAddress, true,
"Instructor account " + created.getUserId() + " created");
return created;
}

/**
* Archive an account: the user can no longer log in.
*
* @throws ResponseStatusException 409 when the admin targets themselves
*/
@Transactional
public void archive(User target, User actor, String ipAddress) {
if (target.getUserId().equals(actor.getUserId())) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"An admin cannot archive their own account");
}
target.setActive(false);
users.save(target);
audit.record("ACCOUNT_ARCHIVED", actor, ipAddress, true,
"Account " + target.getUserId() + " archived");
}

/** Reactivate an archived account: the user can log in again. */
@Transactional
public void reactivate(User target, User actor, String ipAddress) {
target.setActive(true);
users.save(target);
audit.record("ACCOUNT_REACTIVATED", actor, ipAddress, true,
"Account " + target.getUserId() + " reactivated");
}
}
237 changes: 237 additions & 0 deletions src/main/java/com/ericbouchut/learndev/admin/AdminController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
package com.ericbouchut.learndev.admin;

import com.ericbouchut.learndev.auth.dto.RegisterForm;
import com.ericbouchut.learndev.auth.exception.DuplicateEmailException;
import com.ericbouchut.learndev.auth.exception.DuplicateUsernameException;
import com.ericbouchut.learndev.course.InstructorCourseService;
import com.ericbouchut.learndev.course.entity.Course;
import com.ericbouchut.learndev.course.entity.Lesson;
import com.ericbouchut.learndev.course.repository.CourseRepository;
import com.ericbouchut.learndev.user.entity.User;
import com.ericbouchut.learndev.user.repository.UserRepository;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
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.server.ResponseStatusException;

import java.security.Principal;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;

/**
* <b>Web</b> endpoints of the administration area under {@code /admin/**}
* (gated to {@code ROLE_ADMIN} by the security rules): the account list,
* instructor account creation, and account archiving. Rules and the audit
* trail live in {@link AccountAdminService}.
*/
@Controller
@RequestMapping("/admin")
public class AdminController {

private final AccountAdminService accounts;
private final UserRepository users;
private final CourseRepository courses;
private final InstructorCourseService instructorCourses;

public AdminController(
AccountAdminService accounts,
UserRepository users,
CourseRepository courses,
InstructorCourseService instructorCourses
) {
this.accounts = accounts;
this.users = users;
this.courses = courses;
this.instructorCourses = instructorCourses;
}

/**
* Display every account with its roles and active flag.
*
* @param model receives the accounts
* @return the account list view name
*/
@GetMapping("/users")
public String userList(Model model) {
model.addAttribute("accounts", accounts.allUsers());
return "admin/users";
}

/**
* Display the instructor account creation form.
*
* @param model receives an empty form
* @return the account form view name
*/
@GetMapping("/users/new-instructor")
public String newInstructorForm(Model model) {
model.addAttribute("form", new RegisterForm("", "", ""));
return "admin/user-form";
}

/**
* Create an instructor account. Duplicate username/email surface as
* field errors, like self-registration.
*
* @param form the submitted form, validated by {@code @Valid}
* @param binding collects validation and duplicate-field errors
* @param principal the logged-in admin
* @param request provides the client IP for the audit trail
* @return a redirect to the account list, or the re-rendered form
*/
@PostMapping("/users/new-instructor")
public String createInstructor(
@Valid @ModelAttribute("form") RegisterForm form,
BindingResult binding,
Principal principal,
HttpServletRequest request
) {
if (binding.hasErrors()) {
return "admin/user-form";
}
try {
accounts.createInstructor(form, currentUser(principal), request.getRemoteAddr());
} catch (DuplicateUsernameException e) {
binding.rejectValue("username", "duplicate", "Username already taken");
return "admin/user-form";
} catch (DuplicateEmailException e) {
binding.rejectValue("email", "duplicate", "Email already registered");
return "admin/user-form";
}
return "redirect:/admin/users?created";
}

/**
* Archive an account (PRG with an {@code archived} flag).
*
* @param userId the account to archive
* @param principal the logged-in admin (self-archive guard)
* @param request provides the client IP for the audit trail
* @return a redirect to the account list
*/
@PostMapping("/users/{userId}/archive")
public String archiveAccount(
@PathVariable UUID userId,
Principal principal,
HttpServletRequest request
) {
accounts.archive(accounts.account(userId),
currentUser(principal), request.getRemoteAddr());
return "redirect:/admin/users?archived";
}

/**
* Reactivate an account (PRG with a {@code reactivated} flag).
*
* @param userId the account to reactivate
* @param principal the logged-in admin
* @param request provides the client IP for the audit trail
* @return a redirect to the account list
*/
@PostMapping("/users/{userId}/reactivate")
public String reactivateAccount(
@PathVariable UUID userId,
Principal principal,
HttpServletRequest request
) {
accounts.reactivate(accounts.account(userId),
currentUser(principal), request.getRemoteAddr());
return "redirect:/admin/users?reactivated";
}

/**
* Display every course, any status, any instructor, with its lessons
* (content moderation view).
*
* @param model receives the courses and their lessons
* @return the course moderation view name
*/
@GetMapping("/courses")
public String courseList(Model model) {
List<Course> allCourses = courses.findAll(Sort.by("title"));
Map<Long, List<Lesson>> lessonsByCourseId = new HashMap<>();
for (Course course : allCourses) {
lessonsByCourseId.put(course.getCourseId(), instructorCourses.lessonsOf(course));
}
model.addAttribute("courses", allCourses);
model.addAttribute("lessonsByCourseId", lessonsByCourseId);
return "admin/courses";
}

/**
* Archive or restore any course, bypassing the instructor ownership
* rule (the lifecycle guards still apply). PRG back to the list.
*
* @param courseId the course to transition
* @param action {@code archive} or {@code restore}
* @param principal the logged-in admin
* @param request provides the client IP for the audit trail
* @return a redirect to the course moderation view
*/
@PostMapping("/courses/{courseId}/{action:archive|restore}")
public String transitionCourse(
@PathVariable Long courseId,
@PathVariable String action,
Principal principal,
HttpServletRequest request
) {
User admin = currentUser(principal);
Course course = courses.findById(courseId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
if ("archive".equals(action)) {
instructorCourses.archive(course, admin, request.getRemoteAddr());
} else {
instructorCourses.restore(course, admin, request.getRemoteAddr());
}
return "redirect:/admin/courses?" + action + "d";
}

/**
* Archive or restore any lesson of a course, bypassing the instructor
* ownership rule. PRG back to the list.
*
* @param courseId the course the lesson belongs to
* @param lessonId the lesson to transition
* @param action {@code archive} or {@code restore}
* @param principal the logged-in admin
* @param request provides the client IP for the audit trail
* @return a redirect to the course moderation view
*/
@PostMapping("/courses/{courseId}/lessons/{lessonId}/{action:archive|restore}")
public String transitionLesson(
@PathVariable Long courseId,
@PathVariable Long lessonId,
@PathVariable String action,
Principal principal,
HttpServletRequest request
) {
User admin = currentUser(principal);
Course course = courses.findById(courseId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
Lesson lesson = instructorCourses.ownedLesson(course, lessonId);
if ("archive".equals(action)) {
instructorCourses.archiveLesson(lesson, admin, request.getRemoteAddr());
} else {
instructorCourses.restoreLesson(lesson, admin, request.getRemoteAddr());
}
return "redirect:/admin/courses?lesson-" + action + "d";
}

private User currentUser(Principal principal) {
return users.findByUsername(principal.getName())
.orElseThrow(() -> new IllegalStateException(
"Authenticated user not found: " + principal.getName()));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,20 +41,36 @@ public RegistrationService(UserRepository users, RoleRepository roles, PasswordE
*/
@Transactional
public User register(RegisterForm form) {
return register(form, "STUDENT");
}

/**
* Registers a new account with the given seeded role (self-registration
* uses {@code STUDENT}; the admin area creates {@code INSTRUCTOR}
* accounts). Same uniqueness and hashing rules as {@link #register}.
*
* @param form the validated registration form
* @param roleName the seeded role to assign
* @return the saved user, including its generated id
* @throws DuplicateUsernameException if the username is already taken
* @throws DuplicateEmailException if the email is already registered
*/
@Transactional
public User register(RegisterForm form, String roleName) {
if (users.existsByUsername(form.username())) {
throw new DuplicateUsernameException(form.username());
}
if (users.existsByEmail(form.email())) {
throw new DuplicateEmailException(form.email());
}
Role student = roles.findByRoleName("STUDENT")
.orElseThrow(() -> new IllegalStateException("STUDENT role not seeded"));
Role role = roles.findByRoleName(roleName)
.orElseThrow(() -> new IllegalStateException(roleName + " role not seeded"));

User user = new User();
user.setUsername(form.username());
user.setEmail(form.email());
user.setPassword(encoder.encode(form.password()));
user.getRoles().add(student);
user.getRoles().add(role);

// The existsBy* pre-checks above race under concurrency: two requests can
// both pass them, and the loser hits the users_username_key/users_email_key
Expand Down
Loading