diff --git a/api/scripts/compile-data.ts b/api/scripts/compile-data.ts index e0270b91..f1bf6eb5 100644 --- a/api/scripts/compile-data.ts +++ b/api/scripts/compile-data.ts @@ -7,7 +7,6 @@ import { env } from "../src/env"; (async () => { const kyselyDb = new Kysely({ dialect: createPgDialect(env.databaseUrl) }); const { useCases } = await bootstrapCore({ - "keycloakUserApiParams": undefined, "dbConfig": { "dbKind": "kysely", "kyselyDb": kyselyDb diff --git a/api/src/core/adapters/dbApi/in-memory/createInMemoryAgentRepository.ts b/api/src/core/adapters/dbApi/in-memory/createInMemoryAgentRepository.ts index 0e38c7b7..e6492a74 100644 --- a/api/src/core/adapters/dbApi/in-memory/createInMemoryAgentRepository.ts +++ b/api/src/core/adapters/dbApi/in-memory/createInMemoryAgentRepository.ts @@ -30,7 +30,11 @@ export const createInMemoryAgentRepository = (): { }, getAll: () => { throw new Error("Not implemented"); - } + }, + getAllOrganizations: () => { + throw new Error("Not implemented"); + }, + countAll: async () => agents.length }, testHelpers: { setAgents: (newAgents: DbAgentWithId[]) => { diff --git a/api/src/core/adapters/dbApi/kysely/createPgAgentRepository.ts b/api/src/core/adapters/dbApi/kysely/createPgAgentRepository.ts index 23f443c2..99feb735 100644 --- a/api/src/core/adapters/dbApi/kysely/createPgAgentRepository.ts +++ b/api/src/core/adapters/dbApi/kysely/createPgAgentRepository.ts @@ -37,7 +37,22 @@ export const createPgAgentRepository = (db: Kysely): AgentRepository = about: about ?? undefined, declarations: [...usersDeclarations, ...referentsDeclarations] })) - ) + ), + countAll: () => + db + .selectFrom("agents") + .select(qb => qb.fn.countAll().as("count")) + .executeTakeFirstOrThrow() + .then(({ count }) => +count), + getAllOrganizations: () => + db + .selectFrom("agents") + .where("organization", "is not", null) + .groupBy("organization") + .orderBy("organization") + .select(({ ref }) => ref("organization").$castTo().as("organization")) + .execute() + .then(results => results.map(({ organization }) => organization)) }); const makeGetAgentBuilder = (db: Kysely) => diff --git a/api/src/core/adapters/dbApi/kysely/createPgSoftwareRepository.ts b/api/src/core/adapters/dbApi/kysely/createPgSoftwareRepository.ts index e5195422..d0f998ea 100644 --- a/api/src/core/adapters/dbApi/kysely/createPgSoftwareRepository.ts +++ b/api/src/core/adapters/dbApi/kysely/createPgSoftwareRepository.ts @@ -446,7 +446,7 @@ const makeGetSoftwareBuilder = (db: Kysely) => ]); type CountForOrganisationAndSoftwareId = { - organization: string; + organization: string | null; softwareId: number; type: "user" | "referent"; count: string; @@ -490,16 +490,19 @@ const getUserAndReferentCountByOrganizationBySoftwareId = async ( .execute(); return [...softwareReferentCountBySoftwareId, ...softwareUserCountBySoftwareId].reduce( - (acc, { organization, softwareId, type, count }): UserAndReferentCountByOrganizationBySoftwareId => ({ - ...acc, - [softwareId]: { - ...(acc[softwareId] ?? {}), - [organization]: { - ...(acc[softwareId]?.[organization] ?? defaultCount), - [type]: +count + (acc, { organization, softwareId, type, count }): UserAndReferentCountByOrganizationBySoftwareId => { + const orga = organization ?? "NO_ORGANIZATION"; + return { + ...acc, + [softwareId]: { + ...(acc[softwareId] ?? {}), + [orga]: { + ...(acc[softwareId]?.[orga] ?? defaultCount), + [type]: +count + } } - } - }), + }; + }, {} as UserAndReferentCountByOrganizationBySoftwareId ); }; diff --git a/api/src/core/adapters/dbApi/kysely/kysely.database.ts b/api/src/core/adapters/dbApi/kysely/kysely.database.ts index 68d68c3d..c3635ded 100644 --- a/api/src/core/adapters/dbApi/kysely/kysely.database.ts +++ b/api/src/core/adapters/dbApi/kysely/kysely.database.ts @@ -14,7 +14,7 @@ export type Database = { type AgentsTable = { id: Generated; email: string; - organization: string; + organization: string | null; about: string | null; isPublic: boolean; }; diff --git a/api/src/core/adapters/dbApi/kysely/migrations/1733923587668_make-organization-nullable-for-agent.ts b/api/src/core/adapters/dbApi/kysely/migrations/1733923587668_make-organization-nullable-for-agent.ts new file mode 100644 index 00000000..75ca42a4 --- /dev/null +++ b/api/src/core/adapters/dbApi/kysely/migrations/1733923587668_make-organization-nullable-for-agent.ts @@ -0,0 +1,15 @@ +import type { Kysely } from "kysely"; + +export async function up(db: Kysely): Promise { + await db.schema + .alterTable("agents") + .alterColumn("organization", ac => ac.dropNotNull()) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema + .alterTable("agents") + .alterColumn("organization", ac => ac.setNotNull()) + .execute(); +} diff --git a/api/src/core/adapters/userApi.ts b/api/src/core/adapters/userApi.ts deleted file mode 100644 index ae758d1f..00000000 --- a/api/src/core/adapters/userApi.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { createKeycloakAdminApiClient } from "../../tools/keycloakAdminApiClient"; -import * as runExclusive from "run-exclusive"; -import memoize from "memoizee"; -import { UserApi } from "../ports/UserApi"; - -export type KeycloakUserApiParams = { - url: string; - adminPassword: string; - realm: string; - organizationUserProfileAttributeName: string; -}; - -const maxAge = 5 * 60 * 1000; - -export function createKeycloakUserApi(params: KeycloakUserApiParams): { - userApi: UserApi; - initializeUserApiCache: () => Promise; -} { - const { url, adminPassword, realm, organizationUserProfileAttributeName } = params; - - const keycloakAdminApiClient = createKeycloakAdminApiClient({ - url, - adminPassword, - realm - }); - - const groupRef = runExclusive.createGroupRef(); - - const userApi: UserApi = { - "updateUserEmail": runExclusive.build(groupRef, ({ userId, email }) => - keycloakAdminApiClient.updateUser({ - userId, - "body": { email } - }) - ), - "updateUserOrganization": runExclusive.build(groupRef, ({ userId, organization }) => - keycloakAdminApiClient.updateUser({ - userId, - "body": { "attributes": { [organizationUserProfileAttributeName]: organization } } - }) - ), - "getAllowedEmailRegexp": memoize( - async () => { - const attributes = await keycloakAdminApiClient.getUserProfileAttributes(); - - let emailRegExpStr: string; - - try { - emailRegExpStr = (attributes.find(({ name }) => name === "email") as any).validations.pattern - .pattern; - } catch { - throw new Error(`Can't extract RegExp from ${JSON.stringify(attributes)}`); - } - - return emailRegExpStr; - }, - { - "promise": true, - maxAge, - "preFetch": true - } - ), - "getAllOrganizations": memoize( - async () => { - const organizations = new Set(); - - let first = 0; - - // eslint-disable-next-line no-constant-condition - while (true) { - const max = 100; - - const users = await keycloakAdminApiClient.getUsers({ - first, - max - }); - - users.forEach(user => { - let organization: string; - - try { - organization = user.attributes[organizationUserProfileAttributeName][0]; - } catch { - console.log("Strange user: ", user); - - return; - } - - //NOTE: Hack, we had a bug so some organization are under - //"MESRI: Ministry of Higher Education, Research and Innovation" instead of "MESRI" - //(for example) - organization = organization.split(":")[0]; - - organizations.add(organization); - }); - - if (users.length < max) { - break; - } - - first += max; - } - - return Array.from(organizations); - }, - { - "promise": true, - maxAge, - "preFetch": true - } - ), - "getUserCount": memoize( - async () => { - let count = 0; - - let first = 0; - - // eslint-disable-next-line no-constant-condition - while (true) { - const max = 100; - - const users = await keycloakAdminApiClient.getUsers({ - first, - max - }); - - count += users.length; - - if (users.length < max) { - break; - } - - first += max; - } - - return count; - }, - { - "promise": true, - maxAge, - "preFetch": true - } - ) - }; - - const initializeUserApiCache = async () => { - const start = Date.now(); - - console.log("Starting userApi cache initialization..."); - - await Promise.all( - (["getUserCount", "getAllOrganizations", "getAllowedEmailRegexp"] as const).map(async function callee( - methodName - ) { - const f = userApi[methodName]; - - await f(); - - setInterval(f, maxAge - 10_000); - }) - ); - - console.log(`userApi cache initialization done in ${Date.now() - start}ms`); - }; - - return { userApi, initializeUserApiCache }; -} diff --git a/api/src/core/bootstrap.ts b/api/src/core/bootstrap.ts index 7cf890ee..04e9119a 100644 --- a/api/src/core/bootstrap.ts +++ b/api/src/core/bootstrap.ts @@ -1,5 +1,4 @@ import { Kysely } from "kysely"; -import { createObjectThatThrowsIfAccessed } from "../tools/createObjectThatThrowsIfAccessed"; import { comptoirDuLibreApi } from "./adapters/comptoirDuLibreApi"; import { createKyselyPgDbApi } from "./adapters/dbApi/kysely/createPgDbApi"; import { Database } from "./adapters/dbApi/kysely/kysely.database"; @@ -10,13 +9,11 @@ import { createGetSoftwareLatestVersion } from "./adapters/getSoftwareLatestVers import { getWikidataSoftware } from "./adapters/wikidata/getWikidataSoftware"; import { getWikidataSoftwareOptions } from "./adapters/wikidata/getWikidataSoftwareOptions"; import { halAdapter } from "./adapters/hal"; -import { createKeycloakUserApi, type KeycloakUserApiParams } from "./adapters/userApi"; import type { ComptoirDuLibreApi } from "./ports/ComptoirDuLibreApi"; import { DbApiV2 } from "./ports/DbApiV2"; import type { ExternalDataOrigin, GetSoftwareExternalData } from "./ports/GetSoftwareExternalData"; import type { GetSoftwareExternalDataOptions } from "./ports/GetSoftwareExternalDataOptions"; import type { GetSoftwareLatestVersion } from "./ports/GetSoftwareLatestVersion"; -import type { UserApi } from "./ports/UserApi"; import { UseCases } from "./usecases"; import { makeGetAgent } from "./usecases/getAgent"; import { makeGetSoftwareFormAutoFillDataFromExternalAndOtherSources } from "./usecases/getSoftwareFormAutoFillDataFromExternalAndOtherSources"; @@ -28,7 +25,6 @@ type DbConfig = PgDbConfig; type ParamsOfBootstrapCore = { dbConfig: DbConfig; - keycloakUserApiParams: KeycloakUserApiParams | undefined; githubPersonalAccessTokenForApiRateLimit: string; doPerPerformPeriodicalCompilation: boolean; doPerformCacheInitialization: boolean; @@ -41,7 +37,6 @@ type ParamsOfBootstrapCore = { export type Context = { paramsOfBootstrapCore: ParamsOfBootstrapCore; dbApi: DbApiV2; - userApi: UserApi; comptoirDuLibreApi: ComptoirDuLibreApi; getSoftwareExternalData: GetSoftwareExternalData; getSoftwareLatestVersion: GetSoftwareLatestVersion; @@ -63,10 +58,8 @@ export async function bootstrapCore( ): Promise<{ dbApi: DbApiV2; context: Context; useCases: UseCases }> { const { dbConfig, - keycloakUserApiParams, githubPersonalAccessTokenForApiRateLimit, doPerPerformPeriodicalCompilation, - doPerformCacheInitialization, externalSoftwareDataOrigin, initializeSoftwareFromSource, botAgentEmail, @@ -81,20 +74,9 @@ export async function bootstrapCore( const { dbApi } = getDbApiAndInitializeCache(dbConfig); - const { userApi, initializeUserApiCache } = - keycloakUserApiParams === undefined - ? { - "userApi": createObjectThatThrowsIfAccessed({ - "debugMessage": "No Keycloak server" - }), - "initializeUserApiCache": async () => {} - } - : createKeycloakUserApi(keycloakUserApiParams); - const context: Context = { "paramsOfBootstrapCore": params, dbApi, - userApi, comptoirDuLibreApi, getSoftwareExternalData, getSoftwareLatestVersion @@ -114,11 +96,6 @@ export async function bootstrapCore( getAgent: makeGetAgent({ agentRepository: dbApi.agent }) }; - if (doPerformCacheInitialization) { - console.log("Performing user cache initialization..."); - await initializeUserApiCache(); - } - if (initializeSoftwareFromSource) { if (!botAgentEmail) throw new Error("No bot agent email provided"); if (externalSoftwareDataOrigin === "HAL") { diff --git a/api/src/core/ports/DbApiV2.ts b/api/src/core/ports/DbApiV2.ts index 758f12fc..de75942e 100644 --- a/api/src/core/ports/DbApiV2.ts +++ b/api/src/core/ports/DbApiV2.ts @@ -86,7 +86,7 @@ export interface InstanceRepository { export type DbAgent = { id: number; email: string; - organization: string; + organization: string | null; about: string | undefined; isPublic: boolean; }; @@ -99,6 +99,8 @@ export interface AgentRepository { remove: (agentId: number) => Promise; getByEmail: (email: string) => Promise; getAll: () => Promise; + countAll: () => Promise; + getAllOrganizations: () => Promise; } export interface SoftwareReferentRepository { diff --git a/api/src/core/ports/UserApi.ts b/api/src/core/ports/UserApi.ts deleted file mode 100644 index 2f6f01ad..00000000 --- a/api/src/core/ports/UserApi.ts +++ /dev/null @@ -1,16 +0,0 @@ -export type UserApi = { - updateUserOrganization: (params: { userId: string; organization: string }) => Promise; - updateUserEmail: (params: { userId: string; email: string }) => Promise; - getAllowedEmailRegexp: { - (): Promise; - clear: () => void; - }; - getAllOrganizations: { - (): Promise; - clear: () => void; - }; - getUserCount: { - (): Promise; - clear: () => void; - }; -}; diff --git a/api/src/core/usecases/getAgent.ts b/api/src/core/usecases/getAgent.ts index 55e0fa16..deba461e 100644 --- a/api/src/core/usecases/getAgent.ts +++ b/api/src/core/usecases/getAgent.ts @@ -22,7 +22,7 @@ export const makeGetAgent = if (currentUser.email === email) { const agentWithoutId = { email: currentUser.email, - organization: currentUser.organization, + organization: null, about: "", isPublic: false }; diff --git a/api/src/core/usecases/getAgent.unit.test.ts b/api/src/core/usecases/getAgent.unit.test.ts index 7e1b2281..7879ff95 100644 --- a/api/src/core/usecases/getAgent.unit.test.ts +++ b/api/src/core/usecases/getAgent.unit.test.ts @@ -29,8 +29,7 @@ describe("getAgent", () => { const currentUser: User = { id: "user-id", - email: "bob@mail.com", - organization: "Some orga" + email: "bob@mail.com" }; let agentRepository: AgentRepository; @@ -52,8 +51,7 @@ describe("getAgent", () => { email: privateAgent.email, currentUser: { id: "user-id", - email: "some@mail.com", - organization: "Truc" + email: "some@mail.com" } }); @@ -71,7 +69,7 @@ describe("getAgent", () => { const expectedAgent: AgentWithId = { id: expect.any(Number), email: currentUser.email, - organization: currentUser.organization, + organization: null, isPublic: false, about: "", declarations: [] diff --git a/api/src/core/usecases/readWriteSillData/types.ts b/api/src/core/usecases/readWriteSillData/types.ts index 29921e18..3003b720 100644 --- a/api/src/core/usecases/readWriteSillData/types.ts +++ b/api/src/core/usecases/readWriteSillData/types.ts @@ -84,7 +84,7 @@ export type Agent = { //NOTE: Undefined if the agent isn't referent of at least one software // If it's the user the email is never undefined. email: string; - organization: string; + organization: string | null; declarations: (DeclarationFormData & { softwareName: string })[]; isPublic: boolean; about: string | undefined; diff --git a/api/src/env.ts b/api/src/env.ts index 5e2ac1cd..e1382a12 100644 --- a/api/src/env.ts +++ b/api/src/env.ts @@ -7,9 +7,7 @@ const zConfiguration = z.object({ .object({ "url": z.string().nonempty(), //Example: https://auth.code.gouv.fr/auth (with the /auth at the end) "realm": z.string().nonempty(), - "clientId": z.string().nonempty(), - "adminPassword": z.string().nonempty(), - "organizationUserProfileAttributeName": z.string().nonempty() + "clientId": z.string().nonempty() }) .optional(), "termsOfServiceUrl": zLocalizedString, diff --git a/api/src/rpc/createTestCaller.ts b/api/src/rpc/createTestCaller.ts index 15804b3b..0e79d4ca 100644 --- a/api/src/rpc/createTestCaller.ts +++ b/api/src/rpc/createTestCaller.ts @@ -15,8 +15,7 @@ type TestCallerConfig = { export const defaultUser: User = { id: "1", - email: "default.user@mail.com", - organization: "Default Organization" + email: "default.user@mail.com" }; export type ApiCaller = Awaited>["apiCaller"]; @@ -25,9 +24,8 @@ export const createTestCaller = async ({ user }: TestCallerConfig = { user: defa const externalSoftwareDataOrigin: ExternalDataOrigin = "wikidata"; const kyselyDb = new Kysely({ dialect: createPgDialect(testPgUrl) }); - const { context, dbApi, useCases } = await bootstrapCore({ + const { dbApi, useCases } = await bootstrapCore({ "dbConfig": { dbKind: "kysely", kyselyDb }, - "keycloakUserApiParams": undefined, "githubPersonalAccessTokenForApiRateLimit": "fake-token", "doPerPerformPeriodicalCompilation": false, "doPerformCacheInitialization": false, @@ -45,7 +43,6 @@ export const createTestCaller = async ({ user }: TestCallerConfig = { user: defa const { router } = createRouter({ useCases, dbApi, - coreContext: context, keycloakParams: undefined, redirectUrl: undefined, externalSoftwareDataOrigin, diff --git a/api/src/rpc/router.ts b/api/src/rpc/router.ts index ec746184..e89c0812 100644 --- a/api/src/rpc/router.ts +++ b/api/src/rpc/router.ts @@ -8,7 +8,6 @@ import superjson from "superjson"; import type { Equals, ReturnType } from "tsafe"; import { assert } from "tsafe/assert"; import { z } from "zod"; -import type { Context as CoreContext } from "../core"; import { DbApiV2 } from "../core/ports/DbApiV2"; import { ExternalDataOrigin, @@ -35,12 +34,7 @@ import type { User } from "./user"; export function createRouter(params: { dbApi: DbApiV2; useCases: UseCases; - coreContext: CoreContext; - keycloakParams: - | (KeycloakParams & { - organizationUserProfileAttributeName: string; - }) - | undefined; + keycloakParams: KeycloakParams | undefined; jwtClaimByUserKey: Record; termsOfServiceUrl: LocalizedString; readmeUrl: LocalizedString; @@ -51,7 +45,6 @@ export function createRouter(params: { }) { const { useCases, - coreContext, dbApi, keycloakParams, jwtClaimByUserKey, @@ -111,17 +104,6 @@ export function createRouter(params: { return () => out; })() ), - "getOrganizationUserProfileAttributeName": loggedProcedure.query( - (() => { - const { organizationUserProfileAttributeName } = keycloakParams ?? {}; - if (organizationUserProfileAttributeName === undefined) { - return () => { - throw new TRPCError({ "code": "METHOD_NOT_SUPPORTED" }); - }; - } - return () => organizationUserProfileAttributeName; - })() - ), "getSoftwares": loggedProcedure.query(() => dbApi.software.getAll()), "getInstances": loggedProcedure.query(() => dbApi.instance.getAll()), "getExternalSoftwareOptions": loggedProcedure @@ -197,7 +179,7 @@ export function createRouter(params: { if (!agent) { agentId = await dbApi.agent.add({ email: user.email, - organization: user.organization, + organization: null, about: undefined, isPublic: false }); @@ -268,7 +250,7 @@ export function createRouter(params: { if (!agent) { agentId = await dbApi.agent.add({ email: user.email, - organization: user.organization, + organization: null, about: undefined, isPublic: false }); @@ -417,31 +399,12 @@ export function createRouter(params: { const agents = await dbApi.agent.getAll(); return { agents }; }), - "updateIsAgentProfilePublic": loggedProcedure - .input( - z.object({ - "isPublic": z.boolean() - }) - ) - .mutation(async ({ ctx: { user }, input }) => { - if (user === undefined) { - throw new TRPCError({ "code": "UNAUTHORIZED" }); - } - - const { isPublic } = input; - - const agent = await dbApi.agent.getByEmail(user.email); - if (!agent) - throw new TRPCError({ - "code": "NOT_FOUND", - message: "Agent not found" - }); - await dbApi.agent.update({ ...agent, isPublic }); - }), - "updateAgentAbout": loggedProcedure + "updateAgentProfile": loggedProcedure .input( z.object({ - "about": z.string().optional() + "isPublic": z.boolean().optional(), + "about": z.string().optional(), + "newOrganization": z.string().optional() }) ) .mutation(async ({ ctx: { user }, input }) => { @@ -449,7 +412,7 @@ export function createRouter(params: { throw new TRPCError({ "code": "UNAUTHORIZED" }); } - const { about } = input; + const { isPublic, newOrganization, about } = input; const agent = await dbApi.agent.getByEmail(user.email); if (!agent) @@ -457,7 +420,12 @@ export function createRouter(params: { "code": "NOT_FOUND", message: "Agent not found" }); - await dbApi.agent.update({ ...agent, about }); + await dbApi.agent.update({ + ...agent, + ...(isPublic !== undefined ? { isPublic } : {}), + ...(newOrganization ? { organization: newOrganization } : {}), + ...(about ? { about } : {}) + }); }), "getIsAgentProfilePublic": loggedProcedure .input( @@ -484,32 +452,7 @@ export function createRouter(params: { currentUser: user }) ), - "getAllowedEmailRegexp": loggedProcedure.query(() => coreContext.userApi.getAllowedEmailRegexp()), - "getAllOrganizations": loggedProcedure.query(() => coreContext.userApi.getAllOrganizations()), - "changeAgentOrganization": loggedProcedure - .input( - z.object({ - "newOrganization": z.string() - }) - ) - .mutation(async ({ ctx: { user }, input }) => { - if (user === undefined) { - throw new TRPCError({ "code": "UNAUTHORIZED" }); - } - - assert(keycloakParams !== undefined); - - const { newOrganization } = input; - - const agent = await dbApi.agent.getByEmail(user.email); - if (!agent) - throw new TRPCError({ - "code": "NOT_FOUND", - message: "Agent not found" - }); - - await dbApi.agent.update({ ...agent, organization: newOrganization }); - }), + "getAllOrganizations": loggedProcedure.query(() => dbApi.agent.getAllOrganizations()), "updateEmail": loggedProcedure .input( z.object({ @@ -533,7 +476,7 @@ export function createRouter(params: { }); await dbApi.agent.update({ ...agent, email: newEmail }); }), - "getRegisteredUserCount": loggedProcedure.query(async () => coreContext.userApi.getUserCount()), + "getRegisteredUserCount": loggedProcedure.query(async () => dbApi.agent.countAll()), "getTotalReferentCount": loggedProcedure.query(async () => { const referentCount = await dbApi.softwareReferent.getTotalCount(); return { referentCount }; diff --git a/api/src/rpc/routes.e2e.test.ts b/api/src/rpc/routes.e2e.test.ts index cf113269..4dad4169 100644 --- a/api/src/rpc/routes.e2e.test.ts +++ b/api/src/rpc/routes.e2e.test.ts @@ -17,37 +17,37 @@ import { ApiCaller, createTestCaller, defaultUser } from "./createTestCaller"; const softwareFormData = createSoftwareFormData(); const declarationFormData = createDeclarationFormData(); -describe("stripNullOrUndefined", () => { - it("removes null and undefined values", () => { - const stripped = stripNullOrUndefinedValues({ - "a": null, - "b": undefined, - "c": 0, - "d": 1, - "e": "", - "f": "yolo" - }); - expect(stripped.hasOwnProperty("a")).toBe(false); - expect(stripped.hasOwnProperty("b")).toBe(false); - expect(stripped).toStrictEqual({ "c": 0, "d": 1, "e": "", "f": "yolo" }); - }); -}); - describe("RPC e2e tests", () => { let apiCaller: ApiCaller; let kyselyDb: Kysely; + describe("stripNullOrUndefined", () => { + it("removes null and undefined values", () => { + const stripped = stripNullOrUndefinedValues({ + "a": null, + "b": undefined, + "c": 0, + "d": 1, + "e": "", + "f": "yolo" + }); + expect(stripped.hasOwnProperty("a")).toBe(false); + expect(stripped.hasOwnProperty("b")).toBe(false); + expect(stripped).toStrictEqual({ "c": 0, "d": 1, "e": "", "f": "yolo" }); + }); + }); + describe("getAgents - wrong paths", () => { it("fails with UNAUTHORIZED if user is not logged in", async () => { ({ apiCaller, kyselyDb } = await createTestCaller({ user: undefined })); - expect(apiCaller.getAgents()).rejects.toThrow("UNAUTHORIZED"); + await expect(apiCaller.getAgents()).rejects.toThrow("UNAUTHORIZED"); }); }); describe("createUserOrReferent - Wrong paths", () => { it("fails with UNAUTHORIZED if user is not logged in", async () => { ({ apiCaller, kyselyDb } = await createTestCaller({ user: undefined })); - expect( + await expect( apiCaller.createUserOrReferent({ formData: declarationFormData, softwareId: 123 @@ -57,7 +57,7 @@ describe("RPC e2e tests", () => { it("fails when software is not found in SILL", async () => { ({ apiCaller, kyselyDb } = await createTestCaller()); - expect( + await expect( apiCaller.createUserOrReferent({ formData: declarationFormData, softwareId: 404 @@ -69,7 +69,7 @@ describe("RPC e2e tests", () => { describe("createSoftware - Wrong paths", () => { it("fails with UNAUTHORIZED if user is not logged in", async () => { ({ apiCaller, kyselyDb } = await createTestCaller({ user: undefined })); - expect( + await expect( apiCaller.createSoftware({ formData: softwareFormData }) @@ -114,7 +114,7 @@ describe("RPC e2e tests", () => { expectToMatchObject(agent, { id: expect.any(Number), email: defaultUser.email, - organization: defaultUser.organization + organization: null }); const softwareRows = await getSoftwareRows(); @@ -162,7 +162,7 @@ describe("RPC e2e tests", () => { expect(agents).toHaveLength(1); expectToMatchObject(agents[0], { "email": defaultUser.email, - "organization": defaultUser.organization + "organization": null }); }); diff --git a/api/src/rpc/start.ts b/api/src/rpc/start.ts index ddd7410e..8ad1940f 100644 --- a/api/src/rpc/start.ts +++ b/api/src/rpc/start.ts @@ -29,8 +29,6 @@ export async function startRpcService(params: { url: string; realm: string; clientId: string; - adminPassword: string; - organizationUserProfileAttributeName: string; }; termsOfServiceUrl: LocalizedString; readmeUrl: LocalizedString; @@ -68,24 +66,11 @@ export async function startRpcService(params: { const kyselyDb = new Kysely({ dialect: createPgDialect(databaseUrl) }); - const { - dbApi, - context: coreContext, - useCases - } = await bootstrapCore({ + const { dbApi, useCases } = await bootstrapCore({ "dbConfig": { "dbKind": "kysely", "kyselyDb": kyselyDb }, - "keycloakUserApiParams": - keycloakParams === undefined - ? undefined - : { - "url": keycloakParams.url, - "realm": keycloakParams.realm, - "adminPassword": keycloakParams.adminPassword, - "organizationUserProfileAttributeName": keycloakParams.organizationUserProfileAttributeName - }, githubPersonalAccessTokenForApiRateLimit, "doPerPerformPeriodicalCompilation": true, // "doPerPerformPeriodicalCompilation": !isDevEnvironnement && redirectUrl === undefined, @@ -118,7 +103,6 @@ export async function startRpcService(params: { dbApi, getSoftwareExternalDataOptions, getSoftwareExternalData, - coreContext, jwtClaimByUserKey, "keycloakParams": keycloakParams === undefined @@ -126,8 +110,7 @@ export async function startRpcService(params: { : { "url": keycloakParams.url, "realm": keycloakParams.realm, - "clientId": keycloakParams.clientId, - "organizationUserProfileAttributeName": keycloakParams.organizationUserProfileAttributeName + "clientId": keycloakParams.clientId }, termsOfServiceUrl, readmeUrl, diff --git a/api/src/rpc/user.ts b/api/src/rpc/user.ts index d277302d..35a4ed77 100644 --- a/api/src/rpc/user.ts +++ b/api/src/rpc/user.ts @@ -5,13 +5,11 @@ import { parsedJwtPayloadToUser } from "../tools/parsedJwtPayloadToUser"; export type User = { id: string; email: string; - organization: string; }; const zUser = z.object({ "id": z.string(), - "email": z.string(), - "organization": z.string() + "email": z.string() }); { diff --git a/deployments/docker-compose-example/.env.sample b/deployments/docker-compose-example/.env.sample index ad8d982f..923f6798 100644 --- a/deployments/docker-compose-example/.env.sample +++ b/deployments/docker-compose-example/.env.sample @@ -1,8 +1,6 @@ SILL_KEYCLOAK_URL=http://localhost:8081/auth SILL_KEYCLOAK_REALM=codegouv SILL_KEYCLOAK_CLIENT_ID=sill -SILL_KEYCLOAK_ADMIN_PASSWORD=xxx -SILL_KEYCLOAK_ORGANIZATION_USER_PROFILE_ATTRIBUTE_NAME=agencyName SILL_README_URL=https://raw.githubusercontent.com/codegouvfr/sill/refs/heads/main/README.md SILL_TERMS_OF_SERVICE_URL=https://code.gouv.fr/sill/tos_fr.md SILL_JWT_ID=sub diff --git a/docs/setting-up-a-development-environment.md b/docs/setting-up-a-development-environment.md index fcae20ca..f56a5558 100644 --- a/docs/setting-up-a-development-environment.md +++ b/docs/setting-up-a-development-environment.md @@ -10,19 +10,11 @@ It is much easier to navigate the code with VSCode (We recommend the free distri ## Run local databases -To launch local databses, you can quickly do that by running the following command +To launch local databases, you can quickly do that by running the following command `docker compose -f docker-compose.ressources.yml up` -We are almost finished ! Go to http://localhost:8081/auth/ to set up a small manual config (that can't be automated yet). - -Login with admin credentials. In our dev environment we used `admin` for both `username` and `password`. - -Go to `userprofile` tab, choose the `JSON Editor` tab. - -Then copy paste the content of the file `keycloak-dev-user-profile.json` located in the root folder into the text field. - -Save an your are good to go ! +This will also start the keycloak server, with a basic configuration (at the root of the project:`keycloak-dev-realm.json`). ## Defining the sill-api parameter @@ -35,11 +27,10 @@ Makes sure to put the name of your SSH key and the private key (generated when y ### Option 1: Using a .env file ``` -SILL_KEYCLOAK_URL=https://auth.code.gouv.fr/auth +SILL_KEYCLOAK_URL=http://localhost:8081/auth SILL_KEYCLOAK_REALM=codegouv SILL_KEYCLOAK_CLIENT_ID=sill SILL_KEYCLOAK_ADMIN_PASSWORD=xxxxxx -SILL_KEYCLOAK_ORGANIZATION_USER_PROFILE_ATTRIBUTE_NAME=agencyName SILL_README_URL=https://raw.githubusercontent.com/codegouvfr/sill/refs/heads/main/docs/sill.md SILL_TERMS_OF_SERVICE_URL=https://code.gouv.fr/sill/tos_fr.md SILL_JWT_ID=sub @@ -61,7 +52,6 @@ export SILL_KEYCLOAK_URL=https://auth.code.gouv.fr/auth export SILL_KEYCLOAK_REALM=codegouv export SILL_KEYCLOAK_CLIENT_ID=sill export SILL_KEYCLOAK_ADMIN_PASSWORD=xxxxxx -export SILL_KEYCLOAK_ORGANIZATION_USER_PROFILE_ATTRIBUTE_NAME=agencyName export SILL_README_URL=https://raw.githubusercontent.com/codegouvfr/sill/refs/heads/main/docs/sill.md export SILL_TERMS_OF_SERVICE_URL=https://code.gouv.fr/sill/tos_fr.md export SILL_JWT_ID=sub @@ -92,8 +82,7 @@ export CONFIGURATION=$(cat << EOF "url": "https://auth.code.gouv.fr/auth", "realm": "codegouv", "clientId": "sill", - "adminPassword": "xxxxxx", - "organizationUserProfileAttributeName": "agencyName" + "adminPassword": "xxxxxx" }, "readmeUrl": "https://raw.githubusercontent.com/codegouvfr/sill/refs/heads/main/docs/sill.md", "termsOfServiceUrl": "https://code.gouv.fr/sill/tos_fr.md", diff --git a/keycloak-dev-realm.json b/keycloak-dev-realm.json index 6f898ffa..8c0e2c2e 100644 --- a/keycloak-dev-realm.json +++ b/keycloak-dev-realm.json @@ -1869,8 +1869,7 @@ "parRequestUriLifespan": "60", "clientSessionMaxLifespan": "0", "clientOfflineSessionIdleTimeout": "0", - "cibaInterval": "5", - "userProfile": "{\"attributes\":[{\"name\":\"username\",\"displayName\":\"${username}\",\"validations\":{\"length\":{\"min\":3,\"max\":255},\"username-prohibited-characters\":{}}},{\"name\":\"email\",\"displayName\":\"${email}\",\"validations\":{\"email\":{},\"length\":{\"max\":255},\"pattern\":{\"pattern\":\"^.*@.*$\"}}},{\"name\":\"firstName\",\"displayName\":\"${firstName}\",\"required\":{\"roles\":[\"user\",\"admin\"]},\"permissions\":{\"view\":[\"user\",\"admin\"],\"edit\":[\"user\",\"admin\"]}},{\"name\":\"lastName\",\"displayName\":\"${lastName}\",\"required\":{\"roles\":[\"user\",\"admin\"]},\"permissions\":{\"view\":[\"user\",\"admin\"],\"edit\":[\"user\",\"admin\"]}},{\"name\":\"agencyName\",\"displayName\":\"Agency Name\",\"required\":{\"roles\":[\"user\",\"admin\"]},\"permissions\":{\"view\":[\"user\",\"admin\"],\"edit\":[\"user\",\"admin\"]},\"validations\":{}}]}" + "cibaInterval": "5" }, "keycloakVersion": "18.0.2", "userManagedAccessAllowed": false, diff --git a/keycloak-dev-user-profile.json b/keycloak-dev-user-profile.json deleted file mode 100644 index 87686349..00000000 --- a/keycloak-dev-user-profile.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "attributes": [ - { - "name": "email", - "displayName": "${email}", - "validations": { - "email": {}, - "length": { - "max": 255 - }, - "pattern": { - "pattern": "^.*@.*$" - } - }, - "permissions": { - "view": [], - "edit": [] - }, - "selector": { - "scopes": [] - } - }, - { - "selector": { - "scopes": [] - }, - "permissions": { - "view": [ - "user", - "admin" - ], - "edit": [ - "user", - "admin" - ] - }, - "name": "agencyName", - "displayName": "${organization}", - "validations": {}, - "required": { - "roles": [ - "user" - ], - "scopes": [] - } - } - ] -} \ No newline at end of file diff --git a/web/src/core/adapter/sillApi.ts b/web/src/core/adapter/sillApi.ts index 99d6ada6..2d2dcf74 100644 --- a/web/src/core/adapter/sillApi.ts +++ b/web/src/core/adapter/sillApi.ts @@ -51,12 +51,6 @@ export function createSillApi(params: { "getOidcParams": memoize(() => trpcClient.getOidcParams.query(), { "promise": true }), - "getOrganizationUserProfileAttributeName": memoize( - () => trpcClient.getOrganizationUserProfileAttributeName.query(), - { - "promise": true - } - ), "getSoftwares": memoize(() => trpcClient.getSoftwares.query(), { "promise": true }), @@ -128,15 +122,6 @@ export function createSillApi(params: { return out; }, "getAgents": memoize(() => trpcClient.getAgents.query(), { "promise": true }), - "changeAgentOrganization": async params => { - const out = await trpcClient.changeAgentOrganization - .mutate(params) - .catch(errorHandler); - - sillApi.getAgents.clear(); - - return out; - }, "updateEmail": async params => { const out = await trpcClient.updateEmail.mutate(params).catch(errorHandler); @@ -144,9 +129,6 @@ export function createSillApi(params: { return out; }, - "getAllowedEmailRegexp": memoize(() => trpcClient.getAllowedEmailRegexp.query(), { - "promise": true - }), "getAllOrganizations": memoize(() => trpcClient.getAllOrganizations.query(), { "promise": true }), @@ -164,13 +146,8 @@ export function createSillApi(params: { "getAgent": params => trpcClient.getAgent.query(params), "getIsAgentProfilePublic": params => trpcClient.getIsAgentProfilePublic.query(params), - "updateAgentAbout": params => - trpcClient.updateAgentAbout.mutate(params).catch(errorHandler), - "updateIsAgentProfilePublic": async params => { - await trpcClient.updateIsAgentProfilePublic - .mutate(params) - .catch(errorHandler); - + "updateAgentProfile": async params => { + await trpcClient.updateAgentProfile.mutate(params).catch(errorHandler); sillApi.getAgents.clear(); }, "unreferenceSoftware": async params => { diff --git a/web/src/core/adapter/sillApiMock.ts b/web/src/core/adapter/sillApiMock.ts index b3e59c49..3a4b3455 100644 --- a/web/src/core/adapter/sillApiMock.ts +++ b/web/src/core/adapter/sillApiMock.ts @@ -25,12 +25,6 @@ export const sillApi: SillApi = { }), { "promise": true } ), - "getOrganizationUserProfileAttributeName": memoize( - async () => { - throw new Error("not implemented"); - }, - { "promise": true } - ), "getSoftwares": memoize(() => Promise.resolve([...softwares]), { "promise": true }), "getInstances": memoize( async () => { @@ -208,13 +202,9 @@ export const sillApi: SillApi = { "promise": true } ), - "changeAgentOrganization": async ({ newOrganization }) => { - console.log(`Update organization -> ${newOrganization}`); - }, "updateEmail": async ({ newEmail }) => { console.log(`Update email ${newEmail}`); }, - "getAllowedEmailRegexp": memoize(async () => "/gouv.fr$/", { "promise": true }), "getAllOrganizations": memoize(async () => ["DINUM", "CNRS", "ESR"], { "promise": true }), @@ -237,15 +227,12 @@ export const sillApi: SillApi = { "isPublic": false } }), - "updateAgentAbout": async ({ about }) => { - console.log(`Update about ${about}`); + "updateAgentProfile": async input => { + console.log(`Update agent profile :`, input); }, "getIsAgentProfilePublic": async ({ email }) => ({ "isPublic": email.startsWith("public") }), - "updateIsAgentProfilePublic": async ({ isPublic }) => { - console.log(`Update isPublic ${isPublic}`); - }, "unreferenceSoftware": async ({ reason }) => { console.log(`Unreference software ${reason}`); } diff --git a/web/src/core/bootstrap.ts b/web/src/core/bootstrap.ts index 56a5653d..e95693d5 100644 --- a/web/src/core/bootstrap.ts +++ b/web/src/core/bootstrap.ts @@ -95,7 +95,6 @@ export async function bootstrapCore( isUserInitiallyLoggedIn, jwtClaimByUserKey, "user": { - "organization": "DINUM", "email": "joseph.garrone@code.gouv.fr", "id": "xxxxx" } @@ -150,7 +149,8 @@ export async function bootstrapCore( dispatch(usecases.externalDataOrigin.protectedThunks.initialize()), dispatch(usecases.softwareCatalog.protectedThunks.initialize()), dispatch(usecases.generalStats.protectedThunks.initialize()), - dispatch(usecases.redirect.protectedThunks.initialize()) + dispatch(usecases.redirect.protectedThunks.initialize()), + dispatch(usecases.userAuthentication.protectedThunks.initialize()) ]); return { core }; diff --git a/web/src/core/ports/SillApi.ts b/web/src/core/ports/SillApi.ts index 236c5f22..2e008c68 100644 --- a/web/src/core/ports/SillApi.ts +++ b/web/src/core/ports/SillApi.ts @@ -23,12 +23,6 @@ export type SillApi = { >; clear: () => void; }; - getOrganizationUserProfileAttributeName: { - (params: TrpcRouterInput["getOrganizationUserProfileAttributeName"]): Promise< - TrpcRouterOutput["getOrganizationUserProfileAttributeName"] - >; - clear: () => void; - }; getSoftwares: { (params: TrpcRouterInput["getSoftwares"]): Promise< TrpcRouterOutput["getSoftwares"] @@ -69,18 +63,10 @@ export type SillApi = { (params: TrpcRouterInput["getAgents"]): Promise; clear: () => void; }; - changeAgentOrganization: ( - params: TrpcRouterInput["changeAgentOrganization"] - ) => Promise; - updateEmail: ( params: TrpcRouterInput["updateEmail"] ) => Promise; - getAllowedEmailRegexp: { - (): Promise; - clear: () => void; - }; getAllOrganizations: { (params: TrpcRouterInput["getAllOrganizations"]): Promise< TrpcRouterOutput["getAllOrganizations"] @@ -114,12 +100,9 @@ export type SillApi = { getAgent: ( params: TrpcRouterInput["getAgent"] ) => Promise; - updateAgentAbout: ( - params: TrpcRouterInput["updateAgentAbout"] - ) => Promise; - updateIsAgentProfilePublic: ( - params: TrpcRouterInput["updateIsAgentProfilePublic"] - ) => Promise; + updateAgentProfile: ( + params: TrpcRouterInput["updateAgentProfile"] + ) => Promise; unreferenceSoftware: ( params: TrpcRouterInput["unreferenceSoftware"] ) => Promise; diff --git a/web/src/core/usecases/instanceForm/state.ts b/web/src/core/usecases/instanceForm/state.ts index 855f32ce..2e12dbe2 100644 --- a/web/src/core/usecases/instanceForm/state.ts +++ b/web/src/core/usecases/instanceForm/state.ts @@ -35,7 +35,7 @@ namespace State { | { type: "navigated from software form"; justRegisteredSoftwareSillId: number; - userOrganization: string; + userOrganization: string | null; } | undefined; step1Data: diff --git a/web/src/core/usecases/instanceForm/thunks.ts b/web/src/core/usecases/instanceForm/thunks.ts index 8850e94a..f2ed3ef0 100644 --- a/web/src/core/usecases/instanceForm/thunks.ts +++ b/web/src/core/usecases/instanceForm/thunks.ts @@ -80,6 +80,7 @@ export const thunks = { assert(oidc.isUserLoggedIn); const user = await getUser(); + const { agent } = await sillApi.getAgent({ email: user.email }); dispatch( actions.initializationCompleted({ @@ -91,7 +92,7 @@ export const thunks = { "type": "navigated from software form", "justRegisteredSoftwareSillId": software.softwareId, - "userOrganization": user.organization + "userOrganization": agent.organization } }) ); diff --git a/web/src/core/usecases/softwareUserAndReferent/state.ts b/web/src/core/usecases/softwareUserAndReferent/state.ts index 09f61ab9..22e85049 100644 --- a/web/src/core/usecases/softwareUserAndReferent/state.ts +++ b/web/src/core/usecases/softwareUserAndReferent/state.ts @@ -19,7 +19,7 @@ export namespace State { }; export type SoftwareUser = { - organization: string; + organization: string | null; usecaseDescription: string; /** NOTE: undefined if the software is not of type desktop/mobile */ os: ApiTypes.Os | undefined; @@ -30,7 +30,7 @@ export namespace State { export type SoftwareReferent = { email: string; - organization: string; + organization: string | null; isTechnicalExpert: boolean; usecaseDescription: string; /** NOTE: Can be not undefined only if cloud */ diff --git a/web/src/core/usecases/userAccountManagement/state.ts b/web/src/core/usecases/userAccountManagement/state.ts index 40b16929..0e4a5da7 100644 --- a/web/src/core/usecases/userAccountManagement/state.ts +++ b/web/src/core/usecases/userAccountManagement/state.ts @@ -15,10 +15,9 @@ namespace State { export type Ready = { stateDescription: "ready"; accountManagementUrl: string | undefined; - allowedEmailRegexpStr: string; allOrganizations: string[]; organization: { - value: string; + value: string | null; isBeingUpdated: boolean; }; email: { @@ -33,6 +32,17 @@ namespace State { }; } +type UpdateFieldPayload = + | { + fieldName: "organization"; + value: string; + } + | { + fieldName: "aboutAndIsPublic"; + about: string; + isPublic: boolean; + }; + export const { reducer, actions } = createUsecaseActions({ name, "initialState": id({ @@ -52,8 +62,7 @@ export const { reducer, actions } = createUsecaseActions({ }: { payload: { accountManagementUrl: string | undefined; - allowedEmailRegexpStr: string; - organization: string; + organization: string | null; email: string; allOrganizations: string[]; about: string; @@ -63,7 +72,6 @@ export const { reducer, actions } = createUsecaseActions({ ) => { const { accountManagementUrl, - allowedEmailRegexpStr, organization, email, allOrganizations, @@ -74,7 +82,6 @@ export const { reducer, actions } = createUsecaseActions({ return { "stateDescription": "ready", accountManagementUrl, - allowedEmailRegexpStr, allOrganizations, "organization": { "value": organization, @@ -95,23 +102,7 @@ export const { reducer, actions } = createUsecaseActions({ } }; }, - "updateFieldStarted": ( - state, - { - payload - }: { - payload: - | { - fieldName: "organization" | "email"; - value: string; - } - | { - fieldName: "aboutAndIsPublic"; - about: string; - isPublic: boolean; - }; - } - ) => { + "updateFieldStarted": (state, { payload }: { payload: UpdateFieldPayload }) => { assert(state.stateDescription === "ready"); if (payload.fieldName === "aboutAndIsPublic") { @@ -129,16 +120,7 @@ export const { reducer, actions } = createUsecaseActions({ "isBeingUpdated": true }; }, - "updateFieldCompleted": ( - state, - { - payload - }: { - payload: { - fieldName: "organization" | "email" | "aboutAndIsPublic"; - }; - } - ) => { + "updateFieldCompleted": (state, { payload }: { payload: UpdateFieldPayload }) => { const { fieldName } = payload; assert(state.stateDescription === "ready"); diff --git a/web/src/core/usecases/userAccountManagement/thunks.ts b/web/src/core/usecases/userAccountManagement/thunks.ts index 8f201ad3..74d7c2ca 100644 --- a/web/src/core/usecases/userAccountManagement/thunks.ts +++ b/web/src/core/usecases/userAccountManagement/thunks.ts @@ -23,25 +23,18 @@ export const thunks = { const user = await getUser(); - const [ - { keycloakParams }, - allowedEmailRegexpStr, - allOrganizations, - { - agent: { about = "", isPublic } - } - ] = await Promise.all([ + const [{ keycloakParams }, allOrganizations, { agent }] = await Promise.all([ sillApi.getOidcParams(), - sillApi.getAllowedEmailRegexp(), sillApi.getAllOrganizations(), sillApi.getAgent({ "email": user.email }) ]); + const { about = "", isPublic, organization } = agent; + dispatch( actions.initialized({ - allowedEmailRegexpStr, "email": user.email, - "organization": user.organization, + "organization": organization, "accountManagementUrl": keycloakParams === undefined ? undefined @@ -65,7 +58,7 @@ export const thunks = { ( params: | { - fieldName: "organization" | "email"; + fieldName: "organization"; value: string; } | { @@ -86,42 +79,22 @@ export const thunks = { assert(oidc.isUserLoggedIn); switch (params.fieldName) { - case "organization": - await sillApi.changeAgentOrganization({ + case "organization": { + await sillApi.updateAgentProfile({ "newOrganization": params.value }); - await oidc.renewTokens(); - break; - case "email": - await sillApi.updateEmail({ "newEmail": params.value }); - await oidc.renewTokens(); break; - case "aboutAndIsPublic": - await Promise.all([ - (async () => { - if (state.aboutAndIsPublic.about === params.about) { - return; - } - - await sillApi.updateAgentAbout({ - "about": params.about || undefined - }); - })(), - (async () => { - if (state.aboutAndIsPublic.isPublic === params.isPublic) { - return; - } - - await sillApi.updateIsAgentProfilePublic({ - "isPublic": params.isPublic - }); - })() - ]); - + } + case "aboutAndIsPublic": { + await sillApi.updateAgentProfile({ + "about": params.about || undefined, + "isPublic": params.isPublic + }); break; + } } - dispatch(actions.updateFieldCompleted({ "fieldName": params.fieldName })); + dispatch(actions.updateFieldCompleted(params)); }, "getAccountManagementUrl": () => diff --git a/web/src/core/usecases/userAuthentication/index.ts b/web/src/core/usecases/userAuthentication/index.ts index 9d9048fe..6e655c5c 100644 --- a/web/src/core/usecases/userAuthentication/index.ts +++ b/web/src/core/usecases/userAuthentication/index.ts @@ -1,2 +1,3 @@ export * from "./state"; export * from "./thunks"; +export * from "./selectors"; diff --git a/web/src/core/usecases/userAuthentication/selectors.ts b/web/src/core/usecases/userAuthentication/selectors.ts new file mode 100644 index 00000000..24348334 --- /dev/null +++ b/web/src/core/usecases/userAuthentication/selectors.ts @@ -0,0 +1,9 @@ +import type { State as RootState } from "core/bootstrap"; +import { name } from "./state"; + +const currentAgent = (rootState: RootState) => { + const state = rootState[name]; + return { currentAgent: state.currentAgent }; +}; + +export const selectors = { currentAgent }; diff --git a/web/src/core/usecases/userAuthentication/state.ts b/web/src/core/usecases/userAuthentication/state.ts index 7bea692b..cbedea62 100644 --- a/web/src/core/usecases/userAuthentication/state.ts +++ b/web/src/core/usecases/userAuthentication/state.ts @@ -1,3 +1,66 @@ +import { Agent } from "api/dist/src/lib/ApiTypes"; +import { createUsecaseActions } from "redux-clean-architecture"; +import { assert } from "tsafe/assert"; +import { id } from "tsafe/id"; +import { actions as usersAccountManagementActions } from "../userAccountManagement"; + export const name = "userAuthentication"; -export const reducer = null; +export type State = State.NotInitialized | State.Ready; + +export namespace State { + export type NotInitialized = { + stateDescription: "not initialized"; + isInitializing: boolean; + currentAgent: null; + }; + + export type Ready = { + stateDescription: "ready"; + currentAgent: Agent | null; + }; +} + +export const { reducer, actions } = createUsecaseActions({ + name, + "initialState": id({ + "stateDescription": "not initialized", + "currentAgent": null, + "isInitializing": false + }), + "reducers": { + "initializationStarted": state => { + assert(state.stateDescription === "not initialized"); + }, + "initialized": (_, action: { payload: { agent: Agent | null } }) => ({ + stateDescription: "ready", + currentAgent: action.payload.agent + }) + }, + extraReducers: builder => { + builder.addCase( + usersAccountManagementActions.updateFieldCompleted, + (state, action) => { + if (!state.currentAgent) return state; + if (action.payload.fieldName === "organization") { + return { + ...state, + currentAgent: { + ...state.currentAgent, + organization: action.payload.value + } + }; + } + + return { + ...state, + currentAgent: { + ...state.currentAgent, + about: action.payload.about, + isPublic: action.payload.isPublic + } + }; + } + ); + } +}); diff --git a/web/src/core/usecases/userAuthentication/thunks.ts b/web/src/core/usecases/userAuthentication/thunks.ts index 82b51a16..82fc1aca 100644 --- a/web/src/core/usecases/userAuthentication/thunks.ts +++ b/web/src/core/usecases/userAuthentication/thunks.ts @@ -1,12 +1,27 @@ import type { Thunks } from "core/bootstrap"; import { assert } from "tsafe/assert"; +import { name, actions } from "./state"; + +export const protectedThunks = { + initialize: + () => + async (dispatch, getState, { sillApi, oidc, getUser }) => { + console.log("OIDC : is user logged in ?", oidc.isUserLoggedIn); + if (!oidc.isUserLoggedIn) return; + const state = getState()[name]; + if (state.stateDescription === "ready" || state.isInitializing) return; + dispatch(actions.initializationStarted()); + const user = await getUser(); + const { agent } = await sillApi.getAgent({ "email": user.email }); + dispatch(actions.initialized({ agent })); + } +} satisfies Thunks; export const thunks = { "getIsUserLoggedIn": () => (...args): boolean => { const [, , { oidc }] = args; - return oidc.isUserLoggedIn; }, "login": diff --git a/web/src/core/usecases/userProfile/state.ts b/web/src/core/usecases/userProfile/state.ts index 3a9b58f2..c99490e6 100644 --- a/web/src/core/usecases/userProfile/state.ts +++ b/web/src/core/usecases/userProfile/state.ts @@ -13,7 +13,7 @@ export namespace State { export type Ready = { stateDescription: "ready"; email: string; - organization: string; + organization: string | null; about: string | undefined; isHimself: boolean; declarations: ApiTypes.Agent["declarations"]; @@ -40,7 +40,7 @@ export const { reducer, actions } = createUsecaseActions({ }: { payload: { email: string; - organization: string; + organization: string | null; about: string | undefined; isHimself: boolean; declarations: ApiTypes.Agent["declarations"]; diff --git a/web/src/ui/App.tsx b/web/src/ui/App.tsx index abb2d5b7..e6057488 100644 --- a/web/src/ui/App.tsx +++ b/web/src/ui/App.tsx @@ -4,7 +4,7 @@ import { useRoute } from "ui/routes"; import { Header } from "ui/shared/Header"; import { Footer } from "ui/shared/Footer"; import { declareComponentKeys } from "i18nifty"; -import { useCore } from "core"; +import { useCore, useCoreState } from "core"; import { RouteProvider } from "ui/routes"; import { evtLang } from "ui/i18n"; import { createCoreProvider } from "core"; @@ -17,6 +17,7 @@ import { keyframes } from "tss-react"; import { LoadingFallback, loadingFallbackClassName } from "ui/shared/LoadingFallback"; import { useDomRect } from "powerhooks/useDomRect"; import { apiUrl, appUrl, appPath } from "urls"; +import { PromptForOrganization } from "./shared/PromptForOrganization"; const { CoreProvider } = createCoreProvider({ apiUrl, @@ -69,6 +70,7 @@ function ContextualizedApp() { const route = useRoute(); const { userAuthentication, sillApiVersion } = useCore().functions; + const { currentAgent } = useCoreState("userAuthentication", "currentAgent"); const headerUserAuthenticationApi = useConst(() => userAuthentication.getIsUserLoggedIn() @@ -116,6 +118,10 @@ function ContextualizedApp() { return ; } + if (currentAgent && !currentAgent.organization) { + return ; + } + return ( ()( { languages, fallbackLanguage }, { @@ -682,7 +683,7 @@ const { }, "UserProfile": { "agent profile": ({ email, organization }) => - `Profile of ${email} - ${organization}`, + `Profile of ${email}${organization ? ` - ${organization}` : ""}`, "send email": "Send an email to this person", "no description": "The user has not written a description yet", "edit my profile": "Edit my profile", @@ -735,6 +736,13 @@ const { }, "SmartLogo": { "software logo": "Software logo" + }, + "PromptForOrganization": { + "title": "Please provide an organization", + "organization is required": + "You need to provide an organization to be able to use Sill. Please provide one below.", + "update": "Update", + "organization": "Organization" } }, "fr": { @@ -1383,7 +1391,7 @@ const { }, "UserProfile": { "agent profile": ({ email, organization }) => - `Profile de ${email} - ${organization}`, + `Profile de ${email}${organization ? ` - ${organization}` : ""}`, "send email": "Envoyer un courrier à l'agent", "no description": "Cet agent n'a pas renségné son profil ou son profil n'est pas visible par les autres agents.", @@ -1437,6 +1445,13 @@ const { }, "SmartLogo": { "software logo": "Logo du logiciel" + }, + "PromptForOrganization": { + "title": "Organisation requise", + "organization is required": + "Vous devez préciser l'organisation à laquelle vous appartenez", + "update": "Mettre à jour", + "organization": "Organisation" } /* spell-checker: enable */ } diff --git a/web/src/ui/i18n/useGetOrganizationFullName.ts b/web/src/ui/i18n/useGetOrganizationFullName.ts index 1053053e..b45ab37d 100644 --- a/web/src/ui/i18n/useGetOrganizationFullName.ts +++ b/web/src/ui/i18n/useGetOrganizationFullName.ts @@ -457,7 +457,8 @@ export function useGetOrganizationFullName() { const { resolveLocalizedString } = useResolveLocalizedString(); const getOrganizationFullName = useCallback( - (organization: string) => { + (organization: string | null) => { + if (!organization) return ""; const organizationFullName = organizationFullNameByAcronym[organization]; if (organizationFullName === undefined) { diff --git a/web/src/ui/pages/account/Account.tsx b/web/src/ui/pages/account/Account.tsx index 7e84d6b2..2a5650f8 100644 --- a/web/src/ui/pages/account/Account.tsx +++ b/web/src/ui/pages/account/Account.tsx @@ -1,15 +1,14 @@ -import { useEffect, useState, useMemo } from "react"; +import { useEffect, useState } from "react"; import { tss } from "tss-react"; import { fr } from "@codegouvfr/react-dsfr"; -import { useTranslation, useGetOrganizationFullName, evtLang } from "ui/i18n"; +import { useTranslation, evtLang } from "ui/i18n"; import { assert } from "tsafe/assert"; import { Equals } from "tsafe"; import { declareComponentKeys } from "i18nifty"; import { useCore, useCoreState } from "core"; import { Input } from "@codegouvfr/react-dsfr/Input"; -import { z } from "zod"; -import { AutocompleteFreeSoloInput } from "ui/shared/AutocompleteFreeSoloInput"; import { Button } from "@codegouvfr/react-dsfr/Button"; +import { OrganizationField } from "../../shared/PromptForOrganization"; import type { PageRoute } from "./route"; import { LoadingFallback } from "ui/shared/LoadingFallback"; import CircularProgress from "@mui/material/CircularProgress"; @@ -55,39 +54,19 @@ function AccountReady(props: { className?: string }) { const { t } = useTranslation({ Account }); - const { - allOrganizations, - email, - organization, - aboutAndIsPublic, - doSupportAccountManagement, - allowedEmailRegExp - } = (function useClosure() { - const state = useCoreState("userAccountManagement", "main"); + const { email, aboutAndIsPublic, doSupportAccountManagement } = + (function useClosure() { + const state = useCoreState("userAccountManagement", "main"); - assert(state !== undefined); + assert(state !== undefined); - const { allowedEmailRegexpStr, ...rest } = state; - - const allowedEmailRegExp = useMemo( - () => new RegExp(allowedEmailRegexpStr), - [allowedEmailRegexpStr] - ); - - return { - ...rest, - allowedEmailRegExp - }; - })(); + return state; + })(); const { isDark } = useIsDark(); const { userAccountManagement } = useCore().functions; - const [emailInputValue, setEmailInputValue] = useState(email.value); - /* prettier-ignore */ - const [, setOrganizationInputValue] = useState(organization.value); - const evtAboutInputValue = useConst(() => Evt.create(aboutAndIsPublic.about)); /* prettier-ignore */ @@ -115,86 +94,34 @@ function AccountReady(props: { className?: string }) { evtAboutInputValue.state = text; }, []); - const emailInputValueErrorMessage = (() => { - try { - z.string().email().parse(emailInputValue); - } catch { - return t("not a valid email"); - } - - if (!allowedEmailRegExp.test(emailInputValue)) { - return t("email domain not allowed", { - "domain": emailInputValue.split("@")[1] - }); - } - - return undefined; - })(); - const { classes, cx, css } = useStyles(); - const { getOrganizationFullName } = useGetOrganizationFullName(); - return (

{t("title")}

-
-
- - setEmailInputValue(event.target.value), - "value": emailInputValue, - "name": "email", - "type": "email", - "id": "email", - "onKeyDown": event => { - if (event.key === "Escape") { - setEmailInputValue(email.value); - } - } - }} - state={ - emailInputValueErrorMessage === undefined - ? undefined - : "error" - } - stateRelatedMessage={emailInputValueErrorMessage} - disabled={true} - /> -
-
-
-
-
- - getOrganizationFullName(organization) - } - value={organization.value} - onValueChange={value => setOrganizationInputValue(value)} - dsfrInputProps={{ - "label": t("organization"), - "disabled": organization.isBeingUpdated - }} - disabled={true} - /> -
-
-
+ + + {doSupportAccountManagement && ( {t("manage account")} )} + +
<>

{t("about title")}

@@ -224,35 +151,31 @@ function AccountReady(props: { className?: string }) { .link })} /> -
-
- - {aboutAndIsPublic.isBeingUpdated && ( - + + + {aboutAndIsPublic.isBeingUpdated && }
{ + const { t } = useTranslation({ PromptForOrganization }); + + return ( +
+

{t("title")}

+

{t("organization is required")}

+ +
+ ); +}; + +export const OrganizationField = ({ firstTime }: PromptForOrganizationProps) => { + const { t } = useTranslation({ PromptForOrganization }); + const { classes } = useStyles(); + const { userAccountManagement } = useCore().functions; + const userAccountManagementState = useCoreState("userAccountManagement", "main"); + + const { getOrganizationFullName } = useGetOrganizationFullName(); + + useEffect(() => { + userAccountManagement.initialize(); + }, []); + + const [organizationInputValue, setOrganizationInputValue] = useState(""); + + if (!userAccountManagementState) return ; + + const { allOrganizations, organization } = userAccountManagementState; + + return ( +
+ getOrganizationFullName(organization)} + value={organization.value ?? ""} + onValueChange={value => { + setOrganizationInputValue(value); + }} + dsfrInputProps={{ + "label": t("organization"), + "disabled": organization.isBeingUpdated + }} + disabled={organization.isBeingUpdated} + /> + {(firstTime || + (organizationInputValue && + organization.value !== organizationInputValue)) && ( + <> + + {organization.isBeingUpdated && } + + )} +
+ ); +}; + +const useStyles = tss.withName({ PromptForOrganization }).create({ + organizationInput: { + flex: 1 + } +}); + +export const { i18n } = declareComponentKeys< + "title" | "organization is required" | "update" | "organization" +>()({ PromptForOrganization });