diff --git a/public/assets/git_icon.svg b/public/assets/git_icon.svg new file mode 100644 index 000000000..5bf444b9b --- /dev/null +++ b/public/assets/git_icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx index 959f7c1ae..f5ba8d91c 100644 --- a/src/components/forms/TextField.tsx +++ b/src/components/forms/TextField.tsx @@ -49,7 +49,6 @@ const useStyles = makeStyles()((theme: Theme) => ({ width: '415px', }, helperTextTop: { - color: theme.palette.cl.text.subTitle, marginTop: '-5px', fontSize: '13px', inlineSize: '70%', diff --git a/src/components/modals/ConfigureGitModal.tsx b/src/components/modals/ConfigureGitModal.tsx new file mode 100644 index 000000000..0ce86d7db --- /dev/null +++ b/src/components/modals/ConfigureGitModal.tsx @@ -0,0 +1,529 @@ +/* eslint-disable no-nested-ternary */ +import { yupResolver } from '@hookform/resolvers/yup' +import { LoadingButton } from '@mui/lab' +import { Box, Button, Modal, Typography, styled } from '@mui/material' +import { FetchBaseQueryError } from '@reduxjs/toolkit/query' +import InformationBanner from 'components/InformationBanner' +import { TextField } from 'components/forms/TextField' +import { useEffect, useMemo, useState } from 'react' +import { Controller, useForm } from 'react-hook-form' +import { useLocalStorage } from 'react-use' +import { useSession } from 'providers/Session' +import { useMigrateGitMutation } from 'redux/otomiApi' +import { GitSettingsFormValues, gitSettingsSchema } from './gitSettingsValidator' + +const MODAL_TITLE = 'Configure Git Repository' + +const ModalBox = styled(Box)(({ theme }) => ({ + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + width: 800, + maxWidth: '95vw', + backgroundColor: theme.palette.background.paper, + boxShadow: + 'rgb(0 0 0 / 20%) 0px 11px 15px -7px, rgb(0 0 0 / 14%) 0px 24px 38px 3px, rgb(0 0 0 / 12%) 0px 9px 46px 8px', + borderRadius: 16, + padding: 0, + overflow: 'hidden', +})) + +const AnimatedContainer = styled(Box, { + shouldForwardProp: (prop) => prop !== 'isTransitioning', +})<{ isTransitioning: boolean }>(({ isTransitioning }) => ({ + opacity: isTransitioning ? 0 : 1, + transform: isTransitioning ? 'translateY(8px)' : 'translateY(0)', + transition: 'opacity 180ms ease, transform 180ms ease', + '@keyframes drawCheck': { + to: { + strokeDashoffset: 0, + }, + }, + '@keyframes iconPop': { + '0%': { + transform: 'scale(0.7)', + opacity: 0, + }, + '100%': { + transform: 'scale(1)', + opacity: 1, + }, + }, +})) + +const ModalContent = styled('div')({ + padding: '40px 52px 32px 52px', + minHeight: 300, +}) + +const ModalFooter = styled('div')({ + borderTop: '1px dashed rgba(145, 158, 171, 0.24)', + display: 'flex', + justifyContent: 'flex-end', + padding: '24px 36px', + gap: '16px', +}) + +const CenteredFooterActions = styled(Box)({ + width: '100%', + display: 'flex', + justifyContent: 'center', +}) + +const ModalTitle = styled(Typography)({ + marginBottom: '25px', + fontWeight: 600, + letterSpacing: 0, + fontSize: '1.8rem', +}) + +const BodyText = styled(Typography)(({ theme }) => ({ + color: theme.palette.text.secondary, + fontSize: '1.05rem', + lineHeight: 1.5, +})) + +const IntroParagraph = styled(BodyText)({ + marginBottom: '24px', +}) + +const SectionTitle = styled(Typography)({ + marginBottom: '4px', + fontWeight: 550, + letterSpacing: '0.035em', +}) + +const SectionDescription = styled(Typography)(({ theme }) => ({ + marginBottom: '10px', + color: theme.palette.text.secondary, +})) + +const DividerSection = styled(Box)({ + borderTop: '1px solid rgba(145, 158, 171, 0.24)', + paddingTop: '16px', +}) + +const RepoFieldBlock = styled(Box)({ + marginBottom: '24px', +}) + +const BranchFieldBlock = styled(Box)({ + marginBottom: '24px', +}) + +const FieldsGrid = styled(Box)({ + display: 'grid', + gridTemplateColumns: '1fr', + gap: '24px', + marginBottom: '24px', + '@media (min-width: 900px)': { + gridTemplateColumns: '1fr 1fr', + }, +}) + +const EmailFieldWrapper = styled(Box)({ + maxWidth: 840, + marginBottom: '16px', +}) + +const SuccessContainer = styled(Box)({ + textAlign: 'center', + paddingTop: '16px', +}) + +const SuccessIconWrapper = styled(Box)(({ theme }) => ({ + width: 90, + height: 90, + borderRadius: '50%', + backgroundColor: theme.palette.success.main, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + margin: '24px auto 40px', + animation: 'iconPop 280ms ease-out', +})) + +const SuccessHeading = styled(Typography)({ + marginBottom: '16px', + fontWeight: 600, +}) + +const SuccessCaption = styled(Typography)(({ theme }) => ({ + color: theme.palette.text.secondary, + marginTop: '16px', + opacity: 0.8, +})) + +interface ConfigureGitModalProps { + open?: boolean + onClose?: () => void +} + +type GitMigrationResponse = { + message?: string + statusCode?: number +} + +function AnimatedCheckmark() { + return ( + + + + ) +} + +function getGitMigrationDataError(data: unknown): string { + const response = data as GitMigrationResponse + + if (response?.statusCode && response.statusCode >= 400) + return response.message || 'Something went wrong while migrating Git settings.' + + return '' +} + +function getErrorMessage(error: unknown): string { + const fetchError = error as FetchBaseQueryError & { + data?: { message?: string; error?: string } + error?: string + originalStatus?: number + status?: number | string + } + + if ('status' in (fetchError || {})) { + if (typeof fetchError.data === 'object' && fetchError.data !== null) + return fetchError.data.message || fetchError.data.error || 'Something went wrong while migrating Git settings.' + + if (fetchError.status === 'PARSING_ERROR' && fetchError.originalStatus === 200) return '' + if (fetchError.status === 503) return 'The API is currently unavailable.' + if (fetchError.status === 400 || fetchError.status === 404) + return 'Cannot connect to the provided Git repository. Check the repository URL and credentials.' + + if (typeof fetchError.error === 'string' && fetchError.error.length > 0) return fetchError.error + } + + return 'Something went wrong while migrating Git settings.' +} + +export default function ConfigureGitModal({ open, onClose }: ConfigureGitModalProps) { + const { + user: { isPlatformAdmin }, + settings: { + otomi: { isPreInstalled }, + }, + } = useSession() + + const [showGitWizard, setShowGitWizard] = useLocalStorage('showGitConfigureWizard', true) + const [showFormStep, setShowFormStep] = useState(false) + const [isTransitioning, setIsTransitioning] = useState(false) + const [submitError, setSubmitError] = useState('') + const [migrationSucceeded, setMigrationSucceeded] = useState(false) + + const [migrateGit, { isLoading: isMigrating }] = useMigrateGitMutation() + + const { + control, + handleSubmit, + formState: { errors }, + reset, + } = useForm({ + resolver: yupResolver(gitSettingsSchema), + defaultValues: { + repoUrl: '', + branch: '', + username: '', + password: '', + email: '', + }, + mode: 'onBlur', + }) + + const isControlled = typeof open === 'boolean' + const actualOpen = useMemo(() => (isControlled ? !!open : !!showGitWizard), [isControlled, open, showGitWizard]) + + const resetModalState = () => { + setShowFormStep(false) + setSubmitError('') + setMigrationSucceeded(false) + setIsTransitioning(false) + reset() + } + + useEffect(() => { + if (showGitWizard === undefined) setShowGitWizard(true) + }, [showGitWizard, setShowGitWizard]) + + useEffect(() => { + if (!isPreInstalled && !isControlled) setShowGitWizard(false) + }, [isPreInstalled, isControlled, setShowGitWizard]) + + useEffect(() => { + if (!actualOpen) resetModalState() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [actualOpen]) + + const handleClose = () => { + resetModalState() + + if (isControlled) { + onClose?.() + return + } + + setShowGitWizard(false) + } + + const goToFormStep = () => { + setIsTransitioning(true) + + setTimeout(() => { + setShowFormStep(true) + setIsTransitioning(false) + }, 180) + } + + const onSubmit = async (data: GitSettingsFormValues) => { + setSubmitError('') + setMigrationSucceeded(false) + + try { + const result = await migrateGit({ + body: { + repoUrl: data.repoUrl.trim(), + branch: data.branch.trim(), + username: data.username?.trim() || undefined, + password: data.password, + email: data.email.trim(), + }, + }) + + if ('data' in result) { + const dataError = getGitMigrationDataError(result.data) + + if (dataError) { + setSubmitError(dataError) + return + } + + setMigrationSucceeded(true) + return + } + + if ('error' in result) { + const message = getErrorMessage(result.error) + + if (!message) { + setMigrationSucceeded(true) + return + } + + setSubmitError(message) + } + } catch (error) { + const message = getErrorMessage(error) + + if (!message) { + setMigrationSucceeded(true) + return + } + + setSubmitError(message) + } + } + + if (!isPlatformAdmin || !isPreInstalled) return null + if (!isControlled && !showGitWizard) return null + + return ( + { + if (reason === 'backdropClick') return + if (isMigrating) return + handleClose() + }} + > + + + {!showFormStep ? ( + <> + + {MODAL_TITLE} + + App Platform is installed on a light weight Git server. + + + This Git server is ideal for testing the platform but not recommended for production workloads. + + + + Configuring an external Git Repo is recommended for installing App Platform. + + + + + + + + + + ) : migrationSucceeded ? ( + <> + + + + + + + Successfully connected to Git repository + + + The App Platform web interface is going to be restarted and will be unavailable for few minutes. + + + (You can now close this window) + + + + + + + + + + ) : ( +
+ + {MODAL_TITLE} + + {!!submitError && } + + + ( + + )} + /> + + + + ( + + )} + /> + + + + Credentials + + Username and password will be used to authenticate to Git repository + + + + ( + + )} + /> + + ( + + )} + /> + + + + ( + + )} + /> + + + + + + + + + Proceed + + +
+ )} +
+
+
+ ) +} diff --git a/src/components/modals/gitSettingsValidator.ts b/src/components/modals/gitSettingsValidator.ts new file mode 100644 index 000000000..8f3930cac --- /dev/null +++ b/src/components/modals/gitSettingsValidator.ts @@ -0,0 +1,20 @@ +import * as yup from 'yup' + +export interface GitSettingsFormValues { + repoUrl: string + branch: string + username: string + password: string + email: string +} + +export const gitSettingsSchema = yup.object({ + repoUrl: yup + .string() + .required('Git repository URL is required') + .matches(/^(https:\/\/|git@).+/, 'Enter a valid Git repository URL starting with https:// or git@'), + branch: yup.string().required('Branch is required'), + username: yup.string().optional(), + password: yup.string().required('Password is required'), + email: yup.string().email('Enter a valid email address').required('Email address is required'), +}) diff --git a/src/layouts/Paper.tsx b/src/layouts/Paper.tsx index e8c389053..fc13bb4dc 100644 --- a/src/layouts/Paper.tsx +++ b/src/layouts/Paper.tsx @@ -5,7 +5,7 @@ import { useLocation } from 'react-router-dom' import Error from 'components/Error' import { useAppDispatch, useAppSelector } from 'redux/hooks' import { setError } from 'redux/reducers' -import ObjWizardModal from 'components/ObjWizardModal' +import ConfigureGitModal from 'components/modals/ConfigureGitModal' import MainLayout from './Base' interface Props { @@ -38,7 +38,7 @@ export default function ({ loading, comp, title, children }: Props): React.React - + ) } diff --git a/src/pages/Apps.tsx b/src/pages/Apps.tsx index 0df7b5732..bdf67263c 100644 --- a/src/pages/Apps.tsx +++ b/src/pages/Apps.tsx @@ -1,4 +1,5 @@ import Apps from 'components/Apps' +import ObjWizardModal from 'components/ObjWizardModal' import useAuthzSession from 'hooks/useAuthzSession' import useSettings from 'hooks/useSettings' import PaperLayout from 'layouts/Paper' @@ -57,13 +58,16 @@ export default function ({ const loading = isLoading || isLoadingSettings const comp = apps && ( - + <> + + + ) return diff --git a/src/pages/SettingsOverview.tsx b/src/pages/SettingsOverview.tsx index d9a318367..ef5d2ff8b 100644 --- a/src/pages/SettingsOverview.tsx +++ b/src/pages/SettingsOverview.tsx @@ -3,23 +3,28 @@ /* eslint-disable react/function-component-definition */ import PaperLayout from 'layouts/Paper' import SvgIconStyle from 'components/SvgIconStyle' -import React from 'react' +import { useState } from 'react' import { Link } from 'react-router-dom' import { Box, Grid, Typography } from '@mui/material' import Versions from 'components/Versions' import { jsx } from '@emotion/react' import { useSession } from 'providers/Session' +import ConfigureGitModal from 'components/modals/ConfigureGitModal' interface Settings { title: string - path: string + path?: string icon: jsx.JSX.Element id: string + onClick?: () => void } + export default function SettingsOverview() { const getIcon = (name: string) => const session = useSession() - const settings = [ + const [openGitModal, setOpenGitModal] = useState(false) + + const settings: Settings[] = [ { title: 'Cluster', path: '/settings/cluster', icon: getIcon('cluster_icon.svg'), id: 'cluster' }, { title: 'Platform', path: '/settings/otomi', icon: getIcon('akamai_icon.svg'), id: 'aplSettings' }, { title: 'Secrets', path: '/settings/kms', icon: getIcon('secrets_icon.svg'), id: 'kms' }, @@ -29,51 +34,87 @@ export default function SettingsOverview() { { title: 'OIDC', path: '/settings/oidc', icon: getIcon('oidc_icon.svg'), id: 'oidc' }, { title: 'Backup', path: '/settings/platformBackups', icon: getIcon('backup_icon.svg'), id: 'backup' }, { title: 'Object Storage', path: '/settings/obj', icon: getIcon('cloud_upload.svg'), id: 'objectStorage' }, + { + title: 'Git', + icon: getIcon('git_icon.svg'), + id: 'git', + onClick: () => setOpenGitModal(true), + }, ] + const removePreInstalledSpecificSettings = ['kms', 'dns', 'ingress'] let filteredSettings: Settings[] = settings + if (session.settings.otomi.isPreInstalled) filteredSettings = settings.filter((setting) => !removePreInstalledSpecificSettings.includes(setting.id)) - // TODO: remove inline styling and use theming - const SettingsCard = ({ title, path, icon }) => ( + + const CardContent = ({ title, icon }: { title: string; icon: jsx.JSX.Element }) => ( + + + {icon} + + + + {title} + + + + ) + + const SettingsCard = ({ title, path, icon, onClick }: Settings) => ( - + {path ? ( + + + + ) : ( - - {icon} - - - - {title} - - + - + )} ) const comp = (
- {filteredSettings.map((setting) => { - return - })} + {filteredSettings.map((setting) => ( + + ))} + + setOpenGitModal(false)} />
) diff --git a/src/redux/otomiApi.ts b/src/redux/otomiApi.ts index e4548f7f2..f2952d5ee 100644 --- a/src/redux/otomiApi.ts +++ b/src/redux/otomiApi.ts @@ -567,6 +567,12 @@ const injectedRtkApi = api.injectEndpoints({ body: queryArg.body, }), }), + migrateGit: build.mutation({ + query: (queryArg) => ({ url: `/v2/git`, method: 'PUT', body: queryArg.body }), + }), + getApiStatus: build.query({ + query: () => ({ url: `/v2/status` }), + }), }), overrideExisting: false, }) @@ -6889,6 +6895,21 @@ export type EditAppApiArg = { } } } +export type MigrateGitApiResponse = /** status 200 Migration successful. API is now locked. */ undefined +export type MigrateGitApiArg = { + /** New git configuration to migrate to. */ + body: { + repoUrl: string + username?: string + password: string + email: string + branch: string + } +} +export type GetApiStatusApiResponse = /** status 200 Successfully obtained API status. */ { + locked: boolean +} +export type GetApiStatusApiArg = void export const { useGetValuesQuery, useGetTeamsQuery, @@ -7035,4 +7056,6 @@ export const { useToggleAppsMutation, useGetTeamAppQuery, useEditAppMutation, + useMigrateGitMutation, + useGetApiStatusQuery, } = injectedRtkApi