Skip to content

Commit e807a55

Browse files
committed
feat: enhance identities management with new invitation handling and search filters
- Introduced functionality to manage expired invitations for identities, allowing for better tracking and re-invitation processes. - Updated the IdentitiesCrudController to include new query parameters for filtering based on invitation expiration. - Enhanced the PasswdService to support initialization of outdated identities, improving user management capabilities. - Refactored search logic to accommodate new filters, ensuring efficient data retrieval and handling. - Added new UI components for displaying and managing outdated identities in the frontend.
1 parent 07d3c68 commit e807a55

10 files changed

Lines changed: 802 additions & 188 deletions

File tree

apps/api/src/management/identities/_schemas/identities.schema.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,4 +99,5 @@ export const IdentitiesSchema = SchemaFactory.createForClass(Identities)
9999
return ctx.inetOrgPerson.employeeType === 'LOCAL';
100100
},
101101
})
102-
.index({ 'inetOrgPerson.employeeNumber': 1, 'inetOrgPerson.employeeType': 1 }, { unique: true });
102+
.index({ 'inetOrgPerson.employeeNumber': 1, 'inetOrgPerson.employeeType': 1 }, { unique: true })
103+
.index({ initState: 1, 'initInfo.initDate': 1, deletedFlag: 1 });

apps/api/src/management/identities/identities-crud.controller.ts

