sprygen 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +80 -0
  2. package/dist/cli.js +55 -0
  3. package/package.json +53 -0
  4. package/templates/auth/AuthController.java.ejs +40 -0
  5. package/templates/auth/JwtAuthFilter.java.ejs +62 -0
  6. package/templates/auth/JwtService.java.ejs +81 -0
  7. package/templates/auth/SecurityConfig.java.ejs +65 -0
  8. package/templates/auth/UserDetailsServiceImpl.java.ejs +24 -0
  9. package/templates/entity/Entity.java.ejs +40 -0
  10. package/templates/entity/EntityController.java.ejs +92 -0
  11. package/templates/entity/EntityControllerTest.java.ejs +24 -0
  12. package/templates/entity/EntityDto.java.ejs +32 -0
  13. package/templates/entity/EntityRepository.java.ejs +9 -0
  14. package/templates/entity/EntityService.java.ejs +32 -0
  15. package/templates/project/java/config/CorsConfig.java.ejs +24 -0
  16. package/templates/project/java/config/SecurityConfig.java.ejs +76 -0
  17. package/templates/project/java/config/SecurityConfigSession.java.ejs +73 -0
  18. package/templates/project/java/config/SwaggerConfig.java.ejs +31 -0
  19. package/templates/project/java/controller/AdminController.java.ejs +82 -0
  20. package/templates/project/java/controller/AuthController.java.ejs +86 -0
  21. package/templates/project/java/controller/HomeController.java.ejs +63 -0
  22. package/templates/project/java/controller/ProfileController.java.ejs +65 -0
  23. package/templates/project/java/controller/UserController.java.ejs +35 -0
  24. package/templates/project/java/dto/AuthRequest.java.ejs +15 -0
  25. package/templates/project/java/dto/AuthResponse.java.ejs +18 -0
  26. package/templates/project/java/dto/ProfileUpdateRequest.java.ejs +20 -0
  27. package/templates/project/java/dto/RegisterRequest.java.ejs +30 -0
  28. package/templates/project/java/dto/UserDto.java.ejs +17 -0
  29. package/templates/project/java/entity/Role.java.ejs +6 -0
  30. package/templates/project/java/entity/User.java.ejs +97 -0
  31. package/templates/project/java/repository/UserRepository.java.ejs +11 -0
  32. package/templates/project/java/security/JwtAuthFilter.java.ejs +62 -0
  33. package/templates/project/java/security/UserDetailsServiceImpl.java.ejs +21 -0
  34. package/templates/project/java/service/JwtService.java.ejs +81 -0
  35. package/templates/project/java/service/UserService.java.ejs +32 -0
  36. package/templates/project/resources/application.yml.ejs +50 -0
  37. package/templates/project/resources/logback-spring.xml.ejs +41 -0
  38. package/templates/project/static/admin.html.ejs +163 -0
  39. package/templates/project/static/assets/app.js.ejs +340 -0
  40. package/templates/project/static/assets/style.css +533 -0
  41. package/templates/project/static/css/style.css +595 -0
  42. package/templates/project/static/dashboard.html.ejs +119 -0
  43. package/templates/project/static/index.html.ejs +96 -0
  44. package/templates/project/static/js/api.js +30 -0
  45. package/templates/project/static/js/auth.js +44 -0
  46. package/templates/project/static/js/nav.js.ejs +82 -0
  47. package/templates/project/static/js/ui.js +57 -0
  48. package/templates/project/static/login.html.ejs +71 -0
  49. package/templates/project/static/profile.html.ejs +163 -0
  50. package/templates/project/static/register.html.ejs +82 -0
  51. package/templates/project/thymeleaf/admin/users.html.ejs +111 -0
  52. package/templates/project/thymeleaf/dashboard.html.ejs +109 -0
  53. package/templates/project/thymeleaf/layout.html.ejs +75 -0
  54. package/templates/project/thymeleaf/login.html.ejs +56 -0
  55. package/templates/project/thymeleaf/profile.html.ejs +133 -0
  56. package/templates/project/thymeleaf/register.html.ejs +56 -0
