diff --git a/src/ClientApp/ApiRoutes.tsx b/src/ClientApp/ApiRoutes.tsx index 69c9fa6..0d08f71 100644 --- a/src/ClientApp/ApiRoutes.tsx +++ b/src/ClientApp/ApiRoutes.tsx @@ -1,9 +1,16 @@ enum ApiRoutes { CACHE_GET_ACCOUNTS = `api/Cache/GetAccounts`, + CACHE_GET_CONTACTS = `api/Cache/GetContacts/{accountId}`, - CACHE_SET_CONTACTS = `api/Cache/SetContacts/{accountId}`, + CACHE_ADD_CONTACT = `api/Cache/AddContact/{accountId}`, + CACHE_DELETE_CONTACT = `api/Cache/DeleteContact/{accountId}?contact={contact}`, + + CACHE_GET_HOSTNAMES = `api/Cache/GetHostnames/{accountId}`, + // TODO: here is different flow via CertsFlowController, cache update is the result of add order and invalidate cert + // CACHE_ADD_HOSTNAME = `api/Cache/AddHostname/{accountId}`, + // CACHE_DELETE_HOSTNAME = `api/Cache/DeleteHostname/{accountId}?hostname={hostname}`, CERTS_FLOW_CONFIGURE_CLIENT = `api/CertsFlow/ConfigureClient`, CERTS_FLOW_TERMS_OF_SERVICE = `api/CertsFlow/TermsOfService/{sessionId}`, diff --git a/src/ClientApp/app/about/page.tsx b/src/ClientApp/app/about/page.tsx index f99f335..122de2c 100644 --- a/src/ClientApp/app/about/page.tsx +++ b/src/ClientApp/app/about/page.tsx @@ -1,12 +1,10 @@ -import Layout from "../layout"; - const AboutPage = () => { return ( <>

About

This is the about page content.

- ); -}; + ) +} -export default AboutPage; +export default AboutPage diff --git a/src/ClientApp/app/contact/page.tsx b/src/ClientApp/app/contact/page.tsx index 8b2408a..3d8e37f 100644 --- a/src/ClientApp/app/contact/page.tsx +++ b/src/ClientApp/app/contact/page.tsx @@ -1,11 +1,9 @@ -import Layout from "../layout"; - const ContactPage = () => { return (<>

Contact Us

This is the contact page content.

- ); -}; + ) +} -export default ContactPage; +export default ContactPage diff --git a/src/ClientApp/app/index.tsx b/src/ClientApp/app/index.tsx deleted file mode 100644 index 1aa82e3..0000000 --- a/src/ClientApp/app/index.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import Layout from "./layout"; - -const HomePage = () => { - return ( - -

Main Content

- {/* Your main content goes here */} -
- ); -}; - -export default HomePage; diff --git a/src/ClientApp/app/layout.tsx b/src/ClientApp/app/layout.tsx index c5d6b42..daffe9b 100644 --- a/src/ClientApp/app/layout.tsx +++ b/src/ClientApp/app/layout.tsx @@ -1,93 +1,97 @@ -"use client"; // Add this line +"use client" // Add this line -import React, { FC, useState, useEffect, useRef } from 'react'; -import './globals.css'; -import { SideMenu } from '../components/sidemenu'; -import { TopMenu } from '../components/topmenu'; -import { Footer } from '../components/footer'; -import { OffCanvas } from '../components/offcanvas'; -import Loader from '../components/loader'; // Import the Loader component -import { Metadata } from 'next'; +import React, { FC, useState, useEffect, useRef } from 'react' +import './globals.css' +import { SideMenu } from '@/components/sidemenu' +import { TopMenu } from '@/components/topmenu' +import { Footer } from '@/components/footer' +import { OffCanvas } from '@/components/offcanvas' +import { Loader } from '@/components/loader' // Import the Loader component +import { Metadata } from 'next' +import { Toast } from '@/components/toast' +import { Provider } from 'react-redux' +import { store } from '@/redux/store' const metadata: Metadata = { title: "Create Next App", description: "Generated by create next app", -}; +} const Layout: FC<{ children: React.ReactNode }> = ({ children }) => { - const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); - const [isManuallyCollapsed, setManuallyCollapsed] = useState(false); - const [isLoading, setIsLoading] = useState(true); // State to control the loader visibility + const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false) + const [isManuallyCollapsed, setManuallyCollapsed] = useState(false) + //const [isLoading, setIsLoading] = useState(true) // State to control the loader visibility - const init = useRef(false); + const init = useRef(false) const toggleSidebar = () => { - setIsSidebarCollapsed((prev) => !prev); - }; + setIsSidebarCollapsed((prev) => !prev) + } const manuallyToggleSidebar = () => { - setManuallyCollapsed((prev) => !prev); - toggleSidebar(); - }; + setManuallyCollapsed((prev) => !prev) + toggleSidebar() + } const handleResize = () => { if (!isManuallyCollapsed) { if (window.innerWidth <= 768) { - if (!isSidebarCollapsed) setIsSidebarCollapsed(true); + if (!isSidebarCollapsed) setIsSidebarCollapsed(true) } else { - if (isSidebarCollapsed) setIsSidebarCollapsed(false); + if (isSidebarCollapsed) setIsSidebarCollapsed(false) } } else { - if (isManuallyCollapsed) return; + if (isManuallyCollapsed) return // Reset manualCollapse if the window is resized to a state that should automatically collapse/expand the sidebar if (window.innerWidth > 768 && isSidebarCollapsed) { - setIsSidebarCollapsed(false); + setIsSidebarCollapsed(false) } else if (window.innerWidth <= 768 && !isSidebarCollapsed) { - setIsSidebarCollapsed(true); + setIsSidebarCollapsed(true) } } - }; + } useEffect(() => { if (!init.current) { - handleResize(); // Set the initial state based on the current window width - init.current = true; + handleResize() // Set the initial state based on the current window width + init.current = true } - window.addEventListener('resize', handleResize); - setTimeout(() => setIsLoading(false), 2000); // Simulate loading for 2 seconds + window.addEventListener('resize', handleResize) return () => { - window.removeEventListener('resize', handleResize); - }; - }, [isSidebarCollapsed, isManuallyCollapsed]); + window.removeEventListener('resize', handleResize) + } + }, [isSidebarCollapsed, isManuallyCollapsed]) - const [isOffCanvasOpen, setIsOffCanvasOpen] = useState(false); + const [isOffCanvasOpen, setIsOffCanvasOpen] = useState(false) const toggleOffCanvas = () => { - setIsOffCanvasOpen((prev) => !prev); - }; + setIsOffCanvasOpen((prev) => !prev) + } return ( - - {isLoading && } {/* Show loader if isLoading is true */} + + -
- -
- -
- {children} -
-
+
+ +
+ +
+ {children} +
+
+
-
- + + + - ); -}; + ) +} -export default Layout; +export default Layout diff --git a/src/ClientApp/app/page.tsx b/src/ClientApp/app/page.tsx index e085c53..eaf0bc9 100644 --- a/src/ClientApp/app/page.tsx +++ b/src/ClientApp/app/page.tsx @@ -1,53 +1,58 @@ -"use client"; // Add this line +"use client" -import { ApiRoutes, GetApiRoute } from "@/ApiRoutes"; -import { GetAccountsResponse } from "@/models/letsEncryptServer/cache/GetAccountsResponse"; -import { GetContactsResponse } from "@/models/letsEncryptServer/cache/GetContactsResponse"; -import { GetHostnamesResponse } from "@/models/letsEncryptServer/cache/GetHostnamesResponse"; -import { httpService } from "@/services/HttpService"; -import { useEffect, useRef, useState } from "react"; -import { useValidation, isValidEmail, isValidHostname } from "@/hooks/useValidation"; // Assuming hooks are in a hooks directory +import { ApiRoutes, GetApiRoute } from "@/ApiRoutes" +import { GetAccountsResponse } from "@/models/letsEncryptServer/cache/GetAccountsResponse" +import { GetContactsResponse } from "@/models/letsEncryptServer/cache/GetContactsResponse" +import { GetHostnamesResponse } from "@/models/letsEncryptServer/cache/GetHostnamesResponse" +import { httpService } from "@/services/httpService" +import { FormEvent, useEffect, useRef, useState } from "react" +import { useValidation, isValidEmail, isValidHostname } from "@/hooks/useValidation" +import { CustomButton, CustomInput } from "@/controls" +import { TrashIcon, PlusIcon } from "@heroicons/react/24/solid" interface CacheAccountHostname { hostname: string - expires: Date, + expires: Date isUpcomingExpire: boolean } interface CacheAccount { accountId: string + description?: string contacts: string[] hostnames: CacheAccountHostname[] + isEditMode: boolean } -// `app/page.tsx` is the UI for the `/` URL export default function Page() { - const [accounts, setAccounts] = useState([]); - const [isEditMode, setIsEditMode] = useState(false); + const [accounts, setAccounts] = useState([]) + const [initialAccounts, setInitialAccounts] = useState([]) + const { value: newContact, error: contactError, handleChange: handleContactChange - } = useValidation("", isValidEmail, "Invalid email format."); + } = useValidation("", isValidEmail, "Invalid email format.") const { value: newHostname, error: hostnameError, handleChange: handleHostnameChange - } = useValidation("", isValidHostname, "Invalid hostname format."); + } = useValidation("", isValidHostname, "Invalid hostname format.") - const init = useRef(false); + const init = useRef(false) useEffect(() => { - if (init.current) - return; + if (init.current) return const fetchAccounts = async () => { - const newAccounts: CacheAccount[] = []; + const newAccounts: CacheAccount[] = [] + const accountsResponse = await httpService.get(GetApiRoute(ApiRoutes.CACHE_GET_ACCOUNTS)) - const accountsResponse = await httpService.get(GetApiRoute(ApiRoutes.CACHE_GET_ACCOUNTS)); for (const accountId of accountsResponse.accountIds) { - const contactsResponse = await httpService.get(GetApiRoute(ApiRoutes.CACHE_GET_CONTACTS, accountId)); - const hostnamesResponse = await httpService.get(GetApiRoute(ApiRoutes.CACHE_GET_HOSTNAMES, accountId)); + const [contactsResponse, hostnamesResponse] = await Promise.all([ + httpService.get(GetApiRoute(ApiRoutes.CACHE_GET_CONTACTS, accountId)), + httpService.get(GetApiRoute(ApiRoutes.CACHE_GET_HOSTNAMES, accountId)) + ]) newAccounts.push({ accountId: accountId, @@ -56,173 +61,265 @@ export default function Page() { hostname: h.hostname, expires: new Date(h.expires), isUpcomingExpire: h.isUpcomingExpire - })) - }); + })), + isEditMode: false + }) } - setAccounts(newAccounts); - }; + setAccounts(newAccounts) + setInitialAccounts(JSON.parse(JSON.stringify(newAccounts))) // Clone initial state + } - fetchAccounts(); - init.current = true; - }, []); + fetchAccounts() + init.current = true + }, []) + + const toggleEditMode = (accountId: string) => { + setAccounts(accounts.map(account => + account.accountId === accountId ? { ...account, isEditMode: !account.isEditMode } : account + )) + } const deleteAccount = (accountId: string) => { - setAccounts(accounts.filter(account => account.accountId !== accountId)); + setAccounts(accounts.filter(account => account.accountId !== accountId)) + + // TODO: Revoke all certificates + // TODO: Remove from cache } const deleteContact = (accountId: string, contact: string) => { + const account = accounts.find(account => account.accountId === accountId) + if (account?.contacts.length ?? 0 < 1) return + + // TODO: Remove from cache + httpService.delete(GetApiRoute(ApiRoutes.CACHE_DELETE_CONTACT, accountId, contact)) + setAccounts(accounts.map(account => account.accountId === accountId ? { ...account, contacts: account.contacts.filter(c => c !== contact) } : account - )); + )) } const addContact = (accountId: string) => { if (newContact.trim() === "" || contactError) { - return; + return } + if (accounts.find(account => account.accountId === accountId)?.contacts.includes(newContact.trim())) + return + setAccounts(accounts.map(account => account.accountId === accountId ? { ...account, contacts: [...account.contacts, newContact.trim()] } : account - )); - handleContactChange(""); + )) + handleContactChange("") } const deleteHostname = (accountId: string, hostname: string) => { + const account = accounts.find(account => account.accountId === accountId) + if (account?.hostnames.length ?? 0 < 1) return + + // TODO: Revoke certificate + // TODO: Remove from cache + setAccounts(accounts.map(account => account.accountId === accountId ? { ...account, hostnames: account.hostnames.filter(h => h.hostname !== hostname) } : account - )); + )) } const addHostname = (accountId: string) => { if (newHostname.trim() === "" || hostnameError) { - return; + return } + if (accounts.find(account => account.accountId === accountId)?.hostnames.some(h => h.hostname === newHostname.trim())) + return + setAccounts(accounts.map(account => account.accountId === accountId ? { ...account, hostnames: [...account.hostnames, { hostname: newHostname.trim(), expires: new Date(), isUpcomingExpire: false }] } : account - )); - handleHostnameChange(""); + )) + handleHostnameChange("") } - useEffect(() => { - if (isEditMode) { - handleContactChange(newContact); - handleHostnameChange(newHostname); + const handleSubmit = async (e: FormEvent, accountId: string) => { + e.preventDefault() + + const account = accounts.find(acc => acc.accountId === accountId) + const initialAccount = initialAccounts.find(acc => acc.accountId === accountId) + + if (!account || !initialAccount) return + + const contactChanges = { + added: account.contacts.filter(contact => !initialAccount.contacts.includes(contact)), + removed: initialAccount.contacts.filter(contact => !account.contacts.includes(contact)) } - }, [isEditMode, newContact, newHostname]); + + const hostnameChanges = { + added: account.hostnames.filter(hostname => !initialAccount.hostnames.some(h => h.hostname === hostname.hostname)), + removed: initialAccount.hostnames.filter(hostname => !account.hostnames.some(h => h.hostname === hostname.hostname)) + } + + // Handle contact changes + if (contactChanges.added.length > 0) { + // TODO: POST new contacts + console.log("Added contacts:", contactChanges.added) + } + if (contactChanges.removed.length > 0) { + // TODO: DELETE removed contacts + console.log("Removed contacts:", contactChanges.removed) + } + + // Handle hostname changes + if (hostnameChanges.added.length > 0) { + // TODO: POST new hostnames + console.log("Added hostnames:", hostnameChanges.added) + } + if (hostnameChanges.removed.length > 0) { + // TODO: DELETE removed hostnames + console.log("Removed hostnames:", hostnameChanges.removed) + } + + // Save current state as initial state + setInitialAccounts(JSON.parse(JSON.stringify(accounts))) + toggleEditMode(accountId) + } return (
-
-

