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.
- package/README.md +80 -0
- package/dist/cli.js +55 -0
- package/package.json +53 -0
- package/templates/auth/AuthController.java.ejs +40 -0
- package/templates/auth/JwtAuthFilter.java.ejs +62 -0
- package/templates/auth/JwtService.java.ejs +81 -0
- package/templates/auth/SecurityConfig.java.ejs +65 -0
- package/templates/auth/UserDetailsServiceImpl.java.ejs +24 -0
- package/templates/entity/Entity.java.ejs +40 -0
- package/templates/entity/EntityController.java.ejs +92 -0
- package/templates/entity/EntityControllerTest.java.ejs +24 -0
- package/templates/entity/EntityDto.java.ejs +32 -0
- package/templates/entity/EntityRepository.java.ejs +9 -0
- package/templates/entity/EntityService.java.ejs +32 -0
- package/templates/project/java/config/CorsConfig.java.ejs +24 -0
- package/templates/project/java/config/SecurityConfig.java.ejs +76 -0
- package/templates/project/java/config/SecurityConfigSession.java.ejs +73 -0
- package/templates/project/java/config/SwaggerConfig.java.ejs +31 -0
- package/templates/project/java/controller/AdminController.java.ejs +82 -0
- package/templates/project/java/controller/AuthController.java.ejs +86 -0
- package/templates/project/java/controller/HomeController.java.ejs +63 -0
- package/templates/project/java/controller/ProfileController.java.ejs +65 -0
- package/templates/project/java/controller/UserController.java.ejs +35 -0
- package/templates/project/java/dto/AuthRequest.java.ejs +15 -0
- package/templates/project/java/dto/AuthResponse.java.ejs +18 -0
- package/templates/project/java/dto/ProfileUpdateRequest.java.ejs +20 -0
- package/templates/project/java/dto/RegisterRequest.java.ejs +30 -0
- package/templates/project/java/dto/UserDto.java.ejs +17 -0
- package/templates/project/java/entity/Role.java.ejs +6 -0
- package/templates/project/java/entity/User.java.ejs +97 -0
- package/templates/project/java/repository/UserRepository.java.ejs +11 -0
- package/templates/project/java/security/JwtAuthFilter.java.ejs +62 -0
- package/templates/project/java/security/UserDetailsServiceImpl.java.ejs +21 -0
- package/templates/project/java/service/JwtService.java.ejs +81 -0
- package/templates/project/java/service/UserService.java.ejs +32 -0
- package/templates/project/resources/application.yml.ejs +50 -0
- package/templates/project/resources/logback-spring.xml.ejs +41 -0
- package/templates/project/static/admin.html.ejs +163 -0
- package/templates/project/static/assets/app.js.ejs +340 -0
- package/templates/project/static/assets/style.css +533 -0
- package/templates/project/static/css/style.css +595 -0
- package/templates/project/static/dashboard.html.ejs +119 -0
- package/templates/project/static/index.html.ejs +96 -0
- package/templates/project/static/js/api.js +30 -0
- package/templates/project/static/js/auth.js +44 -0
- package/templates/project/static/js/nav.js.ejs +82 -0
- package/templates/project/static/js/ui.js +57 -0
- package/templates/project/static/login.html.ejs +71 -0
- package/templates/project/static/profile.html.ejs +163 -0
- package/templates/project/static/register.html.ejs +82 -0
- package/templates/project/thymeleaf/admin/users.html.ejs +111 -0
- package/templates/project/thymeleaf/dashboard.html.ejs +109 -0
- package/templates/project/thymeleaf/layout.html.ejs +75 -0
- package/templates/project/thymeleaf/login.html.ejs +56 -0
- package/templates/project/thymeleaf/profile.html.ejs +133 -0
- 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,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>
|