feat(admin): add OIDC administration usage slice
This commit is contained in:
1 parent
69ee90a530
commit
13826d60da
36 files changed
+719
-14
No files matched your search
@@ -4,6 +4,7 @@ MONGO_ROOT_PASSWORD=change-me-to-a-long-random-url-safe-password
|
||||
OIDC_ISSUER_URI=https://identity.example.com/realms/lmnp
|
||||
OIDC_CLIENT_ID=lmnp-client
|
||||
OIDC_CLIENT_SECRET=replace-with-your-oidc-client-secret
|
||||
OIDC_ADMIN_ROLE=admin
|
||||
|
||||
# Development-only host ports used by compose.dev.yml.
|
||||
BACKEND_PORT=8080
|
||||
|
||||
@@ -141,6 +141,8 @@ MONGO_ROOT_PASSWORD=votre-mot-de-passe-securise-url-safe
|
||||
OIDC_ISSUER_URI=http://localhost:8090/realms/voidAuth
|
||||
OIDC_CLIENT_ID=lmnp-client
|
||||
OIDC_CLIENT_SECRET=votre-secret-client-oidc
|
||||
# Autorité OIDC qui accorde la capacité Administrateur
|
||||
OIDC_ADMIN_ROLE=admin
|
||||
|
||||
# Ports de développement (pour compose.dev.yml)
|
||||
BACKEND_PORT=8080
|
||||
|
||||
@@ -8,7 +8,8 @@ import org.springframework.boot.persistence.autoconfigure.EntityScan;
|
||||
@EntityScan(basePackages = {"fr.nessar.lmnporganizer.rental.adapter.out.mongodb",
|
||||
"fr.nessar.lmnporganizer.accounting.adapter.out.mongodb"})
|
||||
@SpringBootApplication(scanBasePackages = {"fr.nessar.lmnporganizer.rental", "fr.nessar.lmnporganizer.accounting",
|
||||
"fr.nessar.lmnporganizer.identity"}, exclude = DataMongoRepositoriesAutoConfiguration.class)
|
||||
"fr.nessar.lmnporganizer.identity",
|
||||
"fr.nessar.lmnporganizer.administration"}, exclude = DataMongoRepositoriesAutoConfiguration.class)
|
||||
public class LmnpOrganizerApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(LmnpOrganizerApplication.class, args);
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package fr.nessar.lmnporganizer.administration.adapter.in.web;
|
||||
|
||||
import fr.nessar.lmnporganizer.administration.application.AdministrationUsageService;
|
||||
import java.time.Instant;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin")
|
||||
public class AdministrationController {
|
||||
private final AdministrationUsageService usage;
|
||||
|
||||
public AdministrationController(AdministrationUsageService usage) {
|
||||
this.usage = usage;
|
||||
}
|
||||
|
||||
@GetMapping("/usage")
|
||||
UsageResponse usage() {
|
||||
var snapshot = usage.currentSnapshot();
|
||||
return new UsageResponse(snapshot.apartmentCount(), snapshot.snapshotAt());
|
||||
}
|
||||
|
||||
record UsageResponse(long apartmentCount, Instant snapshotAt) {
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package fr.nessar.lmnporganizer.administration.adapter.out.mongodb;
|
||||
|
||||
import fr.nessar.lmnporganizer.administration.application.port.out.AdministrationUsageRepository;
|
||||
import org.springframework.data.mongodb.core.MongoTemplate;
|
||||
import org.springframework.data.mongodb.core.query.Query;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public class MongoAdministrationUsageRepository implements AdministrationUsageRepository {
|
||||
private static final String APARTMENTS_COLLECTION = "apartments";
|
||||
|
||||
private final MongoTemplate mongo;
|
||||
|
||||
public MongoAdministrationUsageRepository(MongoTemplate mongo) {
|
||||
this.mongo = mongo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long countApartments() {
|
||||
return mongo.count(new Query(), APARTMENTS_COLLECTION);
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package fr.nessar.lmnporganizer.administration.application;
|
||||
|
||||
import fr.nessar.lmnporganizer.administration.application.port.out.AdministrationUsageRepository;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class AdministrationUsageService {
|
||||
private final AdministrationUsageRepository repository;
|
||||
private final Clock clock;
|
||||
|
||||
public AdministrationUsageService(AdministrationUsageRepository repository, Clock clock) {
|
||||
this.repository = repository;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public Usage currentSnapshot() {
|
||||
return new Usage(repository.countApartments(), Instant.now(clock));
|
||||
}
|
||||
|
||||
public record Usage(long apartmentCount, Instant snapshotAt) {
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package fr.nessar.lmnporganizer.administration.application.port.out;
|
||||
|
||||
public interface AdministrationUsageRepository {
|
||||
long countApartments();
|
||||
}
|
||||
+5
-2
@@ -1,5 +1,6 @@
|
||||
package fr.nessar.lmnporganizer.identity.adapter.in.security;
|
||||
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -11,11 +12,13 @@ import org.springframework.security.web.servlet.util.matcher.PathPatternRequestM
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@EnableConfigurationProperties(SecurityProperties.class)
|
||||
public class SecurityConfiguration {
|
||||
@Bean
|
||||
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
SecurityFilterChain securityFilterChain(HttpSecurity http, SecurityProperties properties) throws Exception {
|
||||
http.authorizeHttpRequests(authorize -> authorize.requestMatchers("/api/user").permitAll()
|
||||
.requestMatchers("/api/**").authenticated().anyRequest().permitAll())
|
||||
.requestMatchers("/api/admin/**").hasAuthority(properties.adminRole()).requestMatchers("/api/**")
|
||||
.authenticated().anyRequest().permitAll())
|
||||
.exceptionHandling(exceptions -> exceptions.defaultAuthenticationEntryPointFor(
|
||||
new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED),
|
||||
PathPatternRequestMatcher.pathPattern("/api/**")))
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package fr.nessar.lmnporganizer.identity.adapter.in.security;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@ConfigurationProperties("lmnp.security")
|
||||
public record SecurityProperties(String adminRole) {
|
||||
public SecurityProperties {
|
||||
if (adminRole == null || adminRole.isBlank()) {
|
||||
throw new IllegalArgumentException("The administrator authority is required.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
-3
@@ -1,5 +1,6 @@
|
||||
package fr.nessar.lmnporganizer.identity.adapter.in.web;
|
||||
|
||||
import fr.nessar.lmnporganizer.identity.adapter.in.security.SecurityProperties;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
@@ -10,15 +11,23 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
@RestController
|
||||
@RequestMapping("/api")
|
||||
public class SessionController {
|
||||
private final SecurityProperties securityProperties;
|
||||
|
||||
public SessionController(SecurityProperties securityProperties) {
|
||||
this.securityProperties = securityProperties;
|
||||
}
|
||||
|
||||
@GetMapping("/user")
|
||||
Session user(@AuthenticationPrincipal OidcUser principal) {
|
||||
if (principal == null) {
|
||||
return new Session(false, null, null);
|
||||
return new Session(false, null, null, false);
|
||||
}
|
||||
String username = principal.getPreferredUsername() == null
|
||||
? principal.getName()
|
||||
: principal.getPreferredUsername();
|
||||
return new Session(true, username, principal.getEmail() == null ? "" : principal.getEmail());
|
||||
boolean administrator = principal.getAuthorities().stream()
|
||||
.anyMatch(authority -> securityProperties.adminRole().equals(authority.getAuthority()));
|
||||
return new Session(true, username, principal.getEmail() == null ? "" : principal.getEmail(), administrator);
|
||||
}
|
||||
|
||||
@GetMapping("/hello")
|
||||
@@ -30,6 +39,6 @@ public class SessionController {
|
||||
return "Hello, " + (name == null ? principal.getName() : name) + "!";
|
||||
}
|
||||
|
||||
record Session(boolean authenticated, String username, String email) {
|
||||
record Session(boolean authenticated, String username, String email, boolean administrator) {
|
||||
}
|
||||
}
|
||||
@@ -25,3 +25,4 @@ spring.security.oauth2.client.registration.oidc.redirect-uri={baseUrl}/login/oau
|
||||
# OIDC Provider - Spring auto-discovers all endpoints via .well-known/openid-configuration
|
||||
spring.security.oauth2.client.provider.oidc.issuer-uri=${OIDC_ISSUER_URI:http://localhost:8090/realms/voidAuth}
|
||||
spring.security.oauth2.client.provider.oidc.user-name-attribute=preferred_username
|
||||
lmnp.security.admin-role=${OIDC_ADMIN_ROLE:admin}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package fr.nessar.lmnporganizer.administration.adapter.in.web;
|
||||
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oidcLogin;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import fr.nessar.lmnporganizer.LmnpOrganizerApplication;
|
||||
import fr.nessar.lmnporganizer.administration.application.AdministrationUsageService;
|
||||
import fr.nessar.lmnporganizer.identity.adapter.in.security.SecurityConfiguration;
|
||||
import java.time.Instant;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.test.context.ContextConfiguration;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
@WebMvcTest(controllers = AdministrationController.class)
|
||||
@ContextConfiguration(classes = LmnpOrganizerApplication.class)
|
||||
@Import(SecurityConfiguration.class)
|
||||
@TestPropertySource(properties = "lmnp.security.admin-role=admin")
|
||||
class AdministrationControllerWebTest {
|
||||
@Autowired
|
||||
MockMvc mockMvc;
|
||||
|
||||
@MockitoBean
|
||||
AdministrationUsageService usage;
|
||||
|
||||
@Test
|
||||
void returnsTheInstallationApartmentCountToAnAdministrator() throws Exception {
|
||||
when(usage.currentSnapshot())
|
||||
.thenReturn(new AdministrationUsageService.Usage(4, Instant.parse("2026-08-22T11:00:00Z")));
|
||||
|
||||
mockMvc.perform(get("/api/admin/usage").with(oidcLogin().authorities(new SimpleGrantedAuthority("admin"))))
|
||||
.andExpect(status().isOk()).andExpect(jsonPath("$.apartmentCount").value(4))
|
||||
.andExpect(jsonPath("$.snapshotAt").value("2026-08-22T11:00:00Z"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsARegularLandlord() throws Exception {
|
||||
mockMvc.perform(get("/api/admin/usage").with(oidcLogin().authorities(new SimpleGrantedAuthority("user"))))
|
||||
.andExpect(status().isForbidden());
|
||||
|
||||
verifyNoInteractions(usage);
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsTheProtectedApiUnauthenticatedForAnonymousRequests() throws Exception {
|
||||
mockMvc.perform(get("/api/admin/usage")).andExpect(status().isUnauthorized());
|
||||
|
||||
verifyNoInteractions(usage);
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package fr.nessar.lmnporganizer.administration.adapter.out.mongodb;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.data.mongodb.core.MongoTemplate;
|
||||
import org.springframework.data.mongodb.core.query.Query;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class MongoAdministrationUsageRepositoryTest {
|
||||
@Mock
|
||||
MongoTemplate mongo;
|
||||
|
||||
@Test
|
||||
void countsEveryPersistedApartmentWithoutAlandlordFilter() {
|
||||
when(mongo.count(any(Query.class), eq("apartments"))).thenReturn(4L);
|
||||
|
||||
var repository = new MongoAdministrationUsageRepository(mongo);
|
||||
|
||||
assertThat(repository.countApartments()).isEqualTo(4);
|
||||
|
||||
ArgumentCaptor<Query> query = ArgumentCaptor.forClass(Query.class);
|
||||
verify(mongo).count(query.capture(), eq("apartments"));
|
||||
assertThat(query.getValue().getQueryObject()).isEmpty();
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package fr.nessar.lmnporganizer.administration.application;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import fr.nessar.lmnporganizer.administration.application.port.out.AdministrationUsageRepository;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class AdministrationUsageServiceTest {
|
||||
@Test
|
||||
void returnsTheApartmentCountWithTheRequestSnapshotTime() {
|
||||
var repository = mock(AdministrationUsageRepository.class);
|
||||
when(repository.countApartments()).thenReturn(3L);
|
||||
var clock = Clock.fixed(Instant.parse("2026-08-22T11:00:00Z"), ZoneOffset.UTC);
|
||||
|
||||
var usage = new AdministrationUsageService(repository, clock).currentSnapshot();
|
||||
|
||||
assertThat(usage.apartmentCount()).isEqualTo(3);
|
||||
assertThat(usage.snapshotAt()).isEqualTo(Instant.parse("2026-08-22T11:00:00Z"));
|
||||
}
|
||||
}
|
||||
+19
-2
@@ -5,6 +5,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oidcLogin;
|
||||
|
||||
import fr.nessar.lmnporganizer.LmnpOrganizerApplication;
|
||||
import fr.nessar.lmnporganizer.identity.adapter.in.web.SessionController;
|
||||
@@ -13,6 +14,7 @@ import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.test.context.ContextConfiguration;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
@@ -20,7 +22,9 @@ import org.springframework.test.web.servlet.MockMvc;
|
||||
@WebMvcTest(SessionController.class)
|
||||
@ContextConfiguration(classes = LmnpOrganizerApplication.class)
|
||||
@Import(SecurityConfiguration.class)
|
||||
@TestPropertySource(properties = "spring.security.oauth2.client.registration.oidc.redirect-uri={baseUrl}/login/oauth2/code/{registrationId}")
|
||||
@TestPropertySource(properties = {
|
||||
"spring.security.oauth2.client.registration.oidc.redirect-uri={baseUrl}/login/oauth2/code/{registrationId}",
|
||||
"OIDC_ADMIN_ROLE=admin", "lmnp.security.admin-role=admin"})
|
||||
class SecurityConfigurationWebTest {
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
@@ -28,7 +32,20 @@ class SecurityConfigurationWebTest {
|
||||
@Test
|
||||
void anonymousSessionBootstrapProvidesTheAngularCsrfCookie() throws Exception {
|
||||
mockMvc.perform(get("/api/user")).andExpect(status().isOk()).andExpect(jsonPath("$.authenticated").value(false))
|
||||
.andExpect(cookie().exists("XSRF-TOKEN"));
|
||||
.andExpect(jsonPath("$.administrator").value(false)).andExpect(cookie().exists("XSRF-TOKEN"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sessionReportsTheServerDerivedAdministratorFlag() throws Exception {
|
||||
mockMvc.perform(get("/api/user").with(oidcLogin().authorities(new SimpleGrantedAuthority("admin"))))
|
||||
.andExpect(status().isOk()).andExpect(jsonPath("$.authenticated").value(true))
|
||||
.andExpect(jsonPath("$.administrator").value(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void regularSessionDoesNotReceiveTheAdministratorFlag() throws Exception {
|
||||
mockMvc.perform(get("/api/user").with(oidcLogin().authorities(new SimpleGrantedAuthority("user"))))
|
||||
.andExpect(status().isOk()).andExpect(jsonPath("$.administrator").value(false));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -10,6 +10,7 @@ spring.security.oauth2.client.provider.oidc.token-uri=http://localhost:8090/real
|
||||
spring.security.oauth2.client.provider.oidc.user-info-uri=http://localhost:8090/realms/test/protocol/openid-connect/userinfo
|
||||
spring.security.oauth2.client.provider.oidc.jwk-set-uri=http://localhost:8090/realms/test/protocol/openid-connect/certs
|
||||
spring.security.oauth2.client.provider.oidc.user-name-attribute=preferred_username
|
||||
lmnp.security.admin-role=${OIDC_ADMIN_ROLE:admin}
|
||||
spring.data.mongodb.auto-index-creation=false
|
||||
lmnp.storage.attachments=${java.io.tmpdir}/lmnp-organizer-test/attachments
|
||||
lmnp.attachments.max-size=250MB
|
||||
|
||||
@@ -51,6 +51,7 @@ services:
|
||||
OIDC_ISSUER_URI: ${OIDC_ISSUER_URI:?Set OIDC_ISSUER_URI}
|
||||
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:?Set OIDC_CLIENT_ID}
|
||||
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:?Set OIDC_CLIENT_SECRET}
|
||||
OIDC_ADMIN_ROLE: ${OIDC_ADMIN_ROLE:-admin}
|
||||
LMNP_ATTACHMENT_STORAGE_PATH: /data/attachments
|
||||
JAVA_TOOL_OPTIONS: -XX:MaxRAMPercentage=55
|
||||
volumes:
|
||||
|
||||
@@ -79,6 +79,7 @@ services:
|
||||
OIDC_ISSUER_URI: http://dex:5556/dex
|
||||
OIDC_CLIENT_ID: lmnp-client
|
||||
OIDC_CLIENT_SECRET: lmnp-secret
|
||||
OIDC_ADMIN_ROLE: admin
|
||||
SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_OIDC_USER_NAME_ATTRIBUTE: sub
|
||||
LMNP_ATTACHMENT_STORAGE_PATH: /data/attachments
|
||||
INSEE_IRL_BASE_URI: http://insee:8080/series/
|
||||
|
||||
@@ -47,6 +47,7 @@ services:
|
||||
OIDC_ISSUER_URI: ${OIDC_ISSUER_URI:?Set OIDC_ISSUER_URI}
|
||||
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:?Set OIDC_CLIENT_ID}
|
||||
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:?Set OIDC_CLIENT_SECRET}
|
||||
OIDC_ADMIN_ROLE: ${OIDC_ADMIN_ROLE:-admin}
|
||||
LMNP_ATTACHMENT_STORAGE_PATH: /data/attachments
|
||||
JAVA_TOOL_OPTIONS: -XX:MaxRAMPercentage=55
|
||||
volumes:
|
||||
|
||||
@@ -9,6 +9,15 @@ give immediate, actionable guidance. The implementation uses the existing Jakart
|
||||
domain rules, Angular forms, and `SaveFailure` contract; it does not add a generated schema, shared
|
||||
validation framework, state-management mechanism, or dependency.
|
||||
|
||||
## Administration usage API
|
||||
|
||||
- **Read-only query**: `GET /api/admin/usage`
|
||||
- **Authorization**: requires the server-derived `administrator` session authority; regular
|
||||
authenticated users receive HTTP 403 and anonymous requests retain the protected-API HTTP 401.
|
||||
- **Success response**: `{ "apartmentCount": number, "snapshotAt": ISO-8601 timestamp }`.
|
||||
- **Angular**: `AdministrationService`, `AdministrationPage`; the route and navigation use only
|
||||
the server-reported session flag and expose loading, success, refresh, and error states.
|
||||
|
||||
## Shared failure and interaction contract
|
||||
|
||||
- Request validation failures use HTTP 400 and
|
||||
|
||||
@@ -20,12 +20,20 @@ describe('routes', () => {
|
||||
'expenses',
|
||||
'tax/:year',
|
||||
'tax',
|
||||
'admin',
|
||||
'settings',
|
||||
'',
|
||||
'**',
|
||||
]);
|
||||
});
|
||||
|
||||
it('protects the lazy administration route with a route guard', () => {
|
||||
const adminRoute = routes.find((route) => route.path === 'admin');
|
||||
|
||||
expect(adminRoute?.canMatch).toHaveLength(1);
|
||||
expect(typeof adminRoute?.loadComponent).toBe('function');
|
||||
});
|
||||
|
||||
it('lazy-loads every workspace page and amortization child route', async () => {
|
||||
const workspaceRoutes = routes.filter((route) => route.path !== '' && route.path !== '**');
|
||||
expect(workspaceRoutes.every((route) => route.component === undefined)).toBe(true);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { administratorGuard } from './services/administrator.guard';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
@@ -105,6 +106,14 @@ export const routes: Routes = [
|
||||
loadComponent: () =>
|
||||
import('./components/tax-page/tax-page').then(({ TaxPage }) => TaxPage),
|
||||
},
|
||||
{
|
||||
path: 'admin',
|
||||
canMatch: [administratorGuard],
|
||||
loadComponent: () =>
|
||||
import('./components/administration-page/administration-page').then(
|
||||
({ AdministrationPage }) => AdministrationPage,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
loadComponent: () =>
|
||||
|
||||
@@ -16,6 +16,7 @@ describe('App', () => {
|
||||
const clearListeners = new Set<() => void>();
|
||||
const status = signal<SessionStatus>('checking');
|
||||
const isLoggedIn = signal(false);
|
||||
const isAdministrator = signal(false);
|
||||
const username = signal('');
|
||||
const email = signal('');
|
||||
const session = {
|
||||
@@ -23,10 +24,12 @@ describe('App', () => {
|
||||
isLoggedIn,
|
||||
username,
|
||||
email,
|
||||
isAdministrator,
|
||||
refresh: vi.fn(),
|
||||
clear: vi.fn(() => {
|
||||
status.set('anonymous');
|
||||
isLoggedIn.set(false);
|
||||
isAdministrator.set(false);
|
||||
username.set('');
|
||||
email.set('');
|
||||
clearListeners.forEach((listener) => listener());
|
||||
@@ -43,6 +46,7 @@ describe('App', () => {
|
||||
session.isLoggedIn.set(false);
|
||||
session.username.set('');
|
||||
session.email.set('');
|
||||
isAdministrator.set(false);
|
||||
vi.clearAllMocks();
|
||||
landlordProfile.getProfile.mockReturnValue(
|
||||
of({
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<section class="page-section administration-page">
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Installation</p>
|
||||
<h1>Administration</h1>
|
||||
<p>Consultez l’utilisation technique et fonctionnelle de l’installation.</p>
|
||||
</div>
|
||||
@if (loaded()) {
|
||||
<button mat-stroked-button type="button" [disabled]="loading()" (click)="load()">
|
||||
Actualiser
|
||||
</button>
|
||||
}
|
||||
</header>
|
||||
|
||||
@if (!loaded() && loading()) {
|
||||
<div class="initial-loading" role="status" aria-live="polite">
|
||||
<mat-spinner diameter="34" />
|
||||
<p>Chargement de l’utilisation de l’application…</p>
|
||||
<div class="skeleton-grid" aria-hidden="true"><span class="skeleton-card"></span></div>
|
||||
</div>
|
||||
} @else if (!loaded() && error()) {
|
||||
<article class="load-error" role="alert">
|
||||
<p>{{ error() }}</p>
|
||||
<button mat-stroked-button type="button" (click)="load()">Réessayer</button>
|
||||
</article>
|
||||
} @else if (snapshot(); as current) {
|
||||
@if (loading()) {
|
||||
<mat-progress-bar mode="indeterminate" aria-label="Actualisation de l’utilisation" />
|
||||
<p class="visually-hidden" role="status">Actualisation de l’utilisation…</p>
|
||||
}
|
||||
@if (error()) {
|
||||
<article class="refresh-error" role="alert">
|
||||
<p>{{ error() }}</p>
|
||||
<button mat-button type="button" (click)="load()">Réessayer</button>
|
||||
</article>
|
||||
}
|
||||
|
||||
<div class="usage-grid" [attr.aria-busy]="loading()">
|
||||
<article class="feature-card usage-card" aria-labelledby="apartment-usage-title">
|
||||
<p class="eyebrow">Usage fonctionnel</p>
|
||||
<h2 id="apartment-usage-title">Appartements persistés</h2>
|
||||
<strong class="usage-count">{{ current.apartmentCount }}</strong>
|
||||
<p>Nombre actuel d’Appartements enregistrés dans toute l’installation.</p>
|
||||
</article>
|
||||
</div>
|
||||
<p class="snapshot-time">
|
||||
Instantané demandé le
|
||||
<time [attr.datetime]="current.snapshotAt">{{ snapshotLabel() }}</time>
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
@@ -0,0 +1,64 @@
|
||||
@use '../../../tokens' as tokens;
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.administration-page {
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
.initial-loading {
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
gap: tokens.space(4);
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton-grid {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.usage-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: tokens.space(4);
|
||||
}
|
||||
|
||||
.usage-card {
|
||||
display: grid;
|
||||
gap: tokens.space(2);
|
||||
max-width: 420px;
|
||||
|
||||
h2,
|
||||
p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
p:last-child {
|
||||
color: var(--text-muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
}
|
||||
|
||||
.usage-count {
|
||||
font-size: clamp(2.7rem, 10vw, 4.5rem);
|
||||
letter-spacing: -0.07em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.snapshot-time {
|
||||
margin-top: tokens.space(4);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { of, Subject, throwError } from 'rxjs';
|
||||
import { vi } from 'vitest';
|
||||
import { AdministrationService, AdministrationUsage } from '../../services/administration.service';
|
||||
import { AdministrationPage } from './administration-page';
|
||||
|
||||
describe('AdministrationPage', () => {
|
||||
const usage = { getUsage: vi.fn() };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideRouter([]), { provide: AdministrationService, useValue: usage }],
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the count unknown while loading and displays a successful snapshot', () => {
|
||||
const response = new Subject<AdministrationUsage>();
|
||||
usage.getUsage.mockReturnValueOnce(response);
|
||||
const fixture = TestBed.createComponent(AdministrationPage);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('Chargement de l’utilisation');
|
||||
expect(fixture.nativeElement.querySelector('.usage-count')).toBeNull();
|
||||
|
||||
response.next({ apartmentCount: 4, snapshotAt: '2026-08-22T11:00:00Z' });
|
||||
response.complete();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('.usage-count').textContent.trim()).toBe('4');
|
||||
expect(fixture.nativeElement.querySelector('time').getAttribute('datetime')).toBe(
|
||||
'2026-08-22T11:00:00Z',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows an actionable error instead of a zero count when loading fails', () => {
|
||||
usage.getUsage.mockReturnValueOnce(throwError(() => new Error('offline')));
|
||||
const fixture = TestBed.createComponent(AdministrationPage);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('[role="alert"]')).not.toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('.usage-count')).toBeNull();
|
||||
expect(fixture.nativeElement.textContent).toContain('Réessayer');
|
||||
});
|
||||
|
||||
it('keeps the last successful count visible while a refresh is pending', () => {
|
||||
usage.getUsage.mockReturnValueOnce(
|
||||
of({ apartmentCount: 2, snapshotAt: '2026-08-22T11:00:00Z' }),
|
||||
);
|
||||
const fixture = TestBed.createComponent(AdministrationPage);
|
||||
fixture.detectChanges();
|
||||
const response = new Subject<AdministrationUsage>();
|
||||
usage.getUsage.mockReturnValueOnce(response);
|
||||
|
||||
fixture.componentInstance.load();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('.usage-count').textContent.trim()).toBe('2');
|
||||
expect(fixture.nativeElement.textContent).toContain('Actualisation');
|
||||
|
||||
response.error(new Error('offline'));
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('[role="alert"]')).not.toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('.usage-count').textContent.trim()).toBe('2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Component, computed, DestroyRef, inject, signal } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { finalize } from 'rxjs';
|
||||
import { AdministrationService, AdministrationUsage } from '../../services/administration.service';
|
||||
import { httpErrorMessage } from '../../services/http-error-message';
|
||||
|
||||
const snapshotFormatter = new Intl.DateTimeFormat('fr-FR', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
timeZone: 'Europe/Paris',
|
||||
});
|
||||
|
||||
@Component({
|
||||
selector: 'app-administration-page',
|
||||
imports: [MatButtonModule, MatProgressBarModule, MatProgressSpinnerModule],
|
||||
templateUrl: './administration-page.html',
|
||||
styleUrl: './administration-page.scss',
|
||||
})
|
||||
export class AdministrationPage {
|
||||
private readonly administration = inject(AdministrationService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
readonly snapshot = signal<AdministrationUsage | null>(null);
|
||||
readonly loading = signal(false);
|
||||
readonly loaded = signal(false);
|
||||
readonly error = signal('');
|
||||
readonly snapshotLabel = computed(() => {
|
||||
const value = this.snapshot();
|
||||
return value ? snapshotFormatter.format(new Date(value.snapshotAt)) : '';
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
if (this.loading()) return;
|
||||
this.loading.set(true);
|
||||
this.error.set('');
|
||||
this.administration
|
||||
.getUsage()
|
||||
.pipe(
|
||||
finalize(() => this.loading.set(false)),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe({
|
||||
next: (snapshot) => {
|
||||
this.snapshot.set(snapshot);
|
||||
this.loaded.set(true);
|
||||
},
|
||||
error: (error) =>
|
||||
this.error.set(
|
||||
httpErrorMessage(
|
||||
error,
|
||||
"Impossible de charger l'utilisation de l'application. Réessayez.",
|
||||
),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
</div>
|
||||
|
||||
<nav class="navigation-items" aria-label="Navigation principale">
|
||||
@for (item of navigationItems; track item.route) {
|
||||
@for (item of visibleNavigationItems(); track item.route) {
|
||||
<a
|
||||
mat-button
|
||||
class="navigation-item"
|
||||
@@ -42,7 +42,7 @@
|
||||
</aside>
|
||||
|
||||
<mat-menu #mobileMoreMenu="matMenu" aria-label="Autres sections">
|
||||
@for (item of navigationItems; track item.route) {
|
||||
@for (item of visibleNavigationItems(); track item.route) {
|
||||
@if (!item.mobilePrimary) {
|
||||
<a
|
||||
mat-menu-item
|
||||
|
||||
@@ -8,9 +8,11 @@ describe('WorkspaceNavigation', () => {
|
||||
const session = {
|
||||
username: signal('Camille'),
|
||||
email: signal('camille@example.com'),
|
||||
isAdministrator: signal(false),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
session.isAdministrator.set(false);
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideRouter([]), { provide: SessionService, useValue: session }],
|
||||
});
|
||||
@@ -52,4 +54,21 @@ describe('WorkspaceNavigation', () => {
|
||||
|
||||
trigger.click();
|
||||
});
|
||||
|
||||
it('shows the administration destination only for server-reported administrators', async () => {
|
||||
session.isAdministrator.set(true);
|
||||
const fixture = TestBed.createComponent(WorkspaceNavigation);
|
||||
fixture.componentRef.setInput('userInitial', 'C');
|
||||
fixture.detectChanges();
|
||||
|
||||
const trigger = fixture.nativeElement.querySelector(
|
||||
'button[aria-label="Afficher les autres sections"]',
|
||||
) as HTMLButtonElement;
|
||||
trigger.click();
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(document.body.textContent).toContain('Administration');
|
||||
trigger.click();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, inject, input, output } from '@angular/core';
|
||||
import { Component, computed, inject, input, output } from '@angular/core';
|
||||
import {
|
||||
faBuilding,
|
||||
faChartLine,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
faGear,
|
||||
faLandmark,
|
||||
faScaleBalanced,
|
||||
faShieldHalved,
|
||||
faReceipt,
|
||||
faTableCellsLarge,
|
||||
faUsers,
|
||||
@@ -36,6 +37,7 @@ interface NavigationItem {
|
||||
})
|
||||
export class WorkspaceNavigation {
|
||||
readonly faEllipsis = faEllipsis;
|
||||
readonly session = inject(SessionService);
|
||||
readonly navigationItems: readonly NavigationItem[] = [
|
||||
{
|
||||
label: 'Tableau de bord',
|
||||
@@ -58,7 +60,17 @@ export class WorkspaceNavigation {
|
||||
{ label: 'Fiscalité', route: '/tax', icon: faScaleBalanced, mobilePrimary: false },
|
||||
{ label: 'Réglages', route: '/settings', icon: faGear, mobilePrimary: false },
|
||||
];
|
||||
private readonly administratorItem: NavigationItem = {
|
||||
label: 'Administration',
|
||||
route: '/admin',
|
||||
icon: faShieldHalved,
|
||||
mobilePrimary: false,
|
||||
};
|
||||
readonly visibleNavigationItems = computed(() =>
|
||||
this.session.isAdministrator()
|
||||
? [...this.navigationItems, this.administratorItem]
|
||||
: this.navigationItems,
|
||||
);
|
||||
readonly userInitial = input.required<string>();
|
||||
readonly logoutRequested = output<void>();
|
||||
readonly session = inject(SessionService);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { firstValueFrom, of } from 'rxjs';
|
||||
import { vi } from 'vitest';
|
||||
import { AdministrationService } from './administration.service';
|
||||
|
||||
describe('AdministrationService', () => {
|
||||
const http = { get: vi.fn() };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [AdministrationService, { provide: HttpClient, useValue: http }],
|
||||
});
|
||||
});
|
||||
|
||||
it('loads the protected administration usage response', async () => {
|
||||
http.get.mockReturnValueOnce(of({ apartmentCount: 4, snapshotAt: '2026-08-22T11:00:00Z' }));
|
||||
|
||||
expect(await firstValueFrom(TestBed.inject(AdministrationService).getUsage())).toEqual({
|
||||
apartmentCount: 4,
|
||||
snapshotAt: '2026-08-22T11:00:00Z',
|
||||
});
|
||||
expect(http.get).toHaveBeenCalledWith('/api/admin/usage');
|
||||
});
|
||||
|
||||
it('rejects an invalid usage response instead of exposing a false count', async () => {
|
||||
http.get.mockReturnValueOnce(of({ apartmentCount: -1, snapshotAt: 'not-a-date' }));
|
||||
|
||||
await expect(
|
||||
firstValueFrom(TestBed.inject(AdministrationService).getUsage()),
|
||||
).rejects.toThrow('Invalid administration usage response.');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { map, Observable } from 'rxjs';
|
||||
|
||||
export interface AdministrationUsage {
|
||||
apartmentCount: number;
|
||||
snapshotAt: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AdministrationService {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
getUsage(): Observable<AdministrationUsage> {
|
||||
return this.http.get<unknown>('/api/admin/usage').pipe(map(parseAdministrationUsage));
|
||||
}
|
||||
}
|
||||
|
||||
function parseAdministrationUsage(value: unknown): AdministrationUsage {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
throw new Error('Invalid administration usage response.');
|
||||
}
|
||||
const response = value as Record<string, unknown>;
|
||||
if (
|
||||
typeof response['apartmentCount'] !== 'number' ||
|
||||
!Number.isInteger(response['apartmentCount']) ||
|
||||
response['apartmentCount'] < 0 ||
|
||||
typeof response['snapshotAt'] !== 'string' ||
|
||||
Number.isNaN(Date.parse(response['snapshotAt']))
|
||||
) {
|
||||
throw new Error('Invalid administration usage response.');
|
||||
}
|
||||
return {
|
||||
apartmentCount: response['apartmentCount'],
|
||||
snapshotAt: response['snapshotAt'],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { PartialMatchRouteSnapshot, provideRouter, Route, Router, UrlTree } from '@angular/router';
|
||||
import { firstValueFrom, isObservable, Observable } from 'rxjs';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { SessionService, SessionStatus } from './session.service';
|
||||
import { administratorGuard } from './administrator.guard';
|
||||
|
||||
describe('administratorGuard', () => {
|
||||
const status = signal<SessionStatus>('authenticated');
|
||||
const isAdministrator = signal(false);
|
||||
const session = { status, isAdministrator };
|
||||
|
||||
beforeEach(() => {
|
||||
status.set('authenticated');
|
||||
isAdministrator.set(false);
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideRouter([]), { provide: SessionService, useValue: session }],
|
||||
});
|
||||
});
|
||||
|
||||
it('allows a server-reported administrator', () => {
|
||||
isAdministrator.set(true);
|
||||
|
||||
expect(guardResult()).toBe(true);
|
||||
});
|
||||
|
||||
it('redirects a regular user to the dashboard', () => {
|
||||
const router = TestBed.inject(Router);
|
||||
|
||||
expect(guardResult()).toEqual(router.parseUrl('/dashboard'));
|
||||
});
|
||||
|
||||
it('waits for the session result before deciding', async () => {
|
||||
status.set('checking');
|
||||
const result = guardResult();
|
||||
expect(isObservable(result)).toBe(true);
|
||||
|
||||
isAdministrator.set(true);
|
||||
status.set('authenticated');
|
||||
|
||||
expect(await firstValueFrom(result as Observable<boolean | UrlTree>)).toBe(true);
|
||||
});
|
||||
|
||||
function guardResult() {
|
||||
return TestBed.runInInjectionContext(() =>
|
||||
administratorGuard({} as Route, [], {} as PartialMatchRouteSnapshot),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { toObservable } from '@angular/core/rxjs-interop';
|
||||
import { CanMatchFn, Router } from '@angular/router';
|
||||
import { filter, map, take } from 'rxjs';
|
||||
import { SessionService } from './session.service';
|
||||
|
||||
export const administratorGuard: CanMatchFn = () => {
|
||||
const session = inject(SessionService);
|
||||
const router = inject(Router);
|
||||
if (session.status() === 'checking') {
|
||||
return toObservable(session.status).pipe(
|
||||
filter((status) => status !== 'checking'),
|
||||
take(1),
|
||||
map(() => (session.isAdministrator() ? true : router.parseUrl('/dashboard'))),
|
||||
);
|
||||
}
|
||||
return session.isAdministrator() ? true : router.parseUrl('/dashboard');
|
||||
};
|
||||
@@ -30,16 +30,23 @@ describe('SessionService', () => {
|
||||
it('stores an authenticated user and applies its defaults', () => {
|
||||
const service = TestBed.inject(SessionService);
|
||||
http.get.mockReturnValueOnce(
|
||||
of({ authenticated: true, username: 'Camille', email: 'camille@example.com' }),
|
||||
of({
|
||||
authenticated: true,
|
||||
username: 'Camille',
|
||||
email: 'camille@example.com',
|
||||
administrator: true,
|
||||
}),
|
||||
);
|
||||
service.refresh();
|
||||
expect(service.username()).toBe('Camille');
|
||||
expect(service.email()).toBe('camille@example.com');
|
||||
expect(service.isAdministrator()).toBe(true);
|
||||
|
||||
http.get.mockReturnValueOnce(of({ authenticated: true }));
|
||||
service.refresh();
|
||||
expect(service.username()).toBe('User');
|
||||
expect(service.email()).toBe('');
|
||||
expect(service.isAdministrator()).toBe(false);
|
||||
});
|
||||
|
||||
it('clears the session for an anonymous or failed request', () => {
|
||||
@@ -47,6 +54,7 @@ describe('SessionService', () => {
|
||||
http.get.mockReturnValueOnce(of({ authenticated: false }));
|
||||
service.refresh();
|
||||
expect(service.isLoggedIn()).toBe(false);
|
||||
expect(service.isAdministrator()).toBe(false);
|
||||
|
||||
http.get.mockReturnValueOnce(throwError(() => new Error('offline')));
|
||||
service.refresh();
|
||||
|
||||
@@ -5,6 +5,7 @@ interface UserResponse {
|
||||
authenticated: boolean;
|
||||
username?: string;
|
||||
email?: string;
|
||||
administrator?: boolean;
|
||||
}
|
||||
|
||||
export type SessionStatus = 'checking' | 'authenticated' | 'anonymous' | 'signing-out' | 'error';
|
||||
@@ -15,6 +16,8 @@ export class SessionService {
|
||||
private readonly clearListeners = new Set<() => void>();
|
||||
readonly status = signal<SessionStatus>('checking');
|
||||
readonly isLoggedIn = computed(() => this.status() === 'authenticated');
|
||||
readonly administrator = signal(false);
|
||||
readonly isAdministrator = computed(() => this.isLoggedIn() && this.administrator());
|
||||
readonly username = signal('');
|
||||
readonly email = signal('');
|
||||
|
||||
@@ -26,6 +29,7 @@ export class SessionService {
|
||||
this.clear();
|
||||
return;
|
||||
}
|
||||
this.administrator.set(user.administrator === true);
|
||||
this.status.set('authenticated');
|
||||
this.username.set(user.username || 'User');
|
||||
this.email.set(user.email || '');
|
||||
@@ -33,6 +37,7 @@ export class SessionService {
|
||||
error: () => {
|
||||
if (this.status() === 'anonymous') return;
|
||||
this.status.set('error');
|
||||
this.administrator.set(false);
|
||||
this.username.set('');
|
||||
this.email.set('');
|
||||
},
|
||||
@@ -41,8 +46,12 @@ export class SessionService {
|
||||
|
||||
clear(): void {
|
||||
const wasActive =
|
||||
this.status() !== 'anonymous' || this.username() !== '' || this.email() !== '';
|
||||
this.status() !== 'anonymous' ||
|
||||
this.administrator() ||
|
||||
this.username() !== '' ||
|
||||
this.email() !== '';
|
||||
this.status.set('anonymous');
|
||||
this.administrator.set(false);
|
||||
this.username.set('');
|
||||
this.email.set('');
|
||||
if (!wasActive) return;
|
||||
|
||||
Reference in new issue
Block a user