feat(user): synced appearance settings

This commit is contained in:
lobo committed 2026-09-24 15:06:16 +02:00
1 parent c9ae7853e5
commit e04fd27b08
16 files changed
+268 -1

No files matched your search

@@ -1,6 +1,7 @@
package auth_service.modules.user.api; package auth_service.modules.user.api;
import auth_service.shared.security.UserPrincipal; import auth_service.shared.security.UserPrincipal;
import auth_service.modules.user.api.dto.request.AppearanceRequest;
import auth_service.modules.user.api.dto.request.UserSettingsRequest; import auth_service.modules.user.api.dto.request.UserSettingsRequest;
import auth_service.modules.user.api.dto.response.UserProfileResponse; import auth_service.modules.user.api.dto.response.UserProfileResponse;
import auth_service.modules.user.api.dto.response.UserSettingsResponse; import auth_service.modules.user.api.dto.response.UserSettingsResponse;
@@ -45,4 +46,11 @@ public class UserController {
ApiMapper.toSteamId(request), request.displayName(), ApiMapper.toDomain(request)); ApiMapper.toSteamId(request), request.displayName(), ApiMapper.toDomain(request));
return ResponseEntity.ok(new UserSettingsResponse(token)); return ResponseEntity.ok(new UserSettingsResponse(token));
} }
@PutMapping(value = "/settings/appearance", version = "1.0")
public ResponseEntity<Void> updateAppearance(@AuthenticationPrincipal UserPrincipal principal,
@Valid @RequestBody AppearanceRequest request) {
this.userService.updateAppearance(principal.getIdAsLong(), ApiMapper.toDomain(request));
return ResponseEntity.noContent().build();
}
} }
@@ -0,0 +1,15 @@
package auth_service.modules.user.api.dto.request;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Positive;
/**
* The frontend has a CSS preset for each allowed name.
*/
public record AppearanceRequest(
@NotNull @Pattern(regexp = "violet|blue|green|orange|pink|red") String accent,
@NotNull @Pattern(regexp = "sharp|default|round") String corners,
Boolean wideLayout,
@Positive Long backgroundAppid) {
}
@@ -0,0 +1,4 @@
package auth_service.modules.user.api.dto.response;
public record AppearanceResponse(String accent, String corners, boolean wideLayout, Long backgroundAppid) {
}
@@ -3,5 +3,6 @@ package auth_service.modules.user.api.dto.response;
import lombok.Builder; import lombok.Builder;
@Builder @Builder
public record SettingsResponse(long id, boolean useDarkTheme, String language, boolean publicProfile) { public record SettingsResponse(long id, boolean useDarkTheme, String language, boolean publicProfile,
AppearanceResponse appearance) {
} }
@@ -1,9 +1,12 @@
package auth_service.modules.user.api.mapper; package auth_service.modules.user.api.mapper;
import auth_service.modules.user.api.dto.request.AppearanceRequest;
import auth_service.modules.user.api.dto.request.UserSettingsRequest; import auth_service.modules.user.api.dto.request.UserSettingsRequest;
import auth_service.modules.user.api.dto.response.AppearanceResponse;
import auth_service.modules.user.api.dto.response.SettingsResponse; import auth_service.modules.user.api.dto.response.SettingsResponse;
import auth_service.modules.user.api.dto.response.UserProfileResponse; import auth_service.modules.user.api.dto.response.UserProfileResponse;
import auth_service.modules.user.model.Appearance;
import auth_service.modules.user.model.User; import auth_service.modules.user.model.User;
import auth_service.modules.user.model.UserSettings; import auth_service.modules.user.model.UserSettings;
@@ -25,10 +28,21 @@ public abstract class ApiMapper {
.useDarkTheme(user.getSettings().getTheme().equals("dark")) .useDarkTheme(user.getSettings().getTheme().equals("dark"))
.language(user.getSettings().getLanguage()) .language(user.getSettings().getLanguage())
.publicProfile(user.getSettings().isPublicProfile()) .publicProfile(user.getSettings().isPublicProfile())
.appearance(toApi(user.getSettings().getAppearance()))
.build()) .build())
.build(); .build();
} }
private static AppearanceResponse toApi(Appearance appearance) {
return new AppearanceResponse(appearance.accent(), appearance.corners(), appearance.wideLayout(),
appearance.backgroundAppid());
}
public static Appearance toDomain(AppearanceRequest request) {
return new Appearance(request.accent(), request.corners(), Boolean.TRUE.equals(request.wideLayout()),
request.backgroundAppid());
}
public static Long toSteamId(UserSettingsRequest request) { public static Long toSteamId(UserSettingsRequest request) {
return request.steamId() == null ? null : Long.valueOf(request.steamId()); return request.steamId() == null ? null : Long.valueOf(request.steamId());
} }
@@ -0,0 +1,9 @@
package auth_service.modules.user.model;
/**
* @param backgroundAppid the Steam app whose art fills the page background; null for none
*/
public record Appearance(String accent, String corners, boolean wideLayout, Long backgroundAppid) {
public static final Appearance DEFAULT = new Appearance("violet", "default", false, null);
}
@@ -9,4 +9,6 @@ public class UserSettings {
private String language; private String language;
private String theme; private String theme;
private boolean publicProfile; private boolean publicProfile;
@Builder.Default
private Appearance appearance = Appearance.DEFAULT;
} }
@@ -1,6 +1,7 @@
package auth_service.modules.user.persistence; package auth_service.modules.user.persistence;
import auth_service.modules.user.model.Appearance;
import auth_service.modules.user.model.User; import auth_service.modules.user.model.User;
import auth_service.modules.user.model.UserSettings; import auth_service.modules.user.model.UserSettings;
import jakarta.transaction.Transactional; import jakarta.transaction.Transactional;
@@ -26,6 +27,9 @@ public interface UserRepository {
@Transactional @Transactional
boolean updateSettings(long userId, Long steamId, String displayName, UserSettings settings); boolean updateSettings(long userId, Long steamId, String displayName, UserSettings settings);
@Transactional
void updateAppearance(long userId, Appearance appearance);
boolean isSteamIdUsedByOtherUser(long userId, long steamId); boolean isSteamIdUsedByOtherUser(long userId, long steamId);
boolean isDisplayNameUsedByOtherUser(long userId, String displayName); boolean isDisplayNameUsedByOtherUser(long userId, String displayName);
@@ -1,6 +1,7 @@
package auth_service.modules.user.persistence; package auth_service.modules.user.persistence;
import auth_service.modules.user.exception.UserNotFoundException; import auth_service.modules.user.exception.UserNotFoundException;
import auth_service.modules.user.model.Appearance;
import auth_service.modules.user.model.User; import auth_service.modules.user.model.User;
import auth_service.modules.user.model.UserSettings; import auth_service.modules.user.model.UserSettings;
import auth_service.modules.user.persistence.entities.UserEntity; import auth_service.modules.user.persistence.entities.UserEntity;
@@ -86,6 +87,30 @@ public class UserRepositoryImpl implements UserRepository {
return steamIdChanged; return steamIdChanged;
} }
@Transactional
@Override
public void updateAppearance(long userId, Appearance appearance) {
var userEntity = entityManager.find(UserEntity.class, userId);
if (userEntity == null) {
throw new UserNotFoundException("Could not find user with id " + userId);
}
var settingsEntity = userEntity.getSettings();
if (settingsEntity == null) {
// Same values as the defaults of the user_settings columns.
settingsEntity = new UserSettingsEntity();
settingsEntity.setUserId(userId);
settingsEntity.setLanguage("");
settingsEntity.setTheme("light");
userEntity.setSettings(settingsEntity);
}
settingsEntity.setAccent(appearance.accent());
settingsEntity.setCorners(appearance.corners());
settingsEntity.setWideLayout(appearance.wideLayout());
settingsEntity.setBackgroundAppid(appearance.backgroundAppid());
}
@Override @Override
public boolean isSteamIdUsedByOtherUser(long userId, long steamId) { public boolean isSteamIdUsedByOtherUser(long userId, long steamId) {
var count = entityManager.createQuery( var count = entityManager.createQuery(
@@ -1,5 +1,6 @@
package auth_service.modules.user.persistence.entities; package auth_service.modules.user.persistence.entities;
import auth_service.modules.user.model.Appearance;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.Id; import jakarta.persistence.Id;
@@ -26,4 +27,17 @@ public class UserSettingsEntity {
@Column(name = "public_profile", nullable = false) @Column(name = "public_profile", nullable = false)
private boolean publicProfile; private boolean publicProfile;
// Java defaults as well: a row created here, not by the migration, must not insert null.
@Column(nullable = false)
private String accent = Appearance.DEFAULT.accent();
@Column(nullable = false)
private String corners = Appearance.DEFAULT.corners();
@Column(name = "wide_layout", nullable = false)
private boolean wideLayout;
@Column(name = "background_appid")
private Long backgroundAppid;
} }
@@ -1,5 +1,6 @@
package auth_service.modules.user.persistence.mapper; package auth_service.modules.user.persistence.mapper;
import auth_service.modules.user.model.Appearance;
import auth_service.modules.user.model.User; import auth_service.modules.user.model.User;
import auth_service.modules.user.model.UserSettings; import auth_service.modules.user.model.UserSettings;
import auth_service.modules.user.persistence.entities.UserEntity; import auth_service.modules.user.persistence.entities.UserEntity;
@@ -22,6 +23,8 @@ public abstract class EntityMapper {
.language(entity.getSettings().getLanguage()) .language(entity.getSettings().getLanguage())
.theme(entity.getSettings().getTheme()) .theme(entity.getSettings().getTheme())
.publicProfile(entity.getSettings().isPublicProfile()) .publicProfile(entity.getSettings().isPublicProfile())
.appearance(new Appearance(entity.getSettings().getAccent(), entity.getSettings().getCorners(),
entity.getSettings().isWideLayout(), entity.getSettings().getBackgroundAppid()))
.build(); .build();
} else { } else {
// Fallback default settings for users without an entry in the database // Fallback default settings for users without an entry in the database
@@ -3,6 +3,7 @@ package auth_service.modules.user.service;
import auth_service.modules.user.exception.DisplayNameAlreadyInUseException; import auth_service.modules.user.exception.DisplayNameAlreadyInUseException;
import auth_service.modules.user.exception.SteamIdAlreadyInUseException; import auth_service.modules.user.exception.SteamIdAlreadyInUseException;
import auth_service.modules.user.exception.UserNotFoundException; import auth_service.modules.user.exception.UserNotFoundException;
import auth_service.modules.user.model.Appearance;
import auth_service.modules.user.model.User; import auth_service.modules.user.model.User;
import auth_service.modules.user.model.UserSettings; import auth_service.modules.user.model.UserSettings;
import auth_service.modules.user.persistence.UserRepository; import auth_service.modules.user.persistence.UserRepository;
@@ -49,4 +50,10 @@ public class UserService {
log.info("User settings updated for user {}", userId); log.info("User settings updated for user {}", userId);
return tokenGenerator.generateToken(String.valueOf(userId), email, steamId); return tokenGenerator.generateToken(String.valueOf(userId), email, steamId);
} }
@Transactional
@CacheEvict(value = "usersByEmail", allEntries = true)
public void updateAppearance(long userId, Appearance appearance) {
userRepository.updateAppearance(userId, appearance);
}
} }
@@ -0,0 +1,6 @@
-- How the frontend looks for the user. The API accepts only known preset names, never raw CSS.
ALTER TABLE user_settings
ADD COLUMN accent VARCHAR(16) NOT NULL DEFAULT 'violet',
ADD COLUMN corners VARCHAR(16) NOT NULL DEFAULT 'default',
ADD COLUMN wide_layout BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN background_appid BIGINT;
@@ -6,6 +6,7 @@ import auth_service.shared.security.UserPrincipal;
import auth_service.modules.user.exception.DisplayNameAlreadyInUseException; import auth_service.modules.user.exception.DisplayNameAlreadyInUseException;
import auth_service.modules.user.exception.SteamIdAlreadyInUseException; import auth_service.modules.user.exception.SteamIdAlreadyInUseException;
import auth_service.modules.user.exception.UserNotFoundException; import auth_service.modules.user.exception.UserNotFoundException;
import auth_service.modules.user.model.Appearance;
import auth_service.modules.user.model.User; import auth_service.modules.user.model.User;
import auth_service.modules.user.model.UserSettings; import auth_service.modules.user.model.UserSettings;
import auth_service.modules.user.service.UserService; import auth_service.modules.user.service.UserService;
@@ -401,4 +402,88 @@ class UserControllerTest extends AbstractWebMvcTest {
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.settings.publicProfile").value(true)); .andExpect(jsonPath("$.settings.publicProfile").value(true));
} }
@Test
void getCurrentUserV1_ShowsAppearance() throws Exception {
var email = "test@example.com";
var settings = UserSettings.builder().language("en").theme("dark")
.appearance(new Appearance("blue", "round", true, 620L)).build();
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(new UserPrincipal("1", email, null), null, Collections.emptyList())
);
when(userService.getUserByEmail(email)).thenReturn(User.builder().email(email).settings(settings).build());
mockMvc.perform(get("/api/users/me").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.settings.appearance.accent").value("blue"))
.andExpect(jsonPath("$.settings.appearance.corners").value("round"))
.andExpect(jsonPath("$.settings.appearance.wideLayout").value(true))
.andExpect(jsonPath("$.settings.appearance.backgroundAppid").value(620));
}
@Test
void getCurrentUserV1_WithoutBackground_SendsNull() throws Exception {
var email = "test@example.com";
var settings = UserSettings.builder().language("en").theme("dark").build();
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(new UserPrincipal("1", email, null), null, Collections.emptyList())
);
when(userService.getUserByEmail(email)).thenReturn(User.builder().email(email).settings(settings).build());
mockMvc.perform(get("/api/users/me").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.settings.appearance.accent").value("violet"))
.andExpect(jsonPath("$.settings.appearance.corners").value("default"))
.andExpect(jsonPath("$.settings.appearance.wideLayout").value(false))
.andExpect(jsonPath("$.settings.appearance.backgroundAppid").isEmpty());
}
@Test
void updateAppearance_StoresTheValues() throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new UserPrincipal("1", "test@example.com", null), null, Collections.emptyList()));
mockMvc.perform(put("/api/users/settings/appearance")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"accent\":\"green\",\"corners\":\"sharp\",\"wideLayout\":true,\"backgroundAppid\":620}"))
.andExpect(status().isNoContent());
verify(userService).updateAppearance(1L, new Appearance("green", "sharp", true, 620L));
}
@Test
void updateAppearance_MissingWideLayoutAndBackground_AreOffAndNone() throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new UserPrincipal("1", "test@example.com", null), null, Collections.emptyList()));
mockMvc.perform(put("/api/users/settings/appearance")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"accent\":\"violet\",\"corners\":\"default\"}"))
.andExpect(status().isNoContent());
verify(userService).updateAppearance(1L, new Appearance("violet", "default", false, null));
}
// Each body differs from a valid one in one field only, so the 400 comes from that field's constraint.
@ParameterizedTest
@ValueSource(strings = {
"{\"accent\":\"hotpink\",\"corners\":\"default\"}",
"{\"accent\":\"red;background:url(x)\",\"corners\":\"default\"}",
"{\"corners\":\"default\"}",
"{\"accent\":\"violet\",\"corners\":\"pointy\"}",
"{\"accent\":\"violet\"}",
"{\"accent\":\"violet\",\"corners\":\"default\",\"backgroundAppid\":0}",
"{\"accent\":\"violet\",\"corners\":\"default\",\"backgroundAppid\":-620}"
})
void updateAppearance_UnknownOrMissingValue_IsRejected(String body) throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new UserPrincipal("1", "test@example.com", null), null, Collections.emptyList()));
mockMvc.perform(put("/api/users/settings/appearance")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest());
verifyNoInteractions(userService);
}
} }
@@ -1,6 +1,7 @@
package auth_service.modules.user.persistence; package auth_service.modules.user.persistence;
import auth_service.modules.user.exception.UserNotFoundException; import auth_service.modules.user.exception.UserNotFoundException;
import auth_service.modules.user.model.Appearance;
import auth_service.modules.user.model.UserSettings; import auth_service.modules.user.model.UserSettings;
import auth_service.modules.user.persistence.entities.UserEntity; import auth_service.modules.user.persistence.entities.UserEntity;
import auth_service.shared.test.AbstractPersistenceTest; import auth_service.shared.test.AbstractPersistenceTest;
@@ -198,4 +199,62 @@ class UserRepositoryTest extends AbstractPersistenceTest {
assertTrue(userRepository.findByEmail("public@example.com").orElseThrow().getSettings().isPublicProfile()); assertTrue(userRepository.findByEmail("public@example.com").orElseThrow().getSettings().isPublicProfile());
} }
@Test
void updateAppearance_WithoutSettingsRow_CreatesItAndFindByEmailReadsIt() {
var userEntity = entityManager.persist(UserEntity.builder()
.email("appearance@example.com")
.password("password")
.enabled(true)
.registrationDate(LocalDateTime.now())
.build());
entityManager.flush();
userRepository.updateAppearance(userEntity.getId(), new Appearance("orange", "round", true, 620L));
entityManager.flush();
entityManager.clear();
var settings = userRepository.findByEmail("appearance@example.com").orElseThrow().getSettings();
assertEquals(new Appearance("orange", "round", true, 620L), settings.getAppearance());
}
@Test
void updateSettings_KeepsTheAppearance() {
var userEntity = entityManager.persist(UserEntity.builder()
.email("keep@example.com")
.password("password")
.enabled(true)
.registrationDate(LocalDateTime.now())
.build());
entityManager.flush();
userRepository.updateAppearance(userEntity.getId(), new Appearance("pink", "sharp", false, 220L));
entityManager.flush();
userRepository.updateSettings(userEntity.getId(), null, null,
UserSettings.builder().language("de").theme("dark").build());
entityManager.flush();
entityManager.clear();
var settings = userRepository.findByEmail("keep@example.com").orElseThrow().getSettings();
assertEquals(new Appearance("pink", "sharp", false, 220L), settings.getAppearance());
}
@Test
void findByEmail_WithoutSettingsRow_HasTheDefaultAppearance() {
entityManager.persist(UserEntity.builder()
.email("defaults@example.com")
.password("password")
.enabled(true)
.registrationDate(LocalDateTime.now())
.build());
entityManager.flush();
assertEquals(Appearance.DEFAULT, userRepository.findByEmail("defaults@example.com").orElseThrow()
.getSettings().getAppearance());
}
@Test
void updateAppearance_UnknownUser() {
assertThrows(UserNotFoundException.class, () -> userRepository.updateAppearance(-1L, Appearance.DEFAULT));
}
} }
@@ -3,6 +3,7 @@ package auth_service.modules.user.service;
import auth_service.modules.user.exception.DisplayNameAlreadyInUseException; import auth_service.modules.user.exception.DisplayNameAlreadyInUseException;
import auth_service.modules.user.exception.SteamIdAlreadyInUseException; import auth_service.modules.user.exception.SteamIdAlreadyInUseException;
import auth_service.modules.user.exception.UserNotFoundException; import auth_service.modules.user.exception.UserNotFoundException;
import auth_service.modules.user.model.Appearance;
import auth_service.modules.user.model.User; import auth_service.modules.user.model.User;
import auth_service.modules.user.model.UserSettings; import auth_service.modules.user.model.UserSettings;
import auth_service.modules.user.persistence.UserRepository; import auth_service.modules.user.persistence.UserRepository;
@@ -131,4 +132,14 @@ class UserServiceTest extends AbstractUnitTest {
// Assert // Assert
verify(userRepository).updateSettings(1L, null, "Lobo", userSettings); verify(userRepository).updateSettings(1L, null, "Lobo", userSettings);
} }
@Test
void updateAppearance_StoresItWithoutANewToken() {
var appearance = new Appearance("red", "round", false, null);
userService.updateAppearance(1L, appearance);
verify(userRepository).updateAppearance(1L, appearance);
verifyNoInteractions(tokenGenerator);
}
} }