Skip to content

Commit eef1f55

Browse files
committed
feat: enhance identity search functionality with dynamic fields
- Introduced a new helper for managing identity search fields, allowing for dynamic merging of default and additional search fields. - Updated the IdentitiesCrudController to utilize the new search fields helper, improving search capabilities. - Added a new component for displaying available search fields in the UI, enhancing user experience. - Implemented tests for the new search fields functionality to ensure reliability and correctness. - Updated the Nuxt.js configuration to support the new identity search fields feature, ensuring proper integration across the application.
1 parent 16c27b5 commit eef1f55

13 files changed

Lines changed: 396 additions & 65 deletions

File tree

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

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
INIT_INVITATION_EXPIRED_QUERY_PARAM,
2929
parseInitInvitationExpiredQuery,
3030
} from '~/management/passwd/init-invitation-expiration.helper';
31+
import { mergeIdentitySearchFields } from '~/management/identities/identities-search-fields.helper';
3132

3233
@ApiTags('management/identities')
3334
@Controller('identities')
@@ -51,14 +52,6 @@ export class IdentitiesCrudController extends AbstractController {
5152
lifecycle: 1,
5253
};
5354

54-
protected static readonly searchFields: PartialProjectionType<any> = {
55-
'inetOrgPerson.cn': 1,
56-
'inetOrgPerson.givenName': 1,
57-
'inetOrgPerson.sn': 1,
58-
'inetOrgPerson.mail': 1,
59-
'inetOrgPerson.employeeType': 1,
60-
};
61-
6255
@Post()
6356
@UseRoles({
6457
resource: '/management/identities',
@@ -152,6 +145,7 @@ export class IdentitiesCrudController extends AbstractController {
152145
@SearchFilterSchema() searchFilterSchema: FilterSchema,
153146
@SearchFilterOptions({ allowUnlimited: true }) searchFilterOptions: FilterOptions,
154147
@Query(INIT_INVITATION_EXPIRED_QUERY_PARAM) initInvitationExpired: string,
148+
@Query('searchFields') searchFields: string | string[],
155149
): Promise<
156150
Response<{
157151
statusCode: number;
@@ -172,8 +166,9 @@ export class IdentitiesCrudController extends AbstractController {
172166
}
173167

174168
if (search && search.trim().length > 0) {
169+
const effectiveSearchFields = mergeIdentitySearchFields(searchFields);
175170
const searchRequest = {};
176-
searchRequest['$or'] = Object.keys(IdentitiesCrudController.searchFields)
171+
searchRequest['$or'] = Object.keys(effectiveSearchFields)
177172
.map((key) => {
178173
return { [key]: { $regex: `^${search}`, $options: 'i' } };
179174
})
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { PartialProjectionType } from '~/_common/types/partial-projection.type';
2+
3+
export const DEFAULT_IDENTITY_SEARCH_FIELD_KEYS = [
4+
'inetOrgPerson.cn',
5+
'inetOrgPerson.givenName',
6+
'inetOrgPerson.sn',
7+
'inetOrgPerson.mail',
8+
'inetOrgPerson.employeeType',
9+
'inetOrgPerson.employeeNumber',
10+
] as const;
11+
12+
export const DEFAULT_IDENTITY_SEARCH_FIELDS: PartialProjectionType<Record<string, unknown>> = Object.fromEntries(
13+
DEFAULT_IDENTITY_SEARCH_FIELD_KEYS.map((key) => [key, 1]),
14+
) as PartialProjectionType<Record<string, unknown>>;
15+
16+
const SEARCH_FIELD_PATH_PATTERN = /^[a-zA-Z][a-zA-Z0-9_.]*$/;
17+
18+
export function isValidIdentitySearchFieldPath(path: string): boolean {
19+
const trimmed = `${path || ''}`.trim();
20+
return trimmed.length > 0 && trimmed.length <= 200 && SEARCH_FIELD_PATH_PATTERN.test(trimmed);
21+
}
22+
23+
export function parseIdentitySearchFieldsQuery(value: unknown): string[] {
24+
if (value === undefined || value === null || value === '') {
25+
return [];
26+
}
27+
28+
const rawValues = Array.isArray(value) ? value : [`${value}`];
29+
const fields: string[] = [];
30+
31+
for (const raw of rawValues) {
32+
if (raw === undefined || raw === null) continue;
33+
`${raw}`
34+
.split(',')
35+
.map((part) => part.trim())
36+
.filter(Boolean)
37+
.forEach((field) => fields.push(field));
38+
}
39+
40+
return fields;
41+
}
42+
43+
export function mergeIdentitySearchFields(additionalFields: unknown): PartialProjectionType<Record<string, unknown>> {
44+
const merged: PartialProjectionType<Record<string, unknown>> = { ...DEFAULT_IDENTITY_SEARCH_FIELDS };
45+
46+
for (const field of parseIdentitySearchFieldsQuery(additionalFields)) {
47+
if (!isValidIdentitySearchFieldPath(field)) continue;
48+
merged[field] = 1;
49+
}
50+
51+
return merged;
52+
}

apps/api/tests/unit/management/identities/identities-crud.controller.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ describe('IdentitiesCrudController invitation expiration filter', () => {
3333
};
3434
const options = { limit: 10, skip: 0, sort: {} };
3535

36-
await controller.search(res as any, '', { initState: InitStatesEnum.SENT } as any, options, 'false');
36+
await controller.search(res as any, '', { initState: InitStatesEnum.SENT } as any, options, 'false', undefined);
3737

3838
expect(service.findAndCount).toHaveBeenCalledWith(
3939
{
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import {
2+
DEFAULT_IDENTITY_SEARCH_FIELD_KEYS,
3+
isValidIdentitySearchFieldPath,
4+
mergeIdentitySearchFields,
5+
parseIdentitySearchFieldsQuery,
6+
} from '~/management/identities/identities-search-fields.helper';
7+
8+
describe('identities-search-fields.helper', () => {
9+
it('parseIdentitySearchFieldsQuery supports arrays and comma-separated values', () => {
10+
expect(parseIdentitySearchFieldsQuery(undefined)).toEqual([]);
11+
expect(parseIdentitySearchFieldsQuery('inetOrgPerson.uid,inetOrgPerson.mail')).toEqual([
12+
'inetOrgPerson.uid',
13+
'inetOrgPerson.mail',
14+
]);
15+
expect(parseIdentitySearchFieldsQuery(['inetOrgPerson.uid', 'inetOrgPerson.sn'])).toEqual([
16+
'inetOrgPerson.uid',
17+
'inetOrgPerson.sn',
18+
]);
19+
});
20+
21+
it('mergeIdentitySearchFields keeps defaults and merges extra fields without duplicates', () => {
22+
const merged = mergeIdentitySearchFields(['inetOrgPerson.uid', 'inetOrgPerson.cn', 'invalid$field']);
23+
24+
expect(Object.keys(merged).sort()).toEqual(
25+
[...DEFAULT_IDENTITY_SEARCH_FIELD_KEYS, 'inetOrgPerson.uid'].sort(),
26+
);
27+
expect(merged['inetOrgPerson.cn']).toBe(1);
28+
expect(merged['inetOrgPerson.uid']).toBe(1);
29+
expect(merged['invalid$field']).toBeUndefined();
30+
});
31+
32+
it('isValidIdentitySearchFieldPath rejects unsafe paths', () => {
33+
expect(isValidIdentitySearchFieldPath('inetOrgPerson.uid')).toBe(true);
34+
expect(isValidIdentitySearchFieldPath('$ne')).toBe(false);
35+
expect(isValidIdentitySearchFieldPath('')).toBe(false);
36+
});
37+
});
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Champs de recherche textuelle supplémentaires (en plus des champs par défaut côté API).
2+
# Ne pas dupliquer : inetOrgPerson.cn, givenName, sn, mail, employeeType, employeeNumber.
3+
fields:
4+
- inetOrgPerson.uid
5+
- additionalFields.attributes.supannPerson.edupersonprincipalname

apps/web/nuxt.config.ts

Lines changed: 21 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,11 @@
11
import { resolve } from 'path'
22
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'fs'
3-
import { fileURLToPath } from 'node:url'
43
import openapiTS, { astToString, COMMENT_HEADER } from 'openapi-typescript'
54
import { defineNuxtConfig } from 'nuxt/config'
65
import * as consola from 'consola'
76
import setupApp from './src/server/extension.setup'
87

9-
const WEB_ROOT = fileURLToPath(new URL('.', import.meta.url))
10-
const CLIENT_MANIFEST_STUB = 'export default {}\n'
11-
12-
/** SPA (`ssr:false`) : Nitro dev peut importer un manifest absent après prepare/reload. */
13-
function ensureClientManifestStub(rootDir = WEB_ROOT): void {
14-
const clientManifestPath = resolve(rootDir, '.nuxt/dist/server/client.manifest.mjs')
15-
16-
if (existsSync(clientManifestPath)) {
17-
return
18-
}
19-
20-
mkdirSync(resolve(rootDir, '.nuxt/dist/server'), { recursive: true })
21-
writeFileSync(clientManifestPath, CLIENT_MANIFEST_STUB)
22-
consola.info('[Nuxt] Created missing client.manifest.mjs stub')
23-
}
24-
258
const SESAME_APP_API_URL = process.env.SESAME_APP_API_URL || 'http://localhost:4002'
26-
/** URL API exposée au navigateur (WebSocket). Ex. http://mactacx:4002 si SESAME_APP_API_URL pointe vers 127.0.0.1:4000. */
27-
const SESAME_APP_PUBLIC_API_URL = process.env.SESAME_APP_PUBLIC_API_URL || ''
289
const SESAME_ALLOWED_HOSTS = process.env.SESAME_ALLOWED_HOSTS ? process.env.SESAME_ALLOWED_HOSTS.split(',') : []
2910
const IS_DEV = process.env.NODE_ENV === 'development'
3011

@@ -95,7 +76,6 @@ export default defineNuxtConfig({
9576
runtimeConfig: {
9677
public: {
9778
release: process.env.npm_package_name + '@' + process.env.npm_package_version,
98-
socketApiUrl: SESAME_APP_PUBLIC_API_URL,
9979
sentry: {
10080
dsn: process.env.SESAME_SENTRY_DSN,
10181
},
@@ -261,7 +241,19 @@ export default defineNuxtConfig({
261241
typescriptBundlerResolution: true,
262242
},
263243
nitro: {
244+
experimental: {
245+
websocket: false,
246+
},
264247
routeRules: {
248+
'/api/core/backends/sse': {
249+
proxy: `${SESAME_APP_API_URL}/core/backends/sse`,
250+
// Disable compression and caching for SSE
251+
headers: {
252+
'Cache-Control': 'no-cache, no-transform',
253+
'Connection': 'keep-alive',
254+
'X-Accel-Buffering': 'no', // Disable buffering in nginx
255+
},
256+
},
265257
'/api/**': {
266258
proxy: `${SESAME_APP_API_URL}/**`,
267259
},
@@ -280,39 +272,18 @@ export default defineNuxtConfig({
280272
storesDirs: ['~/stores'],
281273
},
282274
hooks: {
283-
'nitro:init': (nitro) => {
284-
try {
285-
ensureClientManifestStub(nitro.options.rootDir)
286-
} catch (error) {
287-
consola.warn('[Nuxt] Unable to ensure client.manifest.mjs stub (nitro:init)', error)
288-
}
289-
},
290-
'nitro:build:before': (nitro) => {
291-
try {
292-
ensureClientManifestStub(nitro.options.rootDir)
293-
} catch (error) {
294-
consola.warn('[Nuxt] Unable to ensure client.manifest.mjs stub (nitro:build:before)', error)
295-
}
296-
},
297-
listen: () => {
298-
try {
299-
ensureClientManifestStub(WEB_ROOT)
300-
} catch (error) {
301-
consola.warn('[Nuxt] Unable to ensure client.manifest.mjs stub (listen)', error)
302-
}
303-
},
304-
close: () => {
305-
try {
306-
ensureClientManifestStub(WEB_ROOT)
307-
} catch (error) {
308-
consola.warn('[Nuxt] Unable to ensure client.manifest.mjs stub (close)', error)
309-
}
310-
},
311275
ready: async (nuxt) => {
276+
// Nuxt (Nitro) en SPA (`ssr:false`) peut parfois importer un client manifest absent en dev,
277+
// ce qui casse toutes les requêtes. On crée un stub si nécessaire.
312278
try {
313-
ensureClientManifestStub(nuxt.options.rootDir)
279+
const clientManifestPath = resolve(process.cwd(), '.nuxt/dist/server/client.manifest.mjs')
280+
if (!existsSync(clientManifestPath)) {
281+
mkdirSync(resolve(process.cwd(), '.nuxt/dist/server'), { recursive: true })
282+
writeFileSync(clientManifestPath, 'export default {}\\n')
283+
consola.info('[Nuxt] Created missing client.manifest.mjs stub')
284+
}
314285
} catch (error) {
315-
consola.warn('[Nuxt] Unable to ensure client.manifest.mjs stub (ready)', error)
286+
consola.warn('[Nuxt] Unable to ensure client.manifest.mjs stub', error)
316287
}
317288

318289
const forceOpenapiRefresh = /true|on|yes|1/i.test(`${process.env.SESAME_FORCE_OPENAPI_TYPES_REFRESH}`)

0 commit comments

Comments
 (0)