LetsEncrypt Client Dashboard

- -
+

LetsEncrypt Client Dashboard

{ accounts.map(account => (

Account: {account.accountId}

- {isEditMode && ( - - )} + toggleEditMode(account.accountId)} className="bg-blue-500 text-white px-3 py-1 rounded"> + {account.isEditMode ? "View Mode" : "Edit Mode"} +
-
-

Contacts:

-
    - { - account.contacts.map(contact => ( -
  • - {contact} - {isEditMode && ( - - )} -
  • - )) - } -
- {isEditMode && ( -
- handleContactChange(e.target.value)} - className="border p-2 rounded mr-2 flex-grow h-10" - placeholder="Add new contact" - /> - + {account.isEditMode ? ( +
handleSubmit(e, account.accountId)}> +
+

Description:

- )} - {isEditMode && contactError &&

{contactError}

} -
-
-

Hostnames:

-
    - { - account.hostnames.map(hostname => ( -
  • -
    - {hostname.hostname} - {hostname.expires.toDateString()} - - - {hostname.isUpcomingExpire ? 'Upcoming' : 'Not Upcoming'} - -
    - {isEditMode && ( - - )} -
  • - )) - } -
- {isEditMode && ( -
- handleHostnameChange(e.target.value)} - className="border p-2 rounded mr-2 flex-grow h-10" - placeholder="Add new hostname" - /> - +
+

Contacts:

+
    + { + account.contacts.map(contact => ( +
  • + {contact} + +
  • + )) + } +
+
+ + +
- )} - {isEditMode && hostnameError &&

{hostnameError}

} -
+
+

Hostnames:

+
    + { + account.hostnames.map(hostname => ( +
  • +
    + {hostname.hostname} - {hostname.expires.toDateString()} - + + {hostname.isUpcomingExpire ? 'Upcoming' : 'Not Upcoming'} + +
    + +
  • + )) + } +
+
+ + +
+
+
+ + + Submit + +
+ + ) : ( + <> +
+

Description:

+
+
+

Contacts:

+
    + { + account.contacts.map(contact => ( +
  • + {contact} +
  • + )) + } +
+
+
+

Hostnames:

+
    + { + account.hostnames.map(hostname => ( +
  • + {hostname.hostname} - {hostname.expires.toDateString()} - + + {hostname.isUpcomingExpire ? 'Upcoming' : 'Not Upcoming'} + +
  • + )) + } +
+
+ + )}
)) }
- ); + ) } diff --git a/src/ClientApp/components/footer.tsx b/src/ClientApp/components/footer.tsx index 7619904..3489b9a 100644 --- a/src/ClientApp/components/footer.tsx +++ b/src/ClientApp/components/footer.tsx @@ -1,13 +1,13 @@ -import React from 'react'; +import React from 'react' const Footer = () => { return (
-

© {new Date().getFullYear()} MAKS-IT

+

© {new Date().getFullYear()} MAKS-IT

) } export { Footer -}; +} diff --git a/src/ClientApp/components/loader/index.tsx b/src/ClientApp/components/loader/index.tsx index 9d08275..0961b2c 100644 --- a/src/ClientApp/components/loader/index.tsx +++ b/src/ClientApp/components/loader/index.tsx @@ -1,7 +1,33 @@ -import React from 'react' -import './loader.css' // Add your loader styles here +// components/Loader.tsx +import React, { useEffect } from 'react' +import { useSelector, useDispatch } from 'react-redux' +import { RootState } from '@/redux/store' +import { reset } from '@/redux/slices/loaderSlice' +import './loader.css' const Loader: React.FC = () => { + const dispatch = useDispatch() + const activeRequests = useSelector((state: RootState) => state.loader.activeRequests) + + useEffect(() => { + let timeout: NodeJS.Timeout | null = null + if (activeRequests > 0) { + timeout = setTimeout(() => { + dispatch(reset()) + }, 10000) // Adjust the timeout as necessary + } + + return () => { + if (timeout) { + clearTimeout(timeout) + } + } + }, [activeRequests, dispatch]) + + if (activeRequests === 0) { + return null + } + return (
@@ -10,4 +36,6 @@ const Loader: React.FC = () => { ) } -export default Loader +export { + Loader +} diff --git a/src/ClientApp/components/offcanvas.tsx b/src/ClientApp/components/offcanvas.tsx index ac28720..8fc87e6 100644 --- a/src/ClientApp/components/offcanvas.tsx +++ b/src/ClientApp/components/offcanvas.tsx @@ -1,4 +1,4 @@ -import React, { FC } from 'react'; +import React, { FC } from 'react' interface OffCanvasProps { isOpen: boolean diff --git a/src/ClientApp/components/sidemenu.tsx b/src/ClientApp/components/sidemenu.tsx index 981a6af..bf76891 100644 --- a/src/ClientApp/components/sidemenu.tsx +++ b/src/ClientApp/components/sidemenu.tsx @@ -1,9 +1,9 @@ -import React, { FC, useEffect, useRef } from 'react'; -import { FaHome, FaUser, FaCog, FaBars } from 'react-icons/fa'; +import React, { FC, useEffect, useRef } from 'react' +import { FaHome, FaUser, FaCog, FaBars } from 'react-icons/fa' interface SideMenuProps { - isCollapsed: boolean; - toggleSidebar: () => void; + isCollapsed: boolean + toggleSidebar: () => void } const SideMenu: FC = ({ isCollapsed, toggleSidebar }) => { @@ -33,9 +33,9 @@ const SideMenu: FC = ({ isCollapsed, toggleSidebar }) => {
- ); -}; + ) +} export { SideMenu -}; +} diff --git a/src/ClientApp/components/toast.tsx b/src/ClientApp/components/toast.tsx new file mode 100644 index 0000000..0d6c659 --- /dev/null +++ b/src/ClientApp/components/toast.tsx @@ -0,0 +1,52 @@ +// components/Toast.tsx +import React, { useEffect } from 'react' +import { ToastContainer, toast } from 'react-toastify' +import 'react-toastify/dist/ReactToastify.css' +import { useDispatch, useSelector } from 'react-redux' +import { RootState } from '@/redux/store' +import { clearToast } from '@/redux/slices/toastSlice' + +const Toast = () => { + const dispatch = useDispatch() + const toastState = useSelector((state: RootState) => state.toast) + + useEffect(() => { + if (toastState.message) { + switch (toastState.type) { + case 'success': + toast.success(toastState.message) + break + case 'error': + toast.error(toastState.message) + break + case 'info': + toast.info(toastState.message) + break + case 'warning': + toast.warn(toastState.message) + break + default: + toast(toastState.message) + break + } + dispatch(clearToast()) + } + }, [toastState, dispatch]) + + return ( + + ) +} + +export { Toast } diff --git a/src/ClientApp/components/topmenu.tsx b/src/ClientApp/components/topmenu.tsx index 33a9860..46bf021 100644 --- a/src/ClientApp/components/topmenu.tsx +++ b/src/ClientApp/components/topmenu.tsx @@ -9,7 +9,7 @@ interface TopMenuProps { } const TopMenu: FC = ({ onToggleOffCanvas }) => { - const [isMenuOpen, setIsMenuOpen] = useState(false); + const [isMenuOpen, setIsMenuOpen] = useState(false) const toggleMenu = () => { setIsMenuOpen(!isMenuOpen) diff --git a/src/ClientApp/controls/customButton.tsx b/src/ClientApp/controls/customButton.tsx new file mode 100644 index 0000000..b284f01 --- /dev/null +++ b/src/ClientApp/controls/customButton.tsx @@ -0,0 +1,28 @@ +"use client" +import React, { FC } from 'react' + +interface CustomButtonProps { + onClick?: () => void + className?: string + children: React.ReactNode + disabled?: boolean + type?: "button" | "submit" | "reset" +} + +const CustomButton: FC = (props) => { + + const { onClick, className = '', children, disabled = false, type = 'button' } = props + + return ( + + ) +} + +export { CustomButton } diff --git a/src/ClientApp/controls/customInput.tsx b/src/ClientApp/controls/customInput.tsx new file mode 100644 index 0000000..16ad6be --- /dev/null +++ b/src/ClientApp/controls/customInput.tsx @@ -0,0 +1,43 @@ +// components/CustomInput.tsx +"use client" +import React from 'react' + +interface CustomInputProps { + value: string + onChange: (value: string) => void + placeholder?: string + type: 'text' | 'password' | 'email' | 'number' | 'tel' | 'url' + error?: string + title?: string + inputClassName?: string + errorClassName?: string + className?: string +} + +const CustomInput: React.FC = ({ + value, + onChange, + placeholder = '', + type = 'text', + error, + title, + inputClassName = '', + errorClassName = '', + className = '' +}) => { + return ( +
+ {title && } + onChange(e.target.value)} + placeholder={placeholder} + className={inputClassName} + /> + {error &&

{error}

} +
+ ) +} + +export { CustomInput } diff --git a/src/ClientApp/controls/index.ts b/src/ClientApp/controls/index.ts new file mode 100644 index 0000000..43b80c5 --- /dev/null +++ b/src/ClientApp/controls/index.ts @@ -0,0 +1,7 @@ +import { CustomButton } from "./customButton" +import { CustomInput } from "./customInput" + +export { + CustomButton, + CustomInput +} \ No newline at end of file diff --git a/src/ClientApp/hooks/useValidation.tsx b/src/ClientApp/hooks/useValidation.tsx index 18e31a7..4238a89 100644 --- a/src/ClientApp/hooks/useValidation.tsx +++ b/src/ClientApp/hooks/useValidation.tsx @@ -1,37 +1,37 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect } from "react" // Helper functions for validation const isValidEmail = (email: string) => { - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - return emailRegex.test(email); + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + return emailRegex.test(email) } const isValidHostname = (hostname: string) => { - const hostnameRegex = /^(?!:\/\/)([a-zA-Z0-9-_]{1,63}\.?)+[a-zA-Z]{2,6}$/; - return hostnameRegex.test(hostname); + const hostnameRegex = /^(?!:\/\/)([a-zA-Z0-9-_]{1,63}\.?)+[a-zA-Z]{2,6}$/ + return hostnameRegex.test(hostname) } // Custom hook for input validation const useValidation = (initialValue: string, validateFn: (value: string) => boolean, errorMessage: string) => { - const [value, setValue] = useState(initialValue); - const [error, setError] = useState(""); + const [value, setValue] = useState(initialValue) + const [error, setError] = useState("") const handleChange = (newValue: string) => { - setValue(newValue); + setValue(newValue) if (newValue.trim() === "") { - setError("This field cannot be empty."); + setError("This field cannot be empty.") } else if (!validateFn(newValue.trim())) { - setError(errorMessage); + setError(errorMessage) } else { - setError(""); + setError("") } - }; + } useEffect(() => { - handleChange(initialValue); - }, [initialValue]); + handleChange(initialValue) + }, [initialValue]) - return { value, error, handleChange }; -}; + return { value, error, handleChange } +} -export { useValidation, isValidEmail, isValidHostname }; +export { useValidation, isValidEmail, isValidHostname } diff --git a/src/ClientApp/package-lock.json b/src/ClientApp/package-lock.json index 647c6e9..7c26696 100644 --- a/src/ClientApp/package-lock.json +++ b/src/ClientApp/package-lock.json @@ -8,12 +8,14 @@ "name": "my-nextjs-app", "version": "0.1.0", "dependencies": { + "@heroicons/react": "^2.1.3", "@reduxjs/toolkit": "^2.2.5", "next": "14.2.3", "react": "^18", "react-dom": "^18", "react-icons": "^5.2.1", - "react-redux": "^9.1.2" + "react-redux": "^9.1.2", + "react-toastify": "^10.0.5" }, "devDependencies": { "@types/node": "^20", @@ -106,6 +108,14 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@heroicons/react": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.1.3.tgz", + "integrity": "sha512-fEcPfo4oN345SoqdlCDdSa4ivjaKbk0jTd+oubcgNxnNgAfzysfwWfQUr+51wigiWHQQRiZNd1Ao0M5Y3M2EGg==", + "peerDependencies": { + "react": ">= 16" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.14", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", @@ -1121,6 +1131,14 @@ "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==" }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3798,6 +3816,18 @@ } } }, + "node_modules/react-toastify": { + "version": "10.0.5", + "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-10.0.5.tgz", + "integrity": "sha512-mNKt2jBXJg4O7pSdbNUfDdTsK9FIdikfsIE/yUCxbAEXl4HMyJaivrVFcn3Elvt5xvCQYhUZm+hqTIu1UXM3Pw==", + "dependencies": { + "clsx": "^2.1.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", diff --git a/src/ClientApp/package.json b/src/ClientApp/package.json index eedc346..84e3ccc 100644 --- a/src/ClientApp/package.json +++ b/src/ClientApp/package.json @@ -9,12 +9,14 @@ "lint": "next lint" }, "dependencies": { + "@heroicons/react": "^2.1.3", "@reduxjs/toolkit": "^2.2.5", "next": "14.2.3", "react": "^18", "react-dom": "^18", "react-icons": "^5.2.1", - "react-redux": "^9.1.2" + "react-redux": "^9.1.2", + "react-toastify": "^10.0.5" }, "devDependencies": { "@types/node": "^20", diff --git a/src/ClientApp/redux/slices/loaderSlice.ts b/src/ClientApp/redux/slices/loaderSlice.ts new file mode 100644 index 0000000..2bcaf3b --- /dev/null +++ b/src/ClientApp/redux/slices/loaderSlice.ts @@ -0,0 +1,31 @@ +// loaderSlice.ts +import { createSlice, PayloadAction } from '@reduxjs/toolkit' + +interface LoaderState { + activeRequests: number +} + +const initialState: LoaderState = { + activeRequests: 0, +} + +const loaderSlice = createSlice({ + name: 'loader', + initialState, + reducers: { + increment: (state) => { + state.activeRequests += 1 + }, + decrement: (state) => { + if (state.activeRequests > 0) { + state.activeRequests -= 1 + } + }, + reset: (state) => { + state.activeRequests = 0 + }, + }, +}) + +export const { increment, decrement, reset } = loaderSlice.actions +export default loaderSlice.reducer diff --git a/src/ClientApp/redux/slices/toastSlice.ts b/src/ClientApp/redux/slices/toastSlice.ts new file mode 100644 index 0000000..2752704 --- /dev/null +++ b/src/ClientApp/redux/slices/toastSlice.ts @@ -0,0 +1,33 @@ +// store/toastSlice.ts +import { createSlice, PayloadAction } from '@reduxjs/toolkit' + +interface ToastState { + message: string + type: 'success' | 'error' | 'info' | 'warning' +} + +const initialState: ToastState = { + message: '', + type: 'info', +} + +const toastSlice = createSlice({ + name: 'toast', + initialState, + reducers: { + showToast: (state, action: PayloadAction<{ + message: string + type: 'success' | 'error' | 'info' | 'warning' + }>) => { + state.message = action.payload.message + state.type = action.payload.type + }, + clearToast: (state) => { + state.message = '' + state.type = 'info' + }, + }, +}) + +export const { showToast, clearToast } = toastSlice.actions +export default toastSlice.reducer diff --git a/src/ClientApp/redux/store.ts b/src/ClientApp/redux/store.ts new file mode 100644 index 0000000..95687fd --- /dev/null +++ b/src/ClientApp/redux/store.ts @@ -0,0 +1,13 @@ +import { configureStore } from '@reduxjs/toolkit' +import loaderReducer from '@/redux/slices//loaderSlice' +import toastReducer from '@/redux/slices/toastSlice' + +export const store = configureStore({ + reducer: { + loader: loaderReducer, + toast: toastReducer, + }, +}) + +export type RootState = ReturnType +export type AppDispatch = typeof store.dispatch \ No newline at end of file diff --git a/src/ClientApp/services/HttpService.tsx b/src/ClientApp/services/HttpService.tsx index 267bdb4..bc47ef9 100644 --- a/src/ClientApp/services/HttpService.tsx +++ b/src/ClientApp/services/HttpService.tsx @@ -1,9 +1,13 @@ +import { store } from '@/redux/store'; +import { increment, decrement } from '@/redux/slices/loaderSlice'; +import { showToast } from '@/redux/slices/toastSlice'; + interface RequestInterceptor { (req: XMLHttpRequest): void; } interface ResponseInterceptor { - (response: T): T; + (response: T | null, error: ProblemDetails | null): T | void; } interface ProblemDetails { @@ -12,100 +16,144 @@ interface ProblemDetails { Status: number; } +interface HttpServiceCallbacks { + onIncrement?: () => void; + onDecrement?: () => void; + onShowToast?: (message: string, type: 'info' | 'error') => void; +} + class HttpService { - private requestInterceptors: Array = []; + private requestInterceptors: RequestInterceptor[] = []; private responseInterceptors: Array> = []; + private callbacks: HttpServiceCallbacks; - private request(method: string, url: string, data?: any): Promise { - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - xhr.open(method, url); + constructor(callbacks: HttpServiceCallbacks) { + this.callbacks = callbacks; + } - // Apply request interceptors - this.requestInterceptors.forEach(interceptor => { - try { - interceptor(xhr); - } catch (error) { - reject({ - Title: 'Request Interceptor Error', - Detail: error instanceof Error ? error.message : 'Unknown error', - Status: 0 - }); - return; - } - }); + private invokeIncrement(): void { + this.callbacks.onIncrement?.(); + } - // Set Content-Type header for JSON data - if (data && typeof data !== 'string') { - xhr.setRequestHeader('Content-Type', 'application/json'); - } + private invokeDecrement(): void { + this.callbacks.onDecrement?.(); + } - xhr.onload = () => { - if (xhr.status >= 200 && xhr.status < 300) { - let response: TResponse; - try { - response = JSON.parse(xhr.response); - } catch (error) { - reject({ - Title: 'Response Parse Error', - Detail: error instanceof Error ? error.message : 'Unknown error', - Status: xhr.status - }); - return; - } + private invokeShowToast(message: string, type: 'info' | 'error'): void { + this.callbacks.onShowToast?.(message, type); + } - // Apply response interceptors - try { - this.responseInterceptors.forEach(interceptor => { - response = interceptor(response); - }); - } catch (error) { - reject({ - Title: 'Response Interceptor Error', - Detail: error instanceof Error ? error.message : 'Unknown error', - Status: xhr.status - }); - return; - } + private async request(method: string, url: string, data?: any): Promise { + const xhr = new XMLHttpRequest(); + xhr.open(method, url); - resolve(response); - } else { - const problemDetails: ProblemDetails = { - Title: xhr.statusText, - Detail: xhr.responseText, - Status: xhr.status - }; - reject(problemDetails); - } - }; + this.handleRequestInterceptors(xhr); - xhr.onerror = () => { - const problemDetails: ProblemDetails = { - Title: 'Network Error', - Detail: null, - Status: 0 - }; - reject(problemDetails); - }; + if (data && typeof data !== 'string') { + xhr.setRequestHeader('Content-Type', 'application/json'); + } + this.invokeIncrement(); + + return new Promise((resolve) => { + xhr.onload = () => this.handleLoad(xhr, resolve); + xhr.onerror = () => this.handleNetworkError(resolve); xhr.send(data ? JSON.stringify(data) : null); }); } - public get(url: string): Promise { - return this.request('GET', url); + private handleRequestInterceptors(xhr: XMLHttpRequest): void { + this.requestInterceptors.forEach(interceptor => { + try { + interceptor(xhr); + } catch (error) { + const problemDetails = this.createProblemDetails('Request Interceptor Error', error, 0); + this.showProblemDetails(problemDetails); + } + }); } - public post(url: string, data: TRequest): Promise { - return this.request('POST', url, data); + private handleResponseInterceptors(response: TResponse | null, error: ProblemDetails | null): TResponse | null { + this.responseInterceptors.forEach(interceptor => { + try { + interceptor(response, error); + } catch (e) { + const problemDetails = this.createProblemDetails('Response Interceptor Error', e, 0); + this.showProblemDetails(problemDetails); + } + }); + return response; } - public put(url: string, data: TRequest): Promise { - return this.request('PUT', url, data); + private handleLoad(xhr: XMLHttpRequest, resolve: (value: TResponse | null) => void): void { + this.invokeDecrement(); + if (xhr.status >= 200 && xhr.status < 300) { + this.handleSuccessfulResponse(xhr, resolve); + } else { + this.handleErrorResponse(xhr, resolve); + } } - public delete(url: string): Promise { - return this.request('DELETE', url); + private handleSuccessfulResponse(xhr: XMLHttpRequest, resolve: (value: TResponse | null) => void): void { + try { + if (xhr.response) { + const response = JSON.parse(xhr.response); + resolve(this.handleResponseInterceptors(response, null) as TResponse); + } else { + resolve(null); + } + } catch (error) { + const problemDetails = this.createProblemDetails('Response Parse Error', error, xhr.status); + this.showProblemDetails(problemDetails); + resolve(null); + } + } + + private handleErrorResponse(xhr: XMLHttpRequest, resolve: (value: TResponse | null) => void): void { + const problemDetails = this.createProblemDetails(xhr.statusText, xhr.responseText, xhr.status); + this.showProblemDetails(problemDetails); + resolve(this.handleResponseInterceptors(null, problemDetails)); + } + + private handleNetworkError(resolve: (value: TResponse | null) => void): void { + const problemDetails = this.createProblemDetails('Network Error', null, 0); + this.showProblemDetails(problemDetails); + resolve(this.handleResponseInterceptors(null, problemDetails)); + } + + private createProblemDetails(title: string, detail: any, status: number): ProblemDetails { + return { + Title: title, + Detail: detail instanceof Error ? detail.message : String(detail), + Status: status + }; + } + + private showProblemDetails(problemDetails: ProblemDetails): void { + if (problemDetails.Detail) { + const errorMessages = problemDetails.Detail.split(','); + errorMessages.forEach(message => { + this.invokeShowToast(message.trim(), 'error'); + }); + } else { + this.invokeShowToast('Unknown error', 'error'); + } + } + + public async get(url: string): Promise { + return await this.request('GET', url); + } + + public async post(url: string, data: TRequest): Promise { + return await this.request('POST', url, data); + } + + public async put(url: string, data: TRequest): Promise { + return await this.request('PUT', url, data); + } + + public async delete(url: string): Promise { + return await this.request('DELETE', url); } public addRequestInterceptor(interceptor: RequestInterceptor): void { @@ -117,19 +165,32 @@ class HttpService { } } - -const httpService = new HttpService(); - -httpService.addRequestInterceptor(xhr => { - +// Instance of HttpService +const httpService = new HttpService({ + onIncrement: () => store.dispatch(increment()), + onDecrement: () => store.dispatch(decrement()), + onShowToast: (message: string, type: 'info' | 'error') => store.dispatch(showToast({ message, type })), }); -httpService.addResponseInterceptor(response => { +// Add loader state handling via interceptors +httpService.addRequestInterceptor((xhr) => { + // Additional request logic can be added here +}); +httpService.addResponseInterceptor((response, error) => { + // Additional response logic can be added here return response; }); +export { httpService }; + +// Example usage of the httpService +// async function fetchData() { +// const data = await httpService.get('/api/data'); +// if (data) { +// console.log('Data received:', data); +// } else { +// console.error('Failed to fetch data'); +// } +// } -export { - httpService -} diff --git a/src/LetsEncrypt/Entities/LetsEncrypt/RegistrationCache.cs b/src/LetsEncrypt/Entities/LetsEncrypt/RegistrationCache.cs index 5c11d7e..25d801d 100644 --- a/src/LetsEncrypt/Entities/LetsEncrypt/RegistrationCache.cs +++ b/src/LetsEncrypt/Entities/LetsEncrypt/RegistrationCache.cs @@ -13,11 +13,14 @@ public class CertificateCache { public class RegistrationCache { + #region Custom Properties /// /// Field used to identify cache by account id /// public Guid AccountId { get; set; } + public string? Description { get; set; } public string[]? Contacts { get; set; } + #endregion public Dictionary? CachedCerts { get; set; } diff --git a/src/LetsEncryptServer/Controllers/CacheController.cs b/src/LetsEncryptServer/Controllers/CacheController.cs index ce91fe2..fe9d764 100644 --- a/src/LetsEncryptServer/Controllers/CacheController.cs +++ b/src/LetsEncryptServer/Controllers/CacheController.cs @@ -1,6 +1,4 @@ - - -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using DomainResults.Mvc; @@ -12,44 +10,58 @@ namespace MaksIT.LetsEncryptServer.Controllers; [ApiController] [Route("api/[controller]")] -public class CacheController { - +public class CacheController : ControllerBase { private readonly Configuration _appSettings; - private readonly ICacheService _cacheService; + private readonly ICacheRestService _cacheService; public CacheController( - IOptions appSettings, - ICacheService cacheService - + IOptions appSettings, + ICacheService cacheService ) { _appSettings = appSettings.Value; - _cacheService = cacheService; + _cacheService = (ICacheRestService)cacheService; } - - [HttpGet("[action]")] + [HttpGet("accounts")] public async Task GetAccounts() { var result = await _cacheService.GetAccountsAsync(); return result.ToActionResult(); } - [HttpGet("[action]/{accountId}")] + #region Contacts + + [HttpGet("{accountId}/contacts")] public async Task GetContacts(Guid accountId) { var result = await _cacheService.GetContactsAsync(accountId); return result.ToActionResult(); } - - [HttpPost("[action]/{accountId}")] - public async Task SetContacts(Guid accountId, [FromBody] SetContactsRequest requestData) { - var result = await _cacheService.SetContactsAsync(accountId, requestData); + [HttpPut("{accountId}/contacts")] + public async Task PutContacts(Guid accountId, [FromBody] PutContactsRequest requestData) { + var result = await _cacheService.PutContactsAsync(accountId, requestData); return result.ToActionResult(); } - [HttpGet("[action]/{accountId}")] + [HttpPatch("{accountId}/contacts")] + public async Task PatchContacts(Guid accountId, [FromBody] PatchContactRequest requestData) { + var result = await _cacheService.PatchContactsAsync(accountId, requestData); + return result.ToActionResult(); + } + + [HttpDelete("{accountId}/contacts/{index}")] + public async Task DeleteContact(Guid accountId, int index) { + var result = await _cacheService.DeleteContactAsync(accountId, index); + return result.ToActionResult(); + } + #endregion + + #region Hostnames + + [HttpGet("{accountId}/hostnames")] public async Task GetHostnames(Guid accountId) { var result = await _cacheService.GetHostnames(accountId); return result.ToActionResult(); } -} + #endregion +} diff --git a/src/LetsEncryptServer/Controllers/CertsFlowController.cs b/src/LetsEncryptServer/Controllers/CertsFlowController.cs index e7f9629..0d9faae 100644 --- a/src/LetsEncryptServer/Controllers/CertsFlowController.cs +++ b/src/LetsEncryptServer/Controllers/CertsFlowController.cs @@ -1,126 +1,115 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; - using DomainResults.Mvc; - using MaksIT.LetsEncryptServer.Services; using Models.LetsEncryptServer.CertsFlow.Requests; +namespace MaksIT.LetsEncryptServer.Controllers { + [ApiController] + [Route("api/[controller]")] + public class CertsFlowController : ControllerBase { + private readonly Configuration _appSettings; + private readonly ICertsFlowService _certsFlowService; -namespace MaksIT.LetsEncryptServer.Controllers; + public CertsFlowController( + IOptions appSettings, + ICertsFlowService certsFlowService + ) { + _appSettings = appSettings.Value; + _certsFlowService = certsFlowService; + } -[ApiController] -[Route("api/[controller]")] -public class CertsFlowController : ControllerBase { + /// + /// Initialize certificate flow session + /// + /// sessionId + [HttpPost("configure-client")] + public async Task ConfigureClient() { + var result = await _certsFlowService.ConfigureClientAsync(); + return result.ToActionResult(); + } - private readonly IOptions _appSettings; - private readonly ICertsFlowService _certsFlowService; + /// + /// Retrieves terms of service + /// + /// Session ID + /// Terms of service + [HttpGet("terms-of-service/{sessionId}")] + public IActionResult TermsOfService(Guid sessionId) { + var result = _certsFlowService.GetTermsOfService(sessionId); + return result.ToActionResult(); + } - public CertsFlowController( - IOptions appSettings, - ICertsFlowService certsFlowService - ) { - _appSettings = appSettings; - _certsFlowService = certsFlowService; - } + /// + /// When a new certificate session is created, create or retrieve cache data by accountId + /// + /// Session ID + /// Account ID + /// Request data + /// Account ID + [HttpPost("{sessionId}/init/{accountId?}")] + public async Task Init(Guid sessionId, Guid? accountId, [FromBody] InitRequest requestData) { + var result = await _certsFlowService.InitAsync(sessionId, accountId, requestData); + return result.ToActionResult(); + } - /// - /// Initialize certificate flow session - /// - /// sessionId - [HttpPost("[action]")] - public async Task ConfigureClient() { - var result = await _certsFlowService.ConfigureClientAsync(); - return result.ToActionResult(); - } + /// + /// After account initialization, create a new order request + /// + /// Session ID + /// Request data + /// New order response + [HttpPost("{sessionId}/order")] + public async Task NewOrder(Guid sessionId, [FromBody] NewOrderRequest requestData) { + var result = await _certsFlowService.NewOrderAsync(sessionId, requestData); + return result.ToActionResult(); + } - [HttpGet("[action]/{sessionId}")] - public IActionResult TermsOfService(Guid sessionId) { - var result = _certsFlowService.GetTermsOfService(sessionId); - return result.ToActionResult(); - } + /// + /// Complete challenges for the new order + /// + /// Session ID + /// Challenges completion response + [HttpPost("{sessionId}/complete-challenges")] + public async Task CompleteChallenges(Guid sessionId) { + var result = await _certsFlowService.CompleteChallengesAsync(sessionId); + return result.ToActionResult(); + } - /// - /// When new certificate session is created, create or retrieve cache data by accountId - /// - /// - /// - /// - /// accountId - [HttpPost("[action]/{sessionId}/{accountId?}")] - public async Task Init(Guid sessionId, Guid? accountId, [FromBody] InitRequest requestData) { - var resurt = await _certsFlowService.InitAsync(sessionId, accountId, requestData); - return resurt.ToActionResult(); - } + /// + /// Get order status before certificate retrieval + /// + /// Session ID + /// Request data + /// Order status + [HttpGet("{sessionId}/order-status")] + public async Task GetOrder(Guid sessionId, [FromBody] GetOrderRequest requestData) { + var result = await _certsFlowService.GetOrderAsync(sessionId, requestData); + return result.ToActionResult(); + } - /// - /// After account initialization create new order request - /// - /// - /// - /// - [HttpPost("[action]/{sessionId}")] - public async Task NewOrder(Guid sessionId, [FromBody] NewOrderRequest requestData) { - var result = await _certsFlowService.NewOrderAsync(sessionId, requestData); - return result.ToActionResult(); - } + /// + /// Download certificates to local cache + /// + /// Session ID + /// Request data + /// Certificates download response + [HttpPost("{sessionId}/certificates/download")] + public async Task GetCertificates(Guid sessionId, [FromBody] GetCertificatesRequest requestData) { + var result = await _certsFlowService.GetCertificatesAsync(sessionId, requestData); + return result.ToActionResult(); + } - /// - /// After new order request complete challenges - /// - /// - /// - [HttpPost("[action]/{sessionId}")] - public async Task CompleteChallenges(Guid sessionId) { - var result = await _certsFlowService.CompleteChallengesAsync(sessionId); - return result.ToActionResult(); - } - - /// - /// Get order status before certs retrieval - /// - /// - /// - /// - [HttpPost("[action]/{sessionId}")] - public async Task GetOrder(Guid sessionId, [FromBody] GetOrderRequest requestData) { - var result = await _certsFlowService.GetOrderAsync(sessionId, requestData); - return result.ToActionResult(); - } - - /// - /// Download certs to local cache - /// - /// - /// - /// - [HttpPost("[action]/{sessionId}")] - public async Task GetCertificates(Guid sessionId, [FromBody] GetCertificatesRequest requestData) { - var result = await _certsFlowService.GetCertificatesAsync(sessionId, requestData); - return result.ToActionResult(); - } - - /// - /// Apply certs from local cache to remote server - /// - /// - /// - /// - [HttpPost("[action]/{sessionId}")] - public async Task ApplyCertificates(Guid sessionId, [FromBody] GetCertificatesRequest requestData) { - var result = await _certsFlowService.ApplyCertificatesAsync(sessionId, requestData); - return result.ToActionResult(); - } - - /// - /// Returns a list of hosts with upcoming SSL expiry - /// - /// - /// - [HttpGet("[action]/{sessionId}")] - public IActionResult HostsWithUpcomingSslExpiry(Guid sessionId) { - var result = _certsFlowService.HostsWithUpcomingSslExpiry(sessionId); - return result.ToActionResult(); + /// + /// Apply certificates from local cache to remote server + /// + /// Session ID + /// Request data + /// Certificates application response + [HttpPost("{sessionId}/certificates/apply")] + public async Task ApplyCertificates(Guid sessionId, [FromBody] GetCertificatesRequest requestData) { + var result = await _certsFlowService.ApplyCertificatesAsync(sessionId, requestData); + return result.ToActionResult(); + } } } - diff --git a/src/LetsEncryptServer/Services/CacheService.cs b/src/LetsEncryptServer/Services/CacheService.cs index b307683..e48bc13 100644 --- a/src/LetsEncryptServer/Services/CacheService.cs +++ b/src/LetsEncryptServer/Services/CacheService.cs @@ -5,7 +5,9 @@ using System.Text.Json; using DomainResults.Common; using MaksIT.Core.Extensions; using MaksIT.LetsEncrypt.Entities; +using MaksIT.Models; using MaksIT.Models.LetsEncryptServer.Cache.Requests; +using MaksIT.Models.LetsEncryptServer.Cache.Responses; using Models.LetsEncryptServer.Cache.Responses; namespace MaksIT.LetsEncryptServer.Services; @@ -14,14 +16,25 @@ public interface ICacheService { Task<(RegistrationCache?, IDomainResult)> LoadFromCacheAsync(Guid accountId); Task SaveToCacheAsync(Guid accountId, RegistrationCache cache); Task DeleteFromCacheAsync(Guid accountId); - Task<(GetAccountsResponse?, IDomainResult)> GetAccountsAsync(); - Task<(GetContactsResponse?, IDomainResult)> GetContactsAsync(Guid accountId); - Task SetContactsAsync(Guid accountId, SetContactsRequest requestData); - - Task<(GetHostnamesResponse?, IDomainResult)> GetHostnames(Guid accountId); } -public class CacheService : ICacheService, IDisposable { +public interface ICacheRestService { + Task<(GetAccountsResponse?, IDomainResult)> GetAccountsAsync(); + Task<(GetAccountResponse?, IDomainResult)> GetAccountAsync(Guid accountId); + + #region Contacts + Task<(GetContactsResponse?, IDomainResult)> GetContactsAsync(Guid accountId); + Task<(GetAccountResponse?, IDomainResult)> PutContactsAsync(Guid accountId, PutContactsRequest requestData); + Task<(GetAccountResponse?, IDomainResult)> PatchContactsAsync(Guid accountId, PatchContactRequest requestData); + Task DeleteContactAsync(Guid accountId, int index); + #endregion + + #region Hostnames + Task<(GetHostnamesResponse?, IDomainResult)> GetHostnames(Guid accountId); + #endregion +} + +public class CacheService : ICacheService, ICacheRestService, IDisposable { private readonly ILogger _logger; private readonly string _cacheDirectory; @@ -126,6 +139,9 @@ public class CacheService : ICacheService, IDisposable { } } + + + #region RestService public async Task<(GetAccountsResponse?, IDomainResult)> GetAccountsAsync() { await _cacheLock.WaitAsync(); @@ -133,13 +149,22 @@ public class CacheService : ICacheService, IDisposable { var cacheFiles = Directory.GetFiles(_cacheDirectory); if (cacheFiles == null) return IDomainResult.Success(new GetAccountsResponse { - AccountIds = Array.Empty() + Accounts = Array.Empty() }); - var accountIds = cacheFiles.Select(x => Path.GetFileNameWithoutExtension(x).ToGuid()).ToArray(); + var accountIds = cacheFiles.Select(x => Path.GetFileNameWithoutExtension(x).ToGuid()); + var accounts = new List(); + foreach (var accountId in accountIds) { + var (account, getAccountResult) = await GetAccountAsync(accountId); + if(!getAccountResult.IsSuccess || account == null) + return (null, getAccountResult); + + accounts.Add(account); + } + return IDomainResult.Success(new GetAccountsResponse { - AccountIds = accountIds + Accounts = accounts.ToArray() }); } catch (Exception ex) { @@ -153,6 +178,40 @@ public class CacheService : ICacheService, IDisposable { } } + public async Task<(GetAccountResponse?, IDomainResult)> GetAccountAsync(Guid accountId) { + + await _cacheLock.WaitAsync(); + + try { + var (registrationCache, gerRegistrationCacheResult) = await LoadFromCacheAsync(accountId); + if (!gerRegistrationCacheResult.IsSuccess || registrationCache == null) + return (null, gerRegistrationCacheResult); + + return IDomainResult.Success(new GetAccountResponse { + AccountId = accountId, + Description = registrationCache.Description, + Contacts = registrationCache.Contacts, + Hostnames = registrationCache.GetHostsWithUpcomingSslExpiry() + }); + } + catch (Exception ex) { + var message = "Error listing cache files"; + _logger.LogError(ex, message); + + return IDomainResult.Failed(message); + } + finally { + _cacheLock.Release(); + } + } + + + #region Contacts + /// + /// Retrieves the contacts list for the account. + /// + /// The ID of the account. + /// The contacts list and domain result. public async Task<(GetContactsResponse?, IDomainResult)> GetContactsAsync(Guid accountId) { var (cache, loadResult) = await LoadFromCacheAsync(accountId); if (!loadResult.IsSuccess || cache == null) @@ -163,16 +222,132 @@ public class CacheService : ICacheService, IDisposable { }); } + /// + /// Adds new contacts to the account. This method initializes the contacts list if it is null. + /// + /// The ID of the account. + /// The request containing the contacts to add. + /// The updated account response and domain result. + public async Task<(GetAccountResponse?, IDomainResult)> PostContactAsync(Guid accountId, PostContactsRequest requestData) { + var (cache, loadResult) = await LoadFromCacheAsync(accountId); + if (!loadResult.IsSuccess || cache == null) + return (null, loadResult); - public async Task SetContactsAsync(Guid accountId, SetContactsRequest requestData) { + var contacts = cache.Contacts?.ToList() ?? new List(); + + if (requestData.Contacts != null) { + contacts.AddRange(requestData.Contacts); + } + + cache.Contacts = contacts.ToArray(); + var saveResult = await SaveToCacheAsync(accountId, cache); + if (!saveResult.IsSuccess) + return (null, saveResult); + + return (new GetAccountResponse { + AccountId = accountId, + Description = cache.Description, + Contacts = cache.Contacts, + Hostnames = cache.GetHostsWithUpcomingSslExpiry() + }, IDomainResult.Success()); + } + + /// + /// Replaces the entire contacts list for the account. + /// + /// The ID of the account. + /// The request containing the new contacts list. + /// The updated account response and domain result. + public async Task<(GetAccountResponse?, IDomainResult)> PutContactsAsync(Guid accountId, PutContactsRequest requestData) { + var (cache, loadResult) = await LoadFromCacheAsync(accountId); + if (!loadResult.IsSuccess || cache == null) + return (null, loadResult); + + cache.Contacts = requestData.Contacts; + var saveResult = await SaveToCacheAsync(accountId, cache); + if (!saveResult.IsSuccess) + return (null, saveResult); + + return (new GetAccountResponse { + AccountId = accountId, + Description = cache.Description, + Contacts = cache.Contacts, + Hostnames = cache.GetHostsWithUpcomingSslExpiry() + }, IDomainResult.Success()); + } + + /// + /// Partially updates the contacts list for the account. Supports add, replace, and remove operations. + /// + /// The ID of the account. + /// The request containing the patch operations for contacts. + /// The updated account response and domain result. + public async Task<(GetAccountResponse?, IDomainResult)> PatchContactsAsync(Guid accountId, PatchContactRequest requestData) { + var (cache, loadResult) = await LoadFromCacheAsync(accountId); + if (!loadResult.IsSuccess || cache == null) + return (null, loadResult); + + var contacts = cache.Contacts?.ToList() ?? new List(); + + foreach (var contact in requestData.Contacts) { + switch (contact.Op) { + case PatchOperation.Add: + if (contact.Value != null) + contacts.Add(contact.Value); + break; + case PatchOperation.Replace: + if (contact.Index.HasValue && contact.Index.Value >= 0 && contact.Index.Value < contacts.Count && contact.Value != null) + contacts[contact.Index.Value] = contact.Value; + break; + case PatchOperation.Remove: + if (contact.Index.HasValue && contact.Index.Value >= 0 && contact.Index.Value < contacts.Count) + contacts.RemoveAt(contact.Index.Value); + break; + default: + return (null, IDomainResult.Failed("Invalid patch operation.")); + } + } + + cache.Contacts = contacts.ToArray(); + var saveResult = await SaveToCacheAsync(accountId, cache); + if (!saveResult.IsSuccess) + return (null, saveResult); + + return (new GetAccountResponse { + AccountId = accountId, + Description = cache.Description, + Contacts = cache.Contacts, + Hostnames = cache.GetHostsWithUpcomingSslExpiry() + }, IDomainResult.Success()); + } + + /// + /// Deletes a contact from the account by index. + /// + /// The ID of the account. + /// The index of the contact to remove. + /// The domain result indicating success or failure. + public async Task DeleteContactAsync(Guid accountId, int index) { var (cache, loadResult) = await LoadFromCacheAsync(accountId); if (!loadResult.IsSuccess || cache == null) return loadResult; - cache.Contacts = requestData.Contacts; - return await SaveToCacheAsync(accountId, cache); + var contacts = cache.Contacts?.ToList() ?? new List(); + + if (index >= 0 && index < contacts.Count) + contacts.RemoveAt(index); + + cache.Contacts = contacts.ToArray(); + var saveResult = await SaveToCacheAsync(accountId, cache); + if (!saveResult.IsSuccess) + return saveResult; + + return IDomainResult.Success(); } + #endregion + + #region Hostnames public async Task<(GetHostnamesResponse?, IDomainResult)> GetHostnames(Guid accountId) { var (cache, loadResult) = await LoadFromCacheAsync(accountId); if (!loadResult.IsSuccess || cache?.CachedCerts == null) @@ -199,6 +374,9 @@ public class CacheService : ICacheService, IDisposable { return IDomainResult.Success(response); } + #endregion + + #endregion public void Dispose() { diff --git a/src/LetsEncryptServer/Services/CertsFlowService.cs b/src/LetsEncryptServer/Services/CertsFlowService.cs index 6f53d23..e9a0007 100644 --- a/src/LetsEncryptServer/Services/CertsFlowService.cs +++ b/src/LetsEncryptServer/Services/CertsFlowService.cs @@ -24,8 +24,7 @@ public interface ICertsFlowService : ICertsFlowServiceBase { Task GetOrderAsync(Guid sessionId, GetOrderRequest requestData); Task GetCertificatesAsync(Guid sessionId, GetCertificatesRequest requestData); Task<(Dictionary?, IDomainResult)> ApplyCertificatesAsync(Guid sessionId, GetCertificatesRequest requestData); - (string[]?, IDomainResult) HostsWithUpcomingSslExpiry(Guid sessionId); -} + } public class CertsFlowService : ICertsFlowService { @@ -166,17 +165,6 @@ public class CertsFlowService : ICertsFlowService { } - public (string[]?, IDomainResult) HostsWithUpcomingSslExpiry(Guid sessionId) { - - var (hostnames, hostnamesResult) = _letsEncryptService.HostsWithUpcomingSslExpiry(sessionId); - if(!hostnamesResult.IsSuccess) - return (null, hostnamesResult); - - return IDomainResult.Success(hostnames); - } - - - diff --git a/src/Models/LetsEncryptServer/Cache/Requests/PatchContactRequest.cs b/src/Models/LetsEncryptServer/Cache/Requests/PatchContactRequest.cs new file mode 100644 index 0000000..c1bd057 --- /dev/null +++ b/src/Models/LetsEncryptServer/Cache/Requests/PatchContactRequest.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace MaksIT.Models.LetsEncryptServer.Cache.Requests { + + public class PatchContactRequest { + public List> Contacts { get; set; } + } +} diff --git a/src/Models/LetsEncryptServer/Cache/Requests/PostContactsRequest.cs b/src/Models/LetsEncryptServer/Cache/Requests/PostContactsRequest.cs new file mode 100644 index 0000000..2e64e95 --- /dev/null +++ b/src/Models/LetsEncryptServer/Cache/Requests/PostContactsRequest.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace MaksIT.Models.LetsEncryptServer.Cache.Requests { + public class PostContactsRequest : IValidatableObject { + + public required string[] Contacts { get; set; } + + public IEnumerable Validate(ValidationContext validationContext) { + + if (Contacts == null || Contacts.Length == 0) + yield return new ValidationResult("Contacts is required", new[] { nameof(Contacts) }); + } + } + } \ No newline at end of file diff --git a/src/Models/LetsEncryptServer/Cache/Requests/SetContactsRequest.cs b/src/Models/LetsEncryptServer/Cache/Requests/PutContactsRequest.cs similarity index 87% rename from src/Models/LetsEncryptServer/Cache/Requests/SetContactsRequest.cs rename to src/Models/LetsEncryptServer/Cache/Requests/PutContactsRequest.cs index 00dc7f5..94ce291 100644 --- a/src/Models/LetsEncryptServer/Cache/Requests/SetContactsRequest.cs +++ b/src/Models/LetsEncryptServer/Cache/Requests/PutContactsRequest.cs @@ -2,7 +2,7 @@ namespace MaksIT.Models.LetsEncryptServer.Cache.Requests { - public class SetContactsRequest : IValidatableObject { + public class PutContactsRequest : IValidatableObject { public required string[] Contacts { get; set; } diff --git a/src/Models/LetsEncryptServer/Cache/Responses/GetAccountResponse.cs b/src/Models/LetsEncryptServer/Cache/Responses/GetAccountResponse.cs new file mode 100644 index 0000000..6e75356 --- /dev/null +++ b/src/Models/LetsEncryptServer/Cache/Responses/GetAccountResponse.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Models.LetsEncryptServer.Cache.Responses { + public class GetAccountResponse { + public Guid AccountId { get; set; } + + public string? Description { get; set; } + + public string []? Contacts { get; set; } + + public string[]? Hostnames { get; set; } + } +} diff --git a/src/Models/LetsEncryptServer/Cache/Responses/GetAccountsResponse.cs b/src/Models/LetsEncryptServer/Cache/Responses/GetAccountsResponse.cs index db76fae..a56ba94 100644 --- a/src/Models/LetsEncryptServer/Cache/Responses/GetAccountsResponse.cs +++ b/src/Models/LetsEncryptServer/Cache/Responses/GetAccountsResponse.cs @@ -1,11 +1,12 @@ -using System; +using Models.LetsEncryptServer.Cache.Responses; +using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; -namespace Models.LetsEncryptServer.Cache.Responses { +namespace MaksIT.Models.LetsEncryptServer.Cache.Responses { public class GetAccountsResponse { - public Guid[] AccountIds { get; set; } + public GetAccountResponse[] Accounts { get; set; } } } diff --git a/src/Models/PatchAction.cs b/src/Models/PatchAction.cs new file mode 100644 index 0000000..d4f6cfc --- /dev/null +++ b/src/Models/PatchAction.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace MaksIT.Models { + public class PatchAction { + public PatchOperation Op { get; set; } // Enum for operation type + public int? Index { get; set; } // Index for the operation (for arrays/lists) + public T? Value { get; set; } // Value for the operation + } +} diff --git a/src/Models/PatchOperation.cs b/src/Models/PatchOperation.cs new file mode 100644 index 0000000..f899a54 --- /dev/null +++ b/src/Models/PatchOperation.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace MaksIT.Models { + public enum PatchOperation { + Add, + Remove, + Replace + } +}