diff --git a/client/apps/portal-administration/app.config.ts b/client/apps/portal-administration/app.config.ts index d2f67cbcf..d3851f709 100644 --- a/client/apps/portal-administration/app.config.ts +++ b/client/apps/portal-administration/app.config.ts @@ -1,9 +1,16 @@ import { defineAppConfig } from '@equinor/fusion-framework-cli'; + +const feature = true; export default defineAppConfig((_env) => ({ environment: { - client: { - baseUri: 'https://backend-fusion-project-portal-test.radix.equinor.com', - defaultScopes: ['api://02f3484c-cad0-4d1d-853d-3a9e604b38f3/access_as_user'], - }, + client: feature + ? { + baseUri: 'https://backend-fusion-project-portal-feature.radix.equinor.com', + defaultScopes: ['api://7bf96dd1-39fe-47dd-8286-329c730ac76b/access_as_user'], + } + : { + baseUri: 'https://backend-fusion-project-portal-test.radix.equinor.com', + defaultScopes: ['api://02f3484c-cad0-4d1d-853d-3a9e604b38f3/access_as_user'], + }, }, })); diff --git a/client/apps/portal-administration/package.json b/client/apps/portal-administration/package.json index ba8392042..26b2bd577 100644 --- a/client/apps/portal-administration/package.json +++ b/client/apps/portal-administration/package.json @@ -24,6 +24,7 @@ "react-router-dom": "^6.26.0", "@equinor/fusion-react-side-sheet": "^1.3.3", "@equinor/fusion-framework-module-navigation": "^4.0.7", + "@equinor/fusion-framework-module-msal": "^3.1.5", "@equinor/fusion-framework-react-app": "^5.2.10", "@ag-grid-community/core": "^32.1.0", "react": "^18.2.0", @@ -43,4 +44,4 @@ "uuidv7": "^1.0.1", "zod": "^3.23.8" } -} +} \ No newline at end of file diff --git a/client/apps/portal-administration/src/components/FormComponents/AddAdmins.tsx b/client/apps/portal-administration/src/components/FormComponents/AddAdmins.tsx new file mode 100644 index 000000000..f9a1d50be --- /dev/null +++ b/client/apps/portal-administration/src/components/FormComponents/AddAdmins.tsx @@ -0,0 +1,93 @@ +import { Label, Icon, Button, Typography } from '@equinor/eds-core-react'; +import { PersonSelect, PersonListItem, PersonSelectEvent } from '@equinor/fusion-react-person'; +import { FieldErrors, UseFormSetValue, UseFormWatch } from 'react-hook-form'; +import styled from 'styled-components'; +import { error_filled, close } from '@equinor/eds-icons'; +import { PortalInputs } from '../../schema'; + +const Style = { + AdminError: styled(Typography).withConfig({ displayName: 'AdminError' })` + display: flex; + gap: 0.5rem; + align-items: center; + `, + AdminList: styled.div` + padding-top: 1rem; + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 1rem; + `, +}; + +export const AddAdmins = ({ + watch, + setValue, + errors, + canEdit, +}: { + watch: UseFormWatch; + setValue: UseFormSetValue; + errors: FieldErrors; + canEdit?: boolean; +}) => { + const handlePersonSelect = (e: PersonSelectEvent) => { + const selected = e.nativeEvent.detail.selected?.azureId; + if (!selected) return; + const current = watch().admins; + const selectedItems = [...current, { azureUniqueId: selected }]; + setValue('admins', selectedItems, { shouldTouch: true, shouldValidate: true }); + }; + + const handlePersonRemove = (azureUniqueId: string) => { + setValue( + 'admins', + watch().admins.filter((a) => a.azureUniqueId !== azureUniqueId), + { shouldTouch: true } + ); + }; + + const handleOnBlur = () => { + setValue('admins', watch().admins, { shouldTouch: true }); + }; + + return ( + <> + {canEdit && ( +
+ + +
+ )} + + {errors.admins && ( + + {errors.admins.message} + + + )} + {watch().admins.map((person) => ( + + {canEdit && ( + + )} + + ))} + + + ); +}; diff --git a/client/apps/portal-administration/src/components/FormComponents/DescriptionInput.tsx b/client/apps/portal-administration/src/components/FormComponents/DescriptionInput.tsx index 9b824127d..0cd63b3ff 100644 --- a/client/apps/portal-administration/src/components/FormComponents/DescriptionInput.tsx +++ b/client/apps/portal-administration/src/components/FormComponents/DescriptionInput.tsx @@ -8,9 +8,10 @@ type DescriptionInputProps = { errors: FieldErrors<{ description?: string | undefined; }>; + canEdit?: boolean; }; -export const DescriptionInput = ({ register, errors }: DescriptionInputProps) => { +export const DescriptionInput = ({ register, errors, canEdit }: DescriptionInputProps) => { return ( inputIcon={errors.description && } label="Description" maxLength={500} + readOnly={!canEdit} /> ); }; diff --git a/client/apps/portal-administration/src/components/FormComponents/IconInput.tsx b/client/apps/portal-administration/src/components/FormComponents/IconInput.tsx index 4f558e706..74b8368fa 100644 --- a/client/apps/portal-administration/src/components/FormComponents/IconInput.tsx +++ b/client/apps/portal-administration/src/components/FormComponents/IconInput.tsx @@ -32,9 +32,10 @@ type IconInputProps = { icon?: string | undefined; }>; icon: string; + canEdit?: boolean; }; -export const IconInput = ({ register, errors, icon }: IconInputProps) => { +export const IconInput = ({ register, errors, icon, canEdit }: IconInputProps) => { return (
@@ -54,6 +55,7 @@ export const IconInput = ({ register, errors, icon }: IconInputProps) => { ; + canEdit?: boolean; }; -export const NameInput = ({ register, errors }: NameInputProps) => { +export const NameInput = ({ register, errors, canEdit }: NameInputProps) => { return ( } diff --git a/client/apps/portal-administration/src/components/FormComponents/ShortNameInput.tsx b/client/apps/portal-administration/src/components/FormComponents/ShortNameInput.tsx index f04c672bf..ae6df6c75 100644 --- a/client/apps/portal-administration/src/components/FormComponents/ShortNameInput.tsx +++ b/client/apps/portal-administration/src/components/FormComponents/ShortNameInput.tsx @@ -8,13 +8,15 @@ type ShortNameInputProps = { errors: FieldErrors<{ shortName?: string | undefined; }>; + canEdit?: boolean; }; -export const ShortNameInput = ({ register, errors }: ShortNameInputProps) => { +export const ShortNameInput = ({ register, errors, canEdit }: ShortNameInputProps) => { return ( } diff --git a/client/apps/portal-administration/src/components/FormComponents/SubTextInput.tsx b/client/apps/portal-administration/src/components/FormComponents/SubTextInput.tsx index 0af0bd812..3fe7cb9ff 100644 --- a/client/apps/portal-administration/src/components/FormComponents/SubTextInput.tsx +++ b/client/apps/portal-administration/src/components/FormComponents/SubTextInput.tsx @@ -8,13 +8,15 @@ type SubtextInputProps = { errors: FieldErrors<{ subtext?: string | undefined; }>; + canEdit?: boolean; }; -export const SubtextInput = ({ register, errors }: SubtextInputProps) => { +export const SubtextInput = ({ register, errors, canEdit }: SubtextInputProps) => { return ( } diff --git a/client/apps/portal-administration/src/components/Portal/EditPortalForm.tsx b/client/apps/portal-administration/src/components/Portal/EditPortalForm.tsx index 0a3350142..519b0009d 100644 --- a/client/apps/portal-administration/src/components/Portal/EditPortalForm.tsx +++ b/client/apps/portal-administration/src/components/Portal/EditPortalForm.tsx @@ -10,13 +10,15 @@ import { useUpdatePortal } from '../../hooks/use-portal-query'; import { PortalInputs, portalEditInputSchema } from '../../schema'; import { ContextType, Portal } from '../../types'; import { DescriptionInput } from '../FormComponents/DescriptionInput'; - import { FormActionBar } from './FormActionBar'; import { IdInput } from '../FormComponents/IdInput'; import { NameInput } from '../FormComponents/NameIntput'; import { ShortNameInput } from '../FormComponents/ShortNameInput'; import { SubtextInput } from '../FormComponents/SubTextInput'; import { IconInput } from '../FormComponents/IconInput'; +import { AddAdmins } from '../FormComponents/AddAdmins'; +import { useCurrentAccount } from '@equinor/fusion-framework-react-app/msal'; +import { useAccess } from '../../hooks/use-access'; const Style = { Wrapper: styled.div` @@ -25,6 +27,7 @@ const Style = { flex-direction: column; padding-bottom: 2rem; `, + Row: styled.div` gap: 1rem; display: flex; @@ -70,17 +73,16 @@ export const EditPortalForm = (props: { formState: { errors, isSubmitting, touchedFields }, watch, setValue, - reset, } = useForm({ resolver: zodResolver(portalEditInputSchema), defaultValues: { ...props.portal, + admins: props.portal.admins || [], }, }); const onSubmit: SubmitHandler = async (editedPortal) => { await updatePortal(editedPortal); - reset(); }; const [type, setType] = useState(contexts && contexts?.length > 0 ? 'context-portal' : 'app-portal'); @@ -98,13 +100,21 @@ export const EditPortalForm = (props: { onDisabled && onDisabled(disabled); }, [disabled, onDisabled]); + const { data: isAdmin } = useAccess(); + + const account = useCurrentAccount(); + const canEdit = useMemo( + () => watch().admins?.some((admin) => admin.azureUniqueId === account?.localAccountId) || isAdmin, + [watch().admins, account, isAdmin] + ); + return ( General - + @@ -112,6 +122,10 @@ export const EditPortalForm = (props: { + + Admins + + Icon @@ -128,12 +142,14 @@ export const EditPortalForm = (props: { value="app-portal" checked={type === 'app-portal'} onChange={onTypeChange} + disabled={!canEdit} /> @@ -144,24 +160,29 @@ export const EditPortalForm = (props: { Context
- ct.type) || []} - selectedOptions={watch().contextTypes} - onOptionsChange={({ selectedItems }) => { - setValue('contextTypes', selectedItems, { shouldTouch: true }); - }} - itemCompare={(item, compare) => { - return item === compare; - }} - label="Context Types" - /> + {canEdit ? ( + ct.type) || []} + selectedOptions={watch().contextTypes} + onOptionsChange={({ selectedItems }) => { + setValue('contextTypes', selectedItems, { shouldTouch: true }); + }} + itemCompare={(item, compare) => { + return item === compare; + }} + label="Context Types" + /> + ) : ( + <>{watch().contextTypes?.join(' | ')} + )} )} - {!props.isSideSheet && ( + + {!props.isSideSheet && canEdit && ( Portal Actions diff --git a/client/apps/portal-administration/src/components/Portals/CreatePortalForm.tsx b/client/apps/portal-administration/src/components/Portals/CreatePortalForm.tsx index 35aa13dc3..52985dbd8 100644 --- a/client/apps/portal-administration/src/components/Portals/CreatePortalForm.tsx +++ b/client/apps/portal-administration/src/components/Portals/CreatePortalForm.tsx @@ -14,6 +14,10 @@ import { NameInput } from '../FormComponents/NameIntput'; import { ShortNameInput } from '../FormComponents/ShortNameInput'; import { SubtextInput } from '../FormComponents/SubTextInput'; import { IconInput } from '../FormComponents/IconInput'; +import { AddAdmins } from '../FormComponents/AddAdmins'; +import { PageMessage } from '../PageMessage/PageMessage'; +import { useAccess } from '../../hooks/use-access'; +import { Loading } from '../Loading'; const Style = { Wrapper: styled.div` @@ -42,11 +46,25 @@ const Style = { padding-top: 1rem; padding-bottom: 1rem; `, + AdminError: styled(Typography).withConfig({ displayName: 'AdminError' })` + display: flex; + gap: 0.5rem; + align-items: center; + `, + AdminList: styled.div` + padding-top: 1rem; + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 1rem; + `, }; const ICON = `\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t`; export const CreatePortalForm = () => { + const { isLoading, data: hasAccess } = useAccess(); + const { mutateAsync: createPortal, reset: resetCreate } = useCreatePortal(); const { @@ -61,6 +79,7 @@ export const CreatePortalForm = () => { resolver: zodResolver(portalInputSchema), defaultValues: { contextTypes: [], + admins: [], icon: ICON, }, }); @@ -83,21 +102,35 @@ export const CreatePortalForm = () => { type === 'app-portal' && setValue('contextTypes', []); }, [type]); + if (isLoading) return ; + + if (!hasAccess) { + return ( + + You do not have access to create portals + + ); + } + return ( General - + - - + + - + + + + Admins + Icon - + Portal Type diff --git a/client/apps/portal-administration/src/components/Portals/PortalsHeader.tsx b/client/apps/portal-administration/src/components/Portals/PortalsHeader.tsx index 8d66a3f38..adff498d9 100644 --- a/client/apps/portal-administration/src/components/Portals/PortalsHeader.tsx +++ b/client/apps/portal-administration/src/components/Portals/PortalsHeader.tsx @@ -44,7 +44,9 @@ export const PortalsHeader = () => { )} - {activeTab.description} + + {typeof activeTab.description === 'string' ? activeTab.description : } + @@ -52,7 +54,7 @@ export const PortalsHeader = () => { {tabs.map((tab) => ( - + diff --git a/client/apps/portal-administration/src/pages/EditPortal.tsx b/client/apps/portal-administration/src/pages/EditPortal.tsx index 62027798c..db3a85d8e 100644 --- a/client/apps/portal-administration/src/pages/EditPortal.tsx +++ b/client/apps/portal-administration/src/pages/EditPortal.tsx @@ -30,12 +30,19 @@ export const EditPortal = () => { const { data: contextTypes, isLoading: contextTypeLoading } = useGetContextTypes(); + if (!portalId) { + return ; + } if (portalLoading || contextTypeLoading) { return ; } - if (!portalId || !portal || !contextTypes) { - return ; + if (!contextTypes) { + return ; + } + + if (!portal) { + return ; } return ( diff --git a/client/apps/portal-administration/src/schema/index.ts b/client/apps/portal-administration/src/schema/index.ts index c4c02c2ba..3282e0070 100644 --- a/client/apps/portal-administration/src/schema/index.ts +++ b/client/apps/portal-administration/src/schema/index.ts @@ -5,8 +5,12 @@ export const contextTypeSchema = z.object({ export type ContextTypeInputs = z.infer; +const user = z.object({ + azureUniqueId: z.string(), +}); + const base = { - name: z.string().min(3, 'Short description must contain at least 3 character(s)').max(50), + name: z.string().min(3, 'Name must contain at least 3 character(s)').max(50), shortName: z .string() .min(2, 'Short Name must contain at least 2 character(s)') @@ -17,6 +21,7 @@ const base = { .max(150, 'Short Name can contain at most 300 character(s)'), description: z.string().max(500, 'Description can contain at most 300 character(s)').optional(), icon: z.string().default(''), + admins: z.array(user).min(1, 'At least one admin is required'), }; export const portalInputSchema = z.object({ diff --git a/client/apps/portal-administration/src/types/index.ts b/client/apps/portal-administration/src/types/index.ts index d5cb295a6..d42fa27e5 100644 --- a/client/apps/portal-administration/src/types/index.ts +++ b/client/apps/portal-administration/src/types/index.ts @@ -5,6 +5,12 @@ export type AppManifest = FusionAppManifest & { isDisabled?: boolean; url?: string; }; + +export type Admin = { + id?: string; + azureUniqueId: string; +}; + export type Portal = { name: string; shortName: string; @@ -14,6 +20,7 @@ export type Portal = { type: string; icon?: string; description: string; + admins: Admin[]; contexts: ContextType[]; }; diff --git a/client/apps/portal-administration/src/utils/error-utils.ts b/client/apps/portal-administration/src/utils/error-utils.ts index aa9a741c3..25d4d414b 100644 --- a/client/apps/portal-administration/src/utils/error-utils.ts +++ b/client/apps/portal-administration/src/utils/error-utils.ts @@ -1,11 +1,39 @@ -import { FormattedError } from "../types"; +import { FormattedError } from '../types'; export const DEFAULT_ERROR: FormattedError = { - type: "Error", - title: "Generic Error", - status: -1, + type: 'Error', + title: 'Generic Error', + status: -1, }; export const formatError = (errorResponse: unknown, status: number) => { - return DEFAULT_ERROR; + if (typeof errorResponse === 'string') { + return { + ...DEFAULT_ERROR, + title: errorResponse, + status, + }; + } + + if (errorResponse instanceof TypeError) { + const { cause, name, message, stack } = errorResponse; + return { + type: 'Error', + title: name || 'Generic Error', + messages: [message], + status: cause, + stack, + }; + } + + if (typeof errorResponse === 'object' && errorResponse !== null) { + const { title, messages } = errorResponse as FormattedError; + return { + type: 'Error', + title: title || 'Generic Error', + messages: messages || [], + status, + }; + } + return DEFAULT_ERROR; };