Lines changed: 69 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,6 @@
1-
import {
2-
BadRequestException,
3-
Body,
4-
Controller,
5-
Delete,
6-
Get,
7-
HttpStatus,
8-
Param,
9-
Patch,
10-
Post,
11-
Query,
12-
Res,
13-
} from '@nestjs/common';
1+
import { BadRequestException, Body, Controller, Get, HttpStatus, Param, Patch, Post, Query, Res } from '@nestjs/common';
142
import { ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
15-
import {
16-
FilterOptions,
17-
filterSchema,
18-
FilterSchema,
19-
SearchFilterOptions,
20-
SearchFilterSchema,
21-
} from '~/_common/restools';
3+
import { FilterOptions, filterSchema, FilterSchema, SearchFilterOptions, SearchFilterSchema } from '~/_common/restools';
224
import { Response } from 'express';
235
import { Document, Types } from 'mongoose';
246
import { AbstractController } from '~/_common/abstracts/abstract.controller';
@@ -39,6 +21,13 @@ import { TransformersFilestorageService } from '~/core/filestorage/_services/tra
3921
import { IdentitiesCrudService } from '~/management/identities/identities-crud.service';
4022
import { UseRoles } from '~/_common/decorators/use-roles.decorator';
4123
import { AC_ACTIONS, AC_DEFAULT_POSSESSION } from '~/_common/types/ac-types';
24+
import { PasswdadmService } from '~/settings/passwdadm.service';
25+
import {
26+
buildExpiredInitInvitationFilter,
27+
buildNonExpiredInitInvitationFilter,
28+
INIT_INVITATION_EXPIRED_QUERY_PARAM,
29+
parseInitInvitationExpiredQuery,
30+
} from '~/management/passwd/init-invitation-expiration.helper';
4231

4332
@ApiTags('management/identities')
4433
@Controller('identities')
@@ -48,6 +37,7 @@ export class IdentitiesCrudController extends AbstractController {
4837
protected readonly _validation: IdentitiesValidationService,
4938
protected readonly filestorage: FilestorageService,
5039
private readonly transformerService: TransformersFilestorageService,
40+
private readonly passwdadmService: PasswdadmService,
5141
) {
5242
super();
5343
}
@@ -96,16 +86,12 @@ export class IdentitiesCrudController extends AbstractController {
9686
body.inetOrgPerson.employeeType = 'LOCAL';
9787
}
9888
if (!body.inetOrgPerson.cn) {
99-
body.inetOrgPerson.cn = [
100-
body.inetOrgPerson.sn?.toUpperCase(),
101-
body.inetOrgPerson.givenName,
102-
].join(' ').trim();
89+
body.inetOrgPerson.cn = [body.inetOrgPerson.sn?.toUpperCase(), body.inetOrgPerson.givenName].join(' ').trim();
10390
}
10491
if (!body.inetOrgPerson.displayName) {
105-
body.inetOrgPerson.displayName = [
106-
body.inetOrgPerson.givenName,
107-
body.inetOrgPerson.sn?.toUpperCase(),
108-
].join(' ').trim();
92+
body.inetOrgPerson.displayName = [body.inetOrgPerson.givenName, body.inetOrgPerson.sn?.toUpperCase()]
93+
.join(' ')
94+
.trim();
10995
}
11096
const data = await this._service.create<Identities>(body);
11197
// If the state is TO_COMPLETE, the identity is created but additional fields are missing or invalid
@@ -165,45 +151,62 @@ export class IdentitiesCrudController extends AbstractController {
165151
@Query('search') search: string,
166152
@SearchFilterSchema() searchFilterSchema: FilterSchema,
167153
@SearchFilterOptions({ allowUnlimited: true }) searchFilterOptions: FilterOptions,
168-
): Promise<Response<{
169-
statusCode: number;
170-
data?: Document<Identities, any, Identities>;
171-
total?: number;
172-
message?: string;
173-
validations?: MixedValue;
174-
}>> {
175-
const searchFilter = {}
154+
@Query(INIT_INVITATION_EXPIRED_QUERY_PARAM) initInvitationExpired: string,
155+
): Promise<
156+
Response<{
157+
statusCode: number;
158+
data?: Document<Identities, any, Identities>;
159+
total?: number;
160+
message?: string;
161+
validations?: MixedValue;
162+
}>
163+
> {
164+
const searchFilters = [];
176165
// Par défaut, on cache les identités "ne pas synchroniser" dans la recherche.
177166
// Si le client fournit déjà un filtre `state`, on ne l'écrase pas.
178167
// Le type `FilterSchema` est récursif (valeurs attendues), alors que pour Mongo on injecte parfois
179168
// des opérateurs comme `{ $ne: ... }`. On garde un cast `any` ici côté controller.
180-
const effectiveSearchFilterSchema: any = { ...searchFilterSchema }
169+
const effectiveSearchFilterSchema: any = { ...searchFilterSchema };
181170
if (!Object.prototype.hasOwnProperty.call(effectiveSearchFilterSchema, 'state')) {
182-
effectiveSearchFilterSchema.state = { $ne: IdentityState.DONT_SYNC }
171+
effectiveSearchFilterSchema.state = { $ne: IdentityState.DONT_SYNC };
183172
}
184173

185174
if (search && search.trim().length > 0) {
186-
const searchRequest = {}
187-
searchRequest['$or'] = Object.keys(IdentitiesCrudController.searchFields).map((key) => {
188-
return { [key]: { $regex: `^${search}`, $options: 'i' } }
189-
}).filter(item => item !== undefined)
190-
searchFilter['$and'] = [searchRequest]
191-
searchFilter['$and'].push(effectiveSearchFilterSchema)
175+
const searchRequest = {};
176+
searchRequest['$or'] = Object.keys(IdentitiesCrudController.searchFields)
177+
.map((key) => {
178+
return { [key]: { $regex: `^${search}`, $options: 'i' } };
179+
})
180+
.filter((item) => item !== undefined);
181+
searchFilters.push(searchRequest);
182+
searchFilters.push(effectiveSearchFilterSchema);
192183
} else {
193-
Object.assign(searchFilter, effectiveSearchFilterSchema)
184+
searchFilters.push(effectiveSearchFilterSchema);
185+
}
186+
187+
const expiredQuery = parseInitInvitationExpiredQuery(initInvitationExpired);
188+
if (expiredQuery !== null) {
189+
const policies = await this.passwdadmService.getPolicies();
190+
searchFilters.push(
191+
expiredQuery
192+
? buildExpiredInitInvitationFilter(policies?.initTokenTTL)
193+
: buildNonExpiredInitInvitationFilter(policies?.initTokenTTL),
194+
);
194195
}
195196

197+
const searchFilter = searchFilters.length === 1 ? searchFilters[0] : { $and: searchFilters };
198+
196199
const [data, total] = await this._service.findAndCount(
197200
searchFilter,
198201
IdentitiesCrudController.projection,
199202
searchFilterOptions,
200-
)
203+
);
201204

202205
return res.status(HttpStatus.OK).json({
203206
statusCode: HttpStatus.OK,
204207
total,
205208
data,
206-
})
209+
});
207210
}
208211

