Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
6189e27
feat(auth): verify user rights on login and account creation
loicguillois Dec 10, 2025
7ff538b
feat(auth): verify Portail DF group access level and geographic perim…
loicguillois Dec 10, 2025
b77b6f8
feat(auth): allow suspended users to login and display modal
loicguillois Dec 11, 2025
fa313af
fix(lint): fix unused vars and unescaped entities
loicguillois Dec 11, 2025
2714462
fix(auth): properly detect deleted users and allow suspended users to…
loicguillois Dec 11, 2025
2c92404
fix(test): ensure MockCeremaService is used in tests
loicguillois Dec 11, 2025
27d2206
feat(auth): filter housing and map data by user perimeter from Portai…
loicguillois Dec 18, 2025
6191a70
feat(auth): filter groups and campaigns by user perimeter
loicguillois Dec 18, 2025
775342e
fix(repositories): use whereRaw for geoCodes NOT IN clause
loicguillois Dec 18, 2025
cdfe009
fix(repositories): fix TypeScript undefined error in geoCodes filtering
loicguillois Dec 22, 2025
e54b6c1
feat(auth): add multi-structure user support
loicguillois Dec 22, 2025
76c505b
feat(auth): sync user perimeter from Portail DF on login and add ADMI…
loicguillois Dec 22, 2025
6197dde
fix(types): fix TypeScript error in isMultiStructure count result
loicguillois Dec 22, 2025
565f94d
feat(perimeter): add EPCI perimeter support, fix empty localities bug…
loicguillois Dec 24, 2025
1860a7f
docs: update decision tree documentation with EPCI perimeter support
loicguillois Dec 24, 2025
b5b5d3c
fix(frontend): use Establishment type consistently in searchable select
loicguillois Dec 24, 2025
632ae16
fix(tests): fix mock auth handler and wildcard SIREN handling
loicguillois Dec 24, 2025
b89caf1
fix(migration): make users_establishments migration idempotent
loicguillois Jan 12, 2026
341034f
Revert "fix(migration): make users_establishments migration idempotent"
loicguillois Jan 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
554 changes: 554 additions & 0 deletions docs/portail-df-decision-tree.md

Large diffs are not rendered by default.

