From def2f0ecc8b73a09329d68039d4721fcf0fccc16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Burkard?= <22095555+JeromeBu@users.noreply.github.com> Date: Wed, 11 Dec 2024 14:59:48 +0100 Subject: [PATCH] handle organization null in frontend --- web/src/core/bootstrap.ts | 4 +- web/src/core/usecases/instanceForm/state.ts | 2 +- web/src/core/usecases/instanceForm/thunks.ts | 3 +- .../usecases/softwareUserAndReferent/state.ts | 4 +- .../usecases/userAccountManagement/state.ts | 44 +++------ .../usecases/userAccountManagement/thunks.ts | 10 +- .../core/usecases/userAuthentication/index.ts | 1 + .../usecases/userAuthentication/selectors.ts | 9 ++ .../core/usecases/userAuthentication/state.ts | 65 ++++++++++++- .../usecases/userAuthentication/thunks.ts | 18 +++- web/src/core/usecases/userProfile/state.ts | 4 +- web/src/ui/App.tsx | 22 +++-- web/src/ui/i18n/i18n.tsx | 19 +++- web/src/ui/i18n/useGetOrganizationFullName.ts | 3 +- web/src/ui/pages/account/Account.tsx | 49 +++++----- web/src/ui/pages/userProfile/UserProfile.tsx | 2 +- web/src/ui/shared/PromptForOrganization.tsx | 91 +++++++++++++++++++ 17 files changed, 270 insertions(+), 80 deletions(-) create mode 100644 web/src/core/usecases/userAuthentication/selectors.ts create mode 100644 web/src/ui/shared/PromptForOrganization.tsx 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/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..c3fa7508 100644 --- a/web/src/core/usecases/userAccountManagement/state.ts +++ b/web/src/core/usecases/userAccountManagement/state.ts @@ -18,7 +18,7 @@ namespace State { allowedEmailRegexpStr: string; allOrganizations: string[]; organization: { - value: string; + value: string | null; isBeingUpdated: boolean; }; email: { @@ -33,6 +33,17 @@ namespace State { }; } +type UpdateFieldPayload = + | { + fieldName: "organization"; + value: string; + } + | { + fieldName: "aboutAndIsPublic"; + about: string; + isPublic: boolean; + }; + export const { reducer, actions } = createUsecaseActions({ name, "initialState": id({ @@ -53,7 +64,7 @@ export const { reducer, actions } = createUsecaseActions({ payload: { accountManagementUrl: string | undefined; allowedEmailRegexpStr: string; - organization: string; + organization: string | null; email: string; allOrganizations: string[]; about: string; @@ -95,23 +106,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 +124,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 26c66e2c..89059e89 100644 --- a/web/src/core/usecases/userAccountManagement/thunks.ts +++ b/web/src/core/usecases/userAccountManagement/thunks.ts @@ -27,9 +27,7 @@ export const thunks = { { keycloakParams }, allowedEmailRegexpStr, allOrganizations, - { - agent: { about = "", isPublic } - } + { agent } ] = await Promise.all([ sillApi.getOidcParams(), sillApi.getAllowedEmailRegexp(), @@ -37,11 +35,13 @@ export const thunks = { 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 @@ -101,7 +101,7 @@ export const thunks = { } } - 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..eabec71f 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": @@ -16,6 +31,7 @@ export const thunks = { const [, , { oidc }] = args; + console.log("asesrting user not logged : ", oidc.isUserLoggedIn); assert(!oidc.isUserLoggedIn); return oidc.login({ doesCurrentHrefRequiresAuth }); 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 93c1a265..ab3680db 100644 --- a/web/src/ui/pages/account/Account.tsx +++ b/web/src/ui/pages/account/Account.tsx @@ -87,7 +87,7 @@ function AccountReady(props: { className?: string }) { const [emailInputValue, setEmailInputValue] = useState(email.value); const [organizationInputValue, setOrganizationInputValue] = useState( - organization.value + organization.value ?? null ); const evtAboutInputValue = useConst(() => Evt.create(aboutAndIsPublic.about)); @@ -190,7 +190,7 @@ function AccountReady(props: { className?: string }) { getOptionLabel={organization => getOrganizationFullName(organization) } - value={organization.value} + value={organization.value ?? ""} onValueChange={value => { setOrganizationInputValue(value); }} @@ -200,28 +200,29 @@ function AccountReady(props: { className?: string }) { }} disabled={organization.isBeingUpdated} /> - {organization.value !== organizationInputValue && ( - <> - - {organization.isBeingUpdated && ( - - )} - - )} + {organizationInputValue && + organization.value !== organizationInputValue && ( + <> + + {organization.isBeingUpdated && ( + + )} + + )} <> diff --git a/web/src/ui/pages/userProfile/UserProfile.tsx b/web/src/ui/pages/userProfile/UserProfile.tsx index 4de7a633..0afbab76 100644 --- a/web/src/ui/pages/userProfile/UserProfile.tsx +++ b/web/src/ui/pages/userProfile/UserProfile.tsx @@ -153,7 +153,7 @@ export const { i18n } = declareComponentKeys< K: "agent profile"; P: { email: string; - organization: string; + organization: string | null; }; } | "no description" diff --git a/web/src/ui/shared/PromptForOrganization.tsx b/web/src/ui/shared/PromptForOrganization.tsx new file mode 100644 index 00000000..0328dd05 --- /dev/null +++ b/web/src/ui/shared/PromptForOrganization.tsx @@ -0,0 +1,91 @@ +import { fr } from "@codegouvfr/react-dsfr"; +import { Button } from "@codegouvfr/react-dsfr/Button"; +import CircularProgress from "@mui/material/CircularProgress"; +import { declareComponentKeys } from "i18nifty"; +import { useEffect, useState } from "react"; +import { tss } from "tss-react"; +import { useGetOrganizationFullName, useTranslation } from "ui/i18n"; +import { useCore, useCoreState } from "../../core"; +import { AutocompleteFreeSoloInput } from "./AutocompleteFreeSoloInput"; +import { LoadingFallback } from "./LoadingFallback"; + +export const PromptForOrganization = ({ firstTime }: { firstTime?: boolean }) => { + 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 ( +
+

{t("title")}

+

{t("organization is required")}

+ +
+ 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 });