@@ -0,0 +1,20 @@
1
+ package <%= packageName %>.dto;
2
+
3
+ import lombok.AllArgsConstructor;
4
+ import lombok.Builder;
5
+ import lombok.Data;
6
+ import lombok.NoArgsConstructor;
7
+
8
+ @Data
9
+ @Builder
10
+ @AllArgsConstructor
11
+ @NoArgsConstructor
12
+ public class ProfileUpdateRequest {
13
+ private String firstName;
14
+ private String lastName;
15
+ private String bio;
16
+ private String avatarUrl;
17
+ /** Optional — only processed if both are supplied */
18
+ private String currentPassword;
19
+ private String newPassword;
20
+ }
@@ -0,0 +1,30 @@
1
+ package <%= packageName %>.dto;
2
+
3
+ import jakarta.validation.constraints.Email;
4
+ import jakarta.validation.constraints.NotBlank;
5
+ import jakarta.validation.constraints.Size;
6
+ import lombok.AllArgsConstructor;
7
+ import lombok.Builder;
8
+ import lombok.Data;
9
+ import lombok.NoArgsConstructor;
10
+
11
+ @Data
12
+ @Builder
13
+ @AllArgsConstructor
14
+ @NoArgsConstructor
15
+ public class RegisterRequest {
16
+
17
+ @NotBlank(message = "First name is required")
18
+ private String firstName;
19
+
20
+ @NotBlank(message = "Last name is required")
21
+ private String lastName;
22
+
23
+ @Email(message = "Must be a valid email")
24
+ @NotBlank(message = "Email is required")
25
+ private String email;
26
+
27
+ @NotBlank(message = "Password is required")
28
+ @Size(min = 6, message = "Password must be at least 6 characters")
29
+ private String password;
30
+ }
@@ -0,0 +1,17 @@
1
+ package <%= packageName %>.dto;
2
+
3
+ import lombok.AllArgsConstructor;
4
+ import lombok.Builder;
5
+ import lombok.Data;
6
+ import lombok.NoArgsConstructor;
7
+
8
+ @Data
9
+ @Builder
10
+ @AllArgsConstructor
11
+ @NoArgsConstructor
12
+ public class UserDto {
13
+ private Long id;
14
+ private String firstName;
15
+ private String lastName;
16
+ private String email;
17
+ }
@@ -0,0 +1,6 @@
1
+ package <%= packageName %>.entity;
2
+
3
+ public enum Role {
4
+ ROLE_USER,
5
+ ROLE_ADMIN
6
+ }
@@ -0,0 +1,97 @@
1
+ package <%= packageName %>.entity;
2
+
3
+ import jakarta.persistence.*;
4
+ import lombok.AllArgsConstructor;
5
+ import lombok.Builder;
6
+ import lombok.Data;
7
+ import lombok.NoArgsConstructor;
8
+ import org.springframework.security.core.GrantedAuthority;
9
+ import org.springframework.security.core.authority.SimpleGrantedAuthority;
10
+ import org.springframework.security.core.userdetails.UserDetails;
11
+
12
+ import java.time.LocalDateTime;
13
+ import java.util.Collection;
14
+ import java.util.HashSet;
15
+ import java.util.Set;
16
+ import java.util.stream.Collectors;
17
+
18
+ @Data
19
+ @Builder
20
+ @NoArgsConstructor
21
+ @AllArgsConstructor
22
+ @Entity
23
+ @Table(name = "users")
24
+ public class User implements UserDetails {
25
+
26
+ @Id
27
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
28
+ private Long id;
29
+
30
+ @Column(nullable = false)
31
+ private String firstName;
32
+
33
+ @Column(nullable = false)
34
+ private String lastName;
35
+
36
+ @Column(unique = true, nullable = false)
37
+ private String email;
38
+
39
+ @Column(nullable = false)
40
+ private String password;
41
+
42
+ private String bio;
43
+
44
+ private String avatarUrl;
45
+
46
+ @Builder.Default
47
+ @ElementCollection(fetch = FetchType.EAGER)
48
+ @CollectionTable(name = "user_roles", joinColumns = @JoinColumn(name = "user_id"))
49
+ @Enumerated(EnumType.STRING)
50
+ private Set<Role> roles = new HashSet<>(Set.of(Role.ROLE_USER));
51
+
52
+ @Column(updatable = false)
53
+ private LocalDateTime createdAt;
54
+
55
+ private LocalDateTime updatedAt;
56
+
57
+ @PrePersist
58
+ protected void onCreate() {
59
+ createdAt = LocalDateTime.now();
60
+ updatedAt = LocalDateTime.now();
61
+ }
62
+
63
+ @PreUpdate
64
+ protected void onUpdate() {
65
+ updatedAt = LocalDateTime.now();
66
+ }
67
+
68
+ // --- UserDetails ---
69
+
70
+ @Override
71
+ public Collection<? extends GrantedAuthority> getAuthorities() {
72
+ return roles.stream()
73
+ .map(role -> new SimpleGrantedAuthority(role.name()))
74
+ .collect(Collectors.toSet());
75
+ }
76
+
77
+ @Override
78
+ public String getUsername() {
79
+ return email;
80
+ }
81
+
82
+ @Override
83
+ public boolean isAccountNonExpired() { return true; }
84
+
85
+ @Override
86
+ public boolean isAccountNonLocked() { return true; }
87
+
88
+ @Override
89
+ public boolean isCredentialsNonExpired() { return true; }
90
+
91
+ @Override
92
+ public boolean isEnabled() { return true; }
93
+
94
+ public boolean isAdmin() {
95
+ return roles != null && roles.contains(Role.ROLE_ADMIN);
96
+ }
97
+ }
@@ -0,0 +1,11 @@
1
+ package <%= packageName %>.repository;
2
+
3
+ import <%= packageName %>.entity.User;
4
+ import org.springframework.data.jpa.repository.JpaRepository;
5
+
6
+ import java.util.Optional;
7
+
8
+ public interface UserRepository extends JpaRepository<User, Long> {
9
+ Optional<User> findByEmail(String email);
10
+ boolean existsByEmail(String email);
11
+ }
@@ -0,0 +1,62 @@
1
+ package <%= packageName %>.security;
2
+
3
+ import <%= packageName %>.service.JwtService;
4
+ import jakarta.servlet.FilterChain;
5
+ import jakarta.servlet.ServletException;
6
+ import jakarta.servlet.http.HttpServletRequest;
7
+ import jakarta.servlet.http.HttpServletResponse;
8
+ import lombok.RequiredArgsConstructor;
9
+ import org.springframework.lang.NonNull;
10
+ import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
11
+ import org.springframework.security.core.context.SecurityContextHolder;
12
+ import org.springframework.security.core.userdetails.UserDetails;
13
+ import org.springframework.security.core.userdetails.UserDetailsService;
14
+ import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
15
+ import org.springframework.stereotype.Component;
16
+ import org.springframework.web.filter.OncePerRequestFilter;
17
+
18
+ import java.io.IOException;
19
+
20
+ @Component
21
+ @RequiredArgsConstructor
22
+ public class JwtAuthFilter extends OncePerRequestFilter {
23
+
24
+ private final JwtService jwtService;
25
+ private final UserDetailsService userDetailsService;
26
+
27
+ @Override
28
+ protected void doFilterInternal(
29
+ @NonNull HttpServletRequest request,
30
+ @NonNull HttpServletResponse response,
31
+ @NonNull FilterChain filterChain
32
+ ) throws ServletException, IOException {
33
+ final String authHeader = request.getHeader("Authorization");
34
+ final String jwt;
35
+ final String userEmail;
36
+
37
+ if (authHeader == null || !authHeader.startsWith("Bearer ")) {
38
+ filterChain.doFilter(request, response);
39
+ return;
40
+ }
41
+
42
+ jwt = authHeader.substring(7);
43
+ userEmail = jwtService.extractUsername(jwt);
44
+
45
+ if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) {
46
+ UserDetails userDetails = this.userDetailsService.loadUserByUsername(userEmail);
47
+
48
+ if (jwtService.isTokenValid(jwt, userDetails)) {
49
+ UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
50
+ userDetails,
51
+ null,
52
+ userDetails.getAuthorities()
53
+ );
54
+ authToken.setDetails(
55
+ new WebAuthenticationDetailsSource().buildDetails(request)
56
+ );
57
+ SecurityContextHolder.getContext().setAuthentication(authToken);
58
+ }
59
+ }
60
+ filterChain.doFilter(request, response);
61
+ }
62
+ }
@@ -0,0 +1,21 @@
1
+ package <%= packageName %>.security;
2
+
3
+ import <%= packageName %>.repository.UserRepository;
4
+ import lombok.RequiredArgsConstructor;
5
+ import org.springframework.security.core.userdetails.UserDetails;
6
+ import org.springframework.security.core.userdetails.UserDetailsService;
7
+ import org.springframework.security.core.userdetails.UsernameNotFoundException;
8
+ import org.springframework.stereotype.Service;
9
+
10
+ @Service
11
+ @RequiredArgsConstructor
12
+ public class UserDetailsServiceImpl implements UserDetailsService {
13
+
14
+ private final UserRepository userRepository;
15
+
16
+ @Override
17
+ public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
18
+ return userRepository.findByEmail(username)
19
+ .orElseThrow(() -> new UsernameNotFoundException("User not found with email: " + username));
20
+ }
21
+ }
@@ -0,0 +1,81 @@
1
+ package <%= packageName %>.service;
2
+
3
+ import io.jsonwebtoken.Claims;
4
+ import io.jsonwebtoken.Jwts;
5
+ import io.jsonwebtoken.SignatureAlgorithm;
6
+ import io.jsonwebtoken.io.Decoders;
7
+ import io.jsonwebtoken.security.Keys;
8
+ import org.springframework.beans.factory.annotation.Value;
9
+ import org.springframework.security.core.userdetails.UserDetails;
10
+ import org.springframework.stereotype.Service;
11
+
12
+ import java.security.Key;
13
+ import java.util.Date;
14
+ import java.util.HashMap;
15
+ import java.util.Map;
16
+ import java.util.function.Function;
17
+
18
+ @Service
19
+ public class JwtService {
20
+
21
+ @Value("${jwt.secret}")
22
+ private String secretKey;
23
+
24
+ @Value("${jwt.expiration}")
25
+ private long jwtExpiration;
26
+
27
+ public String extractUsername(String token) {
28
+ return extractClaim(token, Claims::getSubject);
29
+ }
30
+
31
+ public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
32
+ final Claims claims = extractAllClaims(token);
33
+ return claimsResolver.apply(claims);
34
+ }
35
+
36
+ public String generateToken(UserDetails userDetails) {
37
+ return generateToken(new HashMap<>(), userDetails);
38
+ }
39
+
40
+ public String generateToken(Map<String, Object> extraClaims, UserDetails userDetails) {
41
+ return buildToken(extraClaims, userDetails, jwtExpiration);
42
+ }
43
+
44
+ private String buildToken(Map<String, Object> extraClaims, UserDetails userDetails, long expiration) {
45
+ return Jwts
46
+ .builder()
47
+ .setClaims(extraClaims)
48
+ .setSubject(userDetails.getUsername())
49
+ .setIssuedAt(new Date(System.currentTimeMillis()))
50
+ .setExpiration(new Date(System.currentTimeMillis() + expiration))
51
+ .signWith(getSignInKey(), SignatureAlgorithm.HS256)
52
+ .compact();
53
+ }
54
+
55
+ public boolean isTokenValid(String token, UserDetails userDetails) {
56
+ final String username = extractUsername(token);
57
+ return (username.equals(userDetails.getUsername())) && !isTokenExpired(token);
58
+ }
59
+
60
+ private boolean isTokenExpired(String token) {
61
+ return extractExpiration(token).before(new Date());
62
+ }
63
+
64
+ private Date extractExpiration(String token) {
65
+ return extractClaim(token, Claims::getExpiration);
66
+ }
67
+
68
+ private Claims extractAllClaims(String token) {
69
+ return Jwts
70
+ .parserBuilder()
71
+ .setSigningKey(getSignInKey())
72
+ .build()
73
+ .parseClaimsJws(token)
74
+ .getBody();
75
+ }
76
+
77
+ private Key getSignInKey() {
78
+ byte[] keyBytes = Decoders.BASE64.decode(secretKey);
79
+ return Keys.hmacShaKeyFor(keyBytes);
80
+ }
81
+ }
@@ -0,0 +1,32 @@
1
+ package <%= packageName %>.service;
2
+
3
+ import <%= packageName %>.entity.User;
4
+ import <%= packageName %>.repository.UserRepository;
5
+ import lombok.RequiredArgsConstructor;
6
+ import org.springframework.stereotype.Service;
7
+
8
+ import java.util.List;
9
+ import java.util.Optional;
10
+
11
+ @Service
12
+ @RequiredArgsConstructor
13
+ public class UserService {
14
+
15
+ private final UserRepository userRepository;
16
+
17
+ public List<User> getAllUsers() {
18
+ return userRepository.findAll();
19
+ }
20
+
21
+ public Optional<User> getUserById(Long id) {
22
+ return userRepository.findById(id);
23
+ }
24
+
25
+ public User saveUser(User user) {
26
+ return userRepository.save(user);
27
+ }
28
+
29
+ public void deleteUser(Long id) {
30
+ userRepository.deleteById(id);
31
+ }
32
+ }
@@ -0,0 +1,50 @@
1
+ spring:
2
+ application:
3
+ name: <%= projectName %>
4
+
5
+ datasource:
6
+ url: ${DB_URL:<%= dbConfig.url %>}
7
+ username: ${DB_USER:<%= dbConfig.username %>}
8
+ password: ${DB_PASS:<%= dbConfig.password %>}
9
+ driver-class-name: <%= dbDriverClass %>
10
+
11
+ jpa:
12
+ <%_ if (dbConfig.dialect) { _%>
13
+ database-platform: <%= dbConfig.dialect %>
14
+ <%_ } _%>
15
+ hibernate:
16
+ ddl-auto: update
17
+ show-sql: false
18
+ properties:
19
+ hibernate:
20
+ format_sql: true
21
+ <%_ if (database === 'h2') { _%>
22
+
23
+ h2:
24
+ console:
25
+ enabled: true
26
+ path: /h2-console
27
+ <%_ } _%>
28
+
29
+ <%_ if (hasMail) { _%>
30
+ mail:
31
+ host: ${MAIL_HOST:smtp.mailtrap.io}
32
+ port: ${MAIL_PORT:2525}
33
+ username: ${MAIL_USER:your_username}
34
+ password: ${MAIL_PASS:your_password}
35
+ properties:
36
+ mail:
37
+ smtp:
38
+ auth: true
39
+ starttls:
40
+ enable: true
41
+ <%_ } _%>
42
+
43
+ jwt:
44
+ secret: ${JWT_SECRET:404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970}
45
+ expiration: ${JWT_EXPIRATION:86400000} # 24h
46
+
47
+ server:
48
+ port: 8080
49
+ error:
50
+ include-message: always
@@ -0,0 +1,41 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <configuration>
3
+ <property name="LOGS" value="./logs" />
4
+
5
+ <appender name="Console" class="ch.qos.logback.core.ConsoleAppender">
6
+ <layout class="ch.qos.logback.classic.PatternLayout">
7
+ <Pattern>
8
+ %black(%d{ISO8601}) %highlight(%-5level) [%blue(%t)] %yellow(%C{1.}): %msg%n%throwable
9
+ </Pattern>
10
+ </layout>
11
+ </appender>
12
+
13
+ <appender name="RollingFile" class="ch.qos.logback.core.rolling.RollingFileAppender">
14
+ <file>${LOGS}/spring-boot-logger.log</file>
15
+ <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
16
+ <Pattern>%d %p %C{1.} [%t] %m%n</Pattern>
17
+ </encoder>
18
+
19
+ <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
20
+ <!-- rollover daily and when the file reaches 10 MegaBytes -->
21
+ <fileNamePattern>${LOGS}/archived/spring-boot-logger-%d{yyyy-MM-dd}.%i.log
22
+ </fileNamePattern>
23
+ <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
24
+ <maxFileSize>10MB</maxFileSize>
25
+ </timeBasedFileNamingAndTriggeringPolicy>
26
+ </rollingPolicy>
27
+ </appender>
28
+
29
+ <!-- LOG everything at INFO level -->
30
+ <root level="info">
31
+ <appender-ref ref="RollingFile" />
32
+ <appender-ref ref="Console" />
33
+ </root>
34
+
35
+ <!-- LOG "<%= packageName %>*" at TRACE level -->
36
+ <logger name="<%= packageName %>" level="trace" additivity="false">
37
+ <appender-ref ref="RollingFile" />
38
+ <appender-ref ref="Console" />
39
+ </logger>
40
+
41
+ </configuration>
@@ -0,0 +1,163 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8"/>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
6
+ <title><%= projectName %> — Admin Panel</title>
7
+ <link rel="stylesheet" href="/css/style.css"/>
8
+ </head>
9
+ <body>
10
+ <div class="app-shell">
11
+ <aside id="sidebar" class="sidebar"></aside>
12
+ <div class="main">
13
+ <header class="topbar">
14
+ <span class="topbar-title">User Management</span>
15
+ <span id="user-count" class="topbar-meta"></span>
16
+ </header>
17
+ <div class="page">
18
+ <div class="page-header">
19
+ <h1>Users</h1>
20
+ <p class="text-muted">Manage roles and access for all registered users.</p>
21
+ </div>
22
+
23
+ <!-- Quick stats -->
24
+ <div class="stats-row" id="admin-stats">
25
+ <div class="stat-card">
26
+ <div class="stat-label">Total Users</div>
27
+ <div class="stat-value text-green" id="a-stat-total">—</div>
28
+ </div>
29
+ <div class="stat-card">
30
+ <div class="stat-label">Administrators</div>
31
+ <div class="stat-value" id="a-stat-admins">—</div>
32
+ </div>
33
+ <div class="stat-card">
34
+ <div class="stat-label">Members</div>
35
+ <div class="stat-value" id="a-stat-members">—</div>
36
+ </div>
37
+ </div>
38
+
39
+ <div class="card">
40
+ <div class="card-header">
41
+ <span class="card-title">All users</span>
42
+ <button class="btn btn-ghost btn-sm" onclick="loadUsers()">Refresh</button>
43
+ </div>
44
+ <div class="table-wrap">
45
+ <table>
46
+ <thead>
47
+ <tr><th>#</th><th>Name</th><th>Email</th><th>Role</th><th>Joined</th><th>Actions</th></tr>
48
+ </thead>
49
+ <tbody id="users-table">
50
+ <tr><td colspan="6" class="table-empty">Loading…</td></tr>
51
+ </tbody>
52
+ </table>
53
+ </div>
54
+ </div>
55
+ </div>
56
+ </div>
57
+ </div>
58
+ <div id="toast" class="toast"></div>
59
+ <script src="/js/auth.js"></script>
60
+ <script src="/js/ui.js"></script>
61
+ <script src="/js/nav.js"></script>
62
+ <script>
63
+ if (!guardAdmin()) throw new Error('redirect');
64
+ renderNav('admin');
65
+
66
+ async function loadStats() {
67
+ try {
68
+ const r = await fetch('/api/v1/admin/stats', {
69
+ headers: { Authorization: 'Bearer ' + Auth.token() }
70
+ });
71
+ if (!r.ok) throw new Error();
72
+ const s = await r.json();
73
+ animateCount(document.getElementById('a-stat-total'), s.totalUsers || 0);
74
+ animateCount(document.getElementById('a-stat-admins'), s.admins || 0);
75
+ animateCount(document.getElementById('a-stat-members'), s.regularUsers || 0);
76
+ } catch { /* silent, stats are bonus */ }
77
+ }
78
+
79
+ async function loadUsers() {
80
+ const tbody = document.getElementById('users-table');
81
+ tbody.innerHTML = '<tr><td colspan="6" class="table-empty">Loading…</td></tr>';
82
+ try {
83
+ const r = await fetch('/api/v1/admin/users', {
84
+ headers: { Authorization: 'Bearer ' + Auth.token() }
85
+ });
86
+ if (!r.ok) throw new Error('Access denied (' + r.status + ')');
87
+ const users = await r.json();
88
+
89
+ document.getElementById('user-count').textContent = users.length + ' users';
90
+
91
+ if (!users.length) {
92
+ tbody.innerHTML = '<tr><td colspan="6" class="table-empty">No users found.</td></tr>';
93
+ return;
94
+ }
95
+
96
+ const meId = Auth.user()?.id;
97
+
98
+ tbody.innerHTML = users.map(u => {
99
+ const isAdmin = u.roles?.includes('ROLE_ADMIN') || u.admin;
100
+ const joined = u.createdAt
101
+ ? new Date(u.createdAt).toLocaleDateString('en-US', { month:'short', day:'numeric', year:'numeric' })
102
+ : '—';
103
+ const isSelf = u.id === meId;
104
+ const newRole = isAdmin ? 'ROLE_USER' : 'ROLE_ADMIN';
105
+ const roleLabel = isAdmin ? 'Make Member' : 'Make Admin';
106
+ const roleBadge = isAdmin
107
+ ? '<span class="badge badge-admin">Admin</span>'
108
+ : '<span class="badge badge-auth">Member</span>';
109
+
110
+ const actions = isSelf
111
+ ? '<span class="text-muted" style="font-size:0.78rem;">— you —</span>'
112
+ : `<div class="actions">
113
+ <button class="btn btn-ghost btn-xs" onclick="changeRole(${u.id},'${newRole}')">${roleLabel}</button>
114
+ <button class="btn btn-danger-ghost btn-xs" onclick="deleteUser(${u.id},'${u.firstName}')">Delete</button>
115
+ </div>`;
116
+
117
+ return `<tr>
118
+ <td class="text-dim">${u.id}</td>
119
+ <td><strong>${u.firstName} ${u.lastName}</strong></td>
120
+ <td class="text-muted">${u.email}</td>
121
+ <td>${roleBadge}</td>
122
+ <td class="text-muted">${joined}</td>
123
+ <td>${actions}</td>
124
+ </tr>`;
125
+ }).join('');
126
+ } catch (err) {
127
+ tbody.innerHTML = `<tr><td colspan="6" class="table-empty text-danger">${err.message}</td></tr>`;
128
+ }
129
+ }
130
+
131
+ async function changeRole(id, role) {
132
+ try {
133
+ const r = await fetch('/api/v1/admin/users/' + id + '/role', {
134
+ method: 'PUT',
135
+ headers: { 'Content-Type':'application/json', Authorization: 'Bearer ' + Auth.token() },
136
+ body: JSON.stringify({ role })
137
+ });
138
+ if (!r.ok) throw new Error('Failed to update role');
139
+ showToast('Role updated');
140
+ loadUsers();
141
+ loadStats();
142
+ } catch (err) { showToast(err.message, 'error'); }
143
+ }
144
+
145
+ async function deleteUser(id, name) {
146
+ if (!confirm('Delete "' + name + '"? This cannot be undone.')) return;
147
+ try {
148
+ const r = await fetch('/api/v1/admin/users/' + id, {
149
+ method: 'DELETE',
150
+ headers: { Authorization: 'Bearer ' + Auth.token() }
151
+ });
152
+ if (!r.ok) throw new Error('Delete failed');
153
+ showToast(name + ' deleted');
154
+ loadUsers();
155
+ loadStats();
156
+ } catch (err) { showToast(err.message, 'error'); }
157
+ }
158
+
159
+ loadStats();
160
+ loadUsers();
161
+ </script>
162
+ </body>
163
+ </html>