46 changes: 27 additions & 19 deletions frontend/src/components/Header/SmallHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,7 @@ import logo from '../../assets/images/zlv.svg';
import { useFilters } from '../../hooks/useFilters';
import { useAppDispatch } from '../../hooks/useStore';
import { useUser } from '../../hooks/useUser';
import {
type Establishment,
fromEstablishmentDTO,
toEstablishmentDTO
} from '../../models/Establishment';
import { type Establishment } from '../../models/Establishment';
import { getUserNavItem, UserNavItems } from '../../models/UserNavItem';
import { zlvApi } from '../../services/api.service';
import { changeEstablishment } from '../../store/actions/authenticationAction';
Expand All @@ -29,7 +25,7 @@ import styles from './small-header.module.scss';
function SmallHeader() {
const dispatch = useAppDispatch();
const location = useLocation();
const { establishment, isAdmin, isVisitor, isAuthenticated } = useUser();
const { establishment, isAdmin, isVisitor, isAuthenticated, canChangeEstablishment, authorizedEstablishments } = useUser();

function getMainNavigationItem(
navItem: UserNavItems
Expand Down Expand Up @@ -119,20 +115,32 @@ function SmallHeader() {
/>
<Grid alignItems="center" display="flex" ml="auto">
{isAuthenticated ? (
isAdmin || isVisitor ? (
canChangeEstablishment ? (
establishment ? (
<EstablishmentSearchableSelect
className={fr.cx('fr-mr-2w')}
disableClearable
value={toEstablishmentDTO(establishment)}
onChange={(establishment) => {
if (establishment) {
onChangeEstablishment(
fromEstablishmentDTO(establishment)
);
}
}}
/>
isAdmin || isVisitor ? (
<EstablishmentSearchableSelect
className={fr.cx('fr-mr-2w')}
disableClearable
value={establishment}
onChange={(establishment) => {
if (establishment) {
onChangeEstablishment(establishment);
}
}}
/>
) : (
<EstablishmentSearchableSelect
className={fr.cx('fr-mr-2w')}
disableClearable
options={authorizedEstablishments ?? []}
value={establishment}
onChange={(establishment) => {
if (establishment) {
onChangeEstablishment(establishment);
}
}}
/>
)
) : null
) : (
<Typography
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,23 @@ import {
type AutocompleteValue
} from '@mui/material/Autocomplete';

import type { EstablishmentDTO } from '@zerologementvacant/models';
import { type ReactNode } from 'react';
import { type Establishment } from '../../models/Establishment';
import { useLazyFindEstablishmentsQuery } from '../../services/establishment.service';
import SearchableSelectNext from '../SearchableSelectNext/SearchableSelectNext';

type Props<Multiple extends boolean, DisableClearable extends boolean> = Pick<
AutocompleteProps<EstablishmentDTO, Multiple, DisableClearable, false>,
AutocompleteProps<Establishment, Multiple, DisableClearable, false>,
'disableClearable' | 'multiple'
> & {
className?: string;
label?: ReactNode;
value: AutocompleteValue<EstablishmentDTO, Multiple, DisableClearable, false>;
/** Pre-defined options to use instead of API search. When provided, no API call is made. */
options?: ReadonlyArray<Establishment>;
value: AutocompleteValue<Establishment, Multiple, DisableClearable, false>;
onChange(
establishment: AutocompleteValue<
EstablishmentDTO,
Establishment,
Multiple,
DisableClearable,
false
Expand All @@ -33,10 +35,17 @@ function EstablishmentSearchableSelect<
const [findEstablishments, { data: establishments, isFetching }] =
useLazyFindEstablishmentsQuery();

const options = (establishments ??
[]) as unknown as ReadonlyArray<EstablishmentDTO>;
// Use pre-defined options if provided, otherwise use API search results
const hasPreDefinedOptions = props.options !== undefined;
const options: ReadonlyArray<Establishment> = hasPreDefinedOptions
? (props.options ?? [])
: (establishments ?? []);

async function search(query: string | undefined): Promise<void> {
// Skip API search if we have pre-defined options
if (hasPreDefinedOptions) {
return;
}
if (query) {
await findEstablishments({ query }).unwrap();
}
Expand All @@ -46,10 +55,10 @@ function EstablishmentSearchableSelect<
<SearchableSelectNext
className={props.className}
disableClearable={props.disableClearable}
debounce={250}
debounce={hasPreDefinedOptions ? 0 : 250}
search={search}
options={options}
loading={isFetching}
loading={hasPreDefinedOptions ? false : isFetching}
label={props.label ?? null}
getOptionKey={(option) => option.id}
getOptionLabel={(option) => option.name}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,25 @@ describe('SuspendedUserModal', () => {
it('should render when user is suspended with multiple reasons', () => {
renderWithUser('2025-01-01T00:00:00Z', 'droits utilisateur expires, droits structure expires, cgu vides');
expect(screen.getByText(/Vos droits d.accès à Zéro Logement Vacant ne sont plus valides/i)).toBeInTheDocument();
expect(screen.getByText(/La date d.expiration de vos droits d.accès aux données LOVAC en tant qu.utilisateur ou ceux de votre structure a été dépassée/i)).toBeInTheDocument();
expect(screen.getByText(/Plusieurs problèmes ont été détectés/i)).toBeInTheDocument();
});

it('should render when user is suspended with invalid access level', () => {
renderWithUser('2025-01-01T00:00:00Z', 'niveau_acces_invalide');
expect(screen.getByText(/Vos droits d.accès à Zéro Logement Vacant ne sont plus valides/i)).toBeInTheDocument();
expect(screen.getByText(/Votre niveau d.accès aux données LOVAC sur le portail Données Foncières du Cerema n.est pas valide/i)).toBeInTheDocument();
});

it('should render when user is suspended with invalid perimeter', () => {
renderWithUser('2025-01-01T00:00:00Z', 'perimetre_invalide');
expect(screen.getByText(/Vos droits d.accès à Zéro Logement Vacant ne sont plus valides/i)).toBeInTheDocument();
expect(screen.getByText(/Votre périmètre géographique sur le portail Données Foncières du Cerema ne correspond pas à votre établissement/i)).toBeInTheDocument();
});

it('should render when user is suspended with both invalid access level and perimeter', () => {
renderWithUser('2025-01-01T00:00:00Z', 'niveau_acces_invalide, perimetre_invalide');
expect(screen.getByText(/Vos droits d.accès à Zéro Logement Vacant ne sont plus valides/i)).toBeInTheDocument();
expect(screen.getByText(/Plusieurs problèmes ont été détectés/i)).toBeInTheDocument();
});

it('should have a link to Portail des Données Foncières', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,18 @@ function SuspendedUserModal() {
return user?.user.suspendedCause?.includes('cgu vides');
}, [user?.user.suspendedCause]);

const isAccessLevelInvalid = useMemo(() => {
return user?.user.suspendedCause?.includes('niveau_acces_invalide');
}, [user?.user.suspendedCause]);

const isPerimeterInvalid = useMemo(() => {
return user?.user.suspendedCause?.includes('perimetre_invalide');
}, [user?.user.suspendedCause]);

const hasMultipleReasons = useMemo(() => {
const causes = [isCguEmpty, isUserExpired, isEstablishmentExpired].filter(Boolean);
const causes = [isCguEmpty, isUserExpired, isEstablishmentExpired, isAccessLevelInvalid, isPerimeterInvalid].filter(Boolean);
return causes.length > 1;
}, [isCguEmpty, isUserExpired, isEstablishmentExpired]);
}, [isCguEmpty, isUserExpired, isEstablishmentExpired, isAccessLevelInvalid, isPerimeterInvalid]);

useEffect(() => {
if (ready && isSuspended) {
Expand Down Expand Up @@ -70,12 +78,20 @@ function SuspendedUserModal() {
<Grid component="section" size={12}>
<Alert
severity="error"
title="Vos droits daccès à Zéro Logement Vacant ne sont plus valides"
title="Vos droits d'accès à Zéro Logement Vacant ne sont plus valides"
description={
<Typography>
{hasMultipleReasons ? (
<>
La date d&apos;expiration de vos droits d&apos;accès aux données LOVAC en tant qu&apos;utilisateur ou ceux de votre structure a été dépassée.
Plusieurs problèmes ont été détectés avec vos droits d&apos;accès aux données LOVAC.
</>
) : isAccessLevelInvalid ? (
<>
Votre niveau d&apos;accès aux données LOVAC sur le portail Données Foncières du Cerema n&apos;est pas valide.
</>
) : isPerimeterInvalid ? (
<>
Votre périmètre géographique sur le portail Données Foncières du Cerema ne correspond pas à votre établissement.
</>
) : isCguEmpty ? (
<>
Expand All @@ -85,10 +101,14 @@ function SuspendedUserModal() {
<>
La date d&apos;expiration de vos droits d&apos;accès aux données LOVAC en tant qu&apos;utilisateur a été dépassée.
</>
) : (
) : isEstablishmentExpired ? (
<>
La date d&apos;expiration des droits d&apos;accès aux données LOVAC de votre structure a été dépassée.
</>
) : (
<>
Vos droits d&apos;accès aux données LOVAC ne sont plus valides.
</>
)}
</Typography>
}
Expand All @@ -99,28 +119,46 @@ function SuspendedUserModal() {
<Typography sx={{ mb: 2 }}>
{hasMultipleReasons ? (
<>
Rendez-vous sur le portail Données Foncières du Cerema pour vérifier vos droits daccès aux données LOVAC et ceux de votre structure.
Rendez-vous sur le portail Données Foncières du Cerema pour vérifier vos droits d&apos;accès aux données LOVAC et ceux de votre structure.
<br />
Si vous n’avez pas de compte sur le portail Données Foncières du Cerema, vous devez en créer un.
Si vous n&apos;avez pas de compte sur le portail Données Foncières du Cerema, vous devez en créer un.
</>
) : isAccessLevelInvalid ? (
<>
Rendez-vous sur le portail Données Foncières du Cerema pour vérifier que votre groupe dispose bien d&apos;un accès aux données LOVAC.
<br />
Si vous ne pouvez pas modifier votre groupe vous-même, demandez au(x) gestionnaire(s) de votre structure de le faire.
</>
) : isPerimeterInvalid ? (
<>
Rendez-vous sur le portail Données Foncières du Cerema pour vérifier que votre périmètre géographique correspond bien à votre établissement.
<br />
Si vous ne pouvez pas modifier votre périmètre vous-même, demandez au(x) gestionnaire(s) de votre structure de le faire.
</>
) : isCguEmpty ? (
<>
Rendez-vous sur le portail Données Foncières du Cerema pour valider les conditions générales dutilisation.
Rendez-vous sur le portail Données Foncières du Cerema pour valider les conditions générales d&apos;utilisation.
<br />
Si vous navez pas de compte sur le portail Données Foncières du Cerema, vous devez en créer un.
Si vous n&apos;avez pas de compte sur le portail Données Foncières du Cerema, vous devez en créer un.
</>
) : isUserExpired ? (
<>
Rendez-vous sur le portail Données Foncières du Cerema pour modifier la date dexpiration de vos droits daccès aux données.
Rendez-vous sur le portail Données Foncières du Cerema pour modifier la date d&apos;expiration de vos droits d&apos;accès aux données.
<br />
Si vous ne pouvez pas modifier la date vous-même, demandez au(x) gestionnaire(s) de votre structure de le faire.
</>
) : (
) : isEstablishmentExpired ? (
<>
Rendez-vous sur le portail Données Foncières du Cerema pour renouveler votre demande daccès aux données LOVAC.
Rendez-vous sur le portail Données Foncières du Cerema pour renouveler votre demande d&apos;accès aux données LOVAC.
<br />
Si vous ne pouvez pas renouveler la demande vous-même, demandez au(x) gestionnaire(s) de votre structure de le faire.
</>
) : (
<>
Rendez-vous sur le portail Données Foncières du Cerema pour vérifier vos droits d&apos;accès.
<br />
Si vous n&apos;avez pas de compte sur le portail Données Foncières du Cerema, vous devez en créer un.
</>
)}
</Typography>
</Grid>
Expand Down
12 changes: 11 additions & 1 deletion frontend/src/hooks/useUser.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { UserRole } from '@zerologementvacant/models';
import { useAppDispatch, useAppSelector } from './useStore';
import authenticationSlice from '~/store/reducers/authenticationReducer';
import { zlvApi } from '~/services/api.service';

export function useUser() {
const dispatch = useAppDispatch();
const { logIn } = useAppSelector((state) => state.authentication);
const { data, error, isError, isLoading, isUninitialized, isSuccess } = logIn;
const establishment = data?.establishment;
const user = data?.user;
const authorizedEstablishments = data?.authorizedEstablishments;

const isAuthenticated =
!!data?.accessToken && !!data?.user && !!data?.establishment;
Expand All @@ -17,6 +19,10 @@ export function useUser() {
const isUsual = isAuthenticated && user?.role === UserRole.USUAL;
const isVisitor = isAuthenticated && user?.role === UserRole.VISITOR;

// USUAL users with multiple authorized establishments can change establishment
const hasMultipleEstablishments = (authorizedEstablishments?.length ?? 0) > 1;
const canChangeEstablishment = isAdmin || isVisitor || (isUsual && hasMultipleEstablishments);

function displayName(): string {
if (user?.firstName && user?.lastName) {
return `${user.firstName} ${user.lastName}`;
Expand All @@ -30,23 +36,27 @@ export function useUser() {
}

function logOut() {
// Reset RTK Query cache to clear all cached data from previous user
dispatch(zlvApi.util.resetApiState());
dispatch(authenticationSlice.actions.logOut());
}

return {
displayName,
logOut,
establishment,
authorizedEstablishments,
user,
isAdmin,
isAuthenticated,
isGuest,
isUsual,
isVisitor,
canChangeEstablishment,
error,
isError,
isLoading,
isUninitialized,
isSuccess
};
};
}
20 changes: 18 additions & 2 deletions frontend/src/mocks/handlers/auth-handlers.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { http, HttpResponse, RequestHandler } from 'msw';
import { constants } from 'node:http2';

import type { UserDTO } from '@zerologementvacant/models';
import type { EstablishmentDTO, UserDTO } from '@zerologementvacant/models';
import config from '../../utils/config';
import data from './data';

Expand All @@ -14,6 +14,7 @@ interface AuthPayload {
interface Auth {
user: UserDTO;
accessToken: string;
establishment: EstablishmentDTO;
}

export const authHandlers: RequestHandler[] = [
Expand All @@ -28,9 +29,24 @@ export const authHandlers: RequestHandler[] = [
});
}

// Find establishment for the user
const establishment: EstablishmentDTO = data.establishments.find(
(e) => e.id === user.establishmentId
) ?? {
id: user.establishmentId ?? 'test-establishment-id',
name: 'Test Establishment',
shortName: 'Test',
siren: '123456789',
geoCodes: ['75056'],
available: true,
kind: 'CA',
source: 'seed'
};

return HttpResponse.json({
user,
accessToken: 'fake-access-token'
accessToken: 'fake-access-token',
establishment
});
}
)
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/models/User.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export interface AuthUser {
user: User;
accessToken: string;
establishment: Establishment;
/** List of establishments the user has access to (for multi-structure USUAL users) */
authorizedEstablishments?: Establishment[];
}

export interface User extends Omit<UserDTO, 'activatedAt'> {
Expand Down
Loading