feat(admin): add OIDC administration usage slice

This commit is contained in:
nessar committed 2026-08-22 14:08:12 +02:00
1 parent 69ee90a530
commit 13826d60da
36 files changed
+719 -14

No files matched your search

+1
View File
@@ -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
+2
View File
@@ -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);
@@ -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) {
}
}
@@ -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);
}
}
@@ -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) {
}
}
@@ -0,0 +1,5 @@
package fr.nessar.lmnporganizer.administration.application.port.out;
public interface AdministrationUsageRepository {
long countApartments();
}
@@ -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/**")))
@@ -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.");
}
}
}
@@ -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}
@@ -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);
}
}
@@ -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();
}
}
@@ -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"));
}
}
@@ -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
+1
View File
@@ -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:
+1
View File
@@ -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/
+1
View File
@@ -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
View File
@@ -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
+8
View File
@@ -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);
+9
View File
@@ -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: () =>
+4
View File
@@ -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();
+10 -1
View File
@@ -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;