209212
@Get(':_id([0-9a-fA-F]{24})')
@@ -255,24 +258,30 @@ export class IdentitiesCrudController extends AbstractController {
255258
@ApiOperation({ summary: "Compte le nombre d'identitées en fonctions des filtres fournis via un body de counts" })
256259
public async countAll(
257260
@Res() res: Response,
258-
@Body() body: {
261+
@Body()
262+
body: {
259263
[key: string]: FilterSchema;
260264
},
261265
@SearchFilterOptions() searchFilterOptions: FilterOptions,
262-
): Promise<Response<{
263-
statusCode: number;
264-
data: {
265-
[key: string]: number;
266-
};
267-
}>> {
268-
let filters: Record<string, FilterSchema>
266+
): Promise<
267+
Response<{
268+
statusCode: number;
269+
data: {
270+
[key: string]: number;
271+
};
272+
}>
273+
> {
274+
let filters: Record<string, FilterSchema>;
269275
try {
270-
filters = Object.entries(body).reduce((acc, [key, value]) => {
271-
acc[key] = filterSchema(value)
272-
return acc
273-
}, {} as Record<string, FilterSchema>)
276+
filters = Object.entries(body).reduce(
277+
(acc, [key, value]) => {
278+
acc[key] = filterSchema(value);
279+
return acc;
280+
},
281+
{} as Record<string, FilterSchema>,
282+
);
274283
} catch (error: any) {
275-
throw new BadRequestException(error?.message ?? 'Invalid filters')
284+
throw new BadRequestException(error?.message ?? 'Invalid filters');
276285
}
277286

278287
const data = await this._service.countAll(filters, searchFilterOptions);
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { FilterQuery } from 'mongoose';
2+
import { InitStatesEnum } from '~/management/identities/_enums/init-state.enum';
3+
import { Identities } from '~/management/identities/_schemas/identities.schema';
4+
5+
export const INIT_INVITATION_EXPIRED_QUERY_PARAM = 'initInvitationExpired';
6+
export const DEFAULT_INIT_TOKEN_TTL_SECONDS = 604800;
7+
8+
export function getInitInvitationExpirationCutoff(
9+
ttlSeconds: number | string | undefined | null,
10+
now: Date = new Date(),
11+
): Date {
12+
const parsedTtlSeconds = Number(ttlSeconds);
13+
const safeTtlSeconds =
14+
Number.isFinite(parsedTtlSeconds) && parsedTtlSeconds > 0 ? parsedTtlSeconds : DEFAULT_INIT_TOKEN_TTL_SECONDS;
15+
16+
return new Date(now.getTime() - safeTtlSeconds * 1000);
17+
}
18+
19+
export function buildExpiredInitInvitationFilter(
20+
ttlSeconds: number | string | undefined | null,
21+
now: Date = new Date(),
22+
): FilterQuery<Identities> {
23+
return {
24+
initState: InitStatesEnum.SENT,
25+
'initInfo.initDate': { $lt: getInitInvitationExpirationCutoff(ttlSeconds, now) },
26+
};
27+
}
28+
29+
export function buildNonExpiredInitInvitationFilter(
30+
ttlSeconds: number | string | undefined | null,
31+
now: Date = new Date(),
32+
): FilterQuery<Identities> {
33+
const cutoff = getInitInvitationExpirationCutoff(ttlSeconds, now);
34+
35+
return {
36+
initState: InitStatesEnum.SENT,
37+
$or: [
38+
{ 'initInfo.initDate': { $gte: cutoff } },
39+
{ 'initInfo.initDate': { $exists: false } },
40+
{ 'initInfo.initDate': null },
41+
],
42+
};
43+
}
44+
45+
export function parseInitInvitationExpiredQuery(value: unknown): boolean | null {
46+
if (value === undefined || value === null || value === '') return null;
47+
if (typeof value === 'boolean') return value;
48+
49+
const normalized = String(value).trim().toLowerCase();
50+
if (/^(1|true|on|yes)$/i.test(normalized)) return true;
51+
if (/^(0|false|off|no)$/i.test(normalized)) return false;
52+
53+
return null;
54+
}

apps/api/src/management/passwd/passwd.controller.ts

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Body, Controller, Get, HttpStatus, Logger, Post, Res } from '@nestjs/co
22
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
33
import { Response } from 'express';
44
import { Document } from 'mongoose';
5+
import { FilterOptions, SearchFilterOptions } from '~/_common/restools';
56
import { Identities } from '~/management/identities/_schemas/identities.schema';
67
import { InitAccountDto } from '~/management/passwd/_dto/init-account.dto';
78
import { InitManyDto } from '~/management/passwd/_dto/init-many.dto';
@@ -20,15 +21,15 @@ export class PasswdController {
2021
public constructor(
2122
private passwdService: PasswdService,
2223
private passwdadmService: PasswdadmService,
23-
) { }
24+
) {}
2425

2526
@Post('change')
2627
@ApiOperation({ summary: 'Execute un job de changement de mot de passe sur le/les backends' })
2728
@ApiResponse({ status: HttpStatus.OK, description: 'Mot de passe synchronisé sur le/les backends' })
2829
public async change(@Body() body: ChangePasswordDto, @Res() res: Response): Promise<Response> {
2930
const debug = {};
3031

31-
const [_, data] = await this.passwdService.change(body);
32+
const [, data] = await this.passwdService.change(body);
3233
this.logger.log(`Call passwd change for : ${body.uid}`);
3334

3435
if (process.env.NODE_ENV === 'development') {
@@ -49,7 +50,7 @@ export class PasswdController {
4950
const debug = {};
5051
this.logger.log('Reset by code : ' + body.token + ' code : ' + body.code);
5152
try {
52-
const [_, data] = await this.passwdService.resetByCode(body);
53+
const [, data] = await this.passwdService.resetByCode(body);
5354
if (process.env.NODE_ENV === 'development') {
5455
debug['_debug'] = data;
5556
}
@@ -58,21 +59,20 @@ export class PasswdController {
5859
message: 'Password changed',
5960
...debug,
6061
});
61-
} catch (e) {
62+
} catch {
6263
return res.status(HttpStatus.BAD_REQUEST).json({
6364
message: 'Erreur serveur',
6465
...debug,
6566
});
6667
}
67-
6868
}
6969

7070
@Post('reset')
7171
@ApiOperation({ summary: 'Execute un job de réinitialisation de mot de passe sur le/les backends' })
7272
@ApiResponse({ status: HttpStatus.OK })
7373
public async reset(@Body() body: ResetPasswordDto, @Res() res: Response): Promise<Response> {
7474
const debug = {};
75-
const [_, data] = await this.passwdService.reset(body);
75+
const [, data] = await this.passwdService.reset(body);
7676

7777
if (process.env.NODE_ENV === 'development') {
7878
debug['_debug'] = data;
@@ -115,6 +115,20 @@ export class PasswdController {
115115
});
116116
}
117117

118+
@Post('initoutdated')
119+
@ApiOperation({ summary: "Initialise toutes les identités dont l'invitation est périmée" })
120+
@ApiResponse({ status: HttpStatus.OK })
121+
public async initOutdated(
122+
@Body() body: Pick<InitManyDto, 'template' | 'variables'>,
123+
@Res() res: Response,
124+
): Promise<Response> {
125+
const data = await this.passwdService.initOutdated(body);
126+
return res.status(HttpStatus.OK).json({
127+
message: 'identités initialisées',
128+
data,
129+
});
130+
}
131+
118132
@Post('initreset')
119133
@ApiOperation({ summary: 'Demande l envoi de mail pour le reset' })
120134
@ApiResponse({ status: HttpStatus.OK })
@@ -131,16 +145,18 @@ export class PasswdController {
131145

132146
@Get('ioutdated')
133147
@ApiOperation({ summary: 'Compte donc l invitation d init n a pas été repondue dans les temps' })
134-
public async search(@Res() res: Response): Promise<
148+
public async search(
149+
@Res() res: Response,
150+
@SearchFilterOptions() searchFilterOptions: FilterOptions,
151+
): Promise<
135152
Response<
136153
{
137154
data?: Document<Identities, any, Identities>;
138155
},
139156
any
140157
>
141158
> {
142-
const data = await this.passwdService.checkInitOutDated();
143-
const total = data.length;
159+
const [data, total] = await this.passwdService.checkInitOutDated(searchFilterOptions);
144160
return res.status(HttpStatus.OK).json({
145161
statusCode: HttpStatus.OK,
146162
total,

0 commit comments

Comments